feat: 去掉 Feed 顶栏统计并强化开源码桶

按帖子列表风格展示码桶,补齐语言/Star/Fork 与论坛作者关联回填,避免未绑定仓库与空列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 03:58:04 +08:00
parent d929bf96aa
commit 012cac23de
11 changed files with 486 additions and 172 deletions

View File

@@ -29,10 +29,11 @@ export const api = {
pages: () => request<{ pages: SitePageSummary[] }>('/api/pages'),
page: (slug: string) => request<{ page: SitePage }>(`/api/pages/${encodeURIComponent(slug)}`),
boards: () => request<{ boards: Board[] }>('/api/boards'),
projects: (params?: { page?: number; limit?: number }) => {
projects: (params?: { page?: number; limit?: number; q?: string }) => {
const q = new URLSearchParams();
if (params?.page) q.set('page', String(params.page));
if (params?.limit) q.set('limit', String(params.limit));
if (params?.q?.trim()) q.set('q', params.q.trim());
const qs = q.toString();
return request<{ projects: GiteaProject[]; total: number; page: number; total_pages: number }>(
`/api/projects${qs ? `?${qs}` : ''}`,

View File

@@ -470,8 +470,21 @@ export interface GiteaProject {
full_name: string;
description: string;
html_url: string;
language?: string;
stars_count?: number;
forks_count?: number;
updated_at_remote?: string | null;
forum_user_id?: number;
owner?: {
id: number;
nickname: string;
avatar: string;
role: 'user' | 'admin';
verified?: boolean;
exp?: number;
level?: number;
badges?: UserBadge[];
};
synced_at: string;
}

View File

@@ -1,80 +1,41 @@
import { Users, FileText, LayoutGrid } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import type { Board, ForumStats } from '../api/types';
import { navigateFeed } from '../utils/feedCache';
interface Props {
boardId: number;
keyword: string;
tag?: string;
author?: string;
titleOnly?: boolean;
boards: Board[];
stats: ForumStats | null;
postTotal: number;
/** 搜索页用 h1首页/板块页中间栏不再展示标题 */
titleAs?: 'h1' | 'h2';
}
export default function FeedHeader({
boardId,
keyword,
tag = '',
author = '',
boards,
stats,
postTotal,
titleAs = 'h1',
}: Props) {
const nav = useNavigate();
const board = boards.find(b => b.id === boardId);
const isSearch = !!(keyword || author);
const isTag = !!tag;
const filtered = isSearch || isTag;
const inBoard = !filtered && boardId > 0 && !!board;
const TitleTag = titleAs;
let title = '';
if (isTag) title = `标签:${tag}`;
else if (isSearch) title = '搜索结果';
// 无标题且无操作时不占位
if (!filtered) return null;
return (
<div className={`feed-head${filtered ? ' feed-head--solo' : ' feed-head--stats-only'}`}>
<div className="feed-head feed-head--solo">
<div className="feed-head__title">
{title ? <TitleTag>{title}</TitleTag> : null}
{!filtered && inBoard && (
<div className="feed-head__stats">
<span className="feed-stat-chip">
<FileText aria-hidden />
<strong>{postTotal}</strong>
</span>
{stats && (
<span className="feed-stat-chip feed-stat-chip--muted" title="全站统计">
{stats.posts} · {stats.users}
</span>
)}
</div>
)}
{!filtered && !inBoard && stats && (
<div className="feed-head__stats">
<span className="feed-stat-chip">
<Users aria-hidden />
<strong>{stats.users}</strong>
</span>
<span className="feed-stat-chip">
<FileText aria-hidden />
<strong>{stats.posts}</strong>
</span>
<span className="feed-stat-chip">
<LayoutGrid aria-hidden />
<strong>{stats.boards}</strong>
</span>
</div>
)}
{filtered && (
<span className="feed-head__meta feed-head__meta--count"> {postTotal} </span>
)}
<span className="feed-head__meta feed-head__meta--count"> {postTotal} </span>
</div>
{isTag && (
<button

View File

@@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import PostListSkeleton from './PostListSkeleton';
import { useForumLimits } from '../hooks/useForumLimits';
/** 首页 Feed 初始骨架(标题区 + 排序栏 + 列表) */
/** 首页 Feed 初始骨架(排序栏 + 列表) */
export default function FeedPageSkeleton() {
const { limits } = useForumLimits();
@@ -11,15 +11,6 @@ export default function FeedPageSkeleton() {
<div className="feed-panel">
<div className="feed-top">
<div className="feed-top__bar">
<div className="feed-head feed-head--stats-only">
<div className="feed-head__title">
<div className="feed-head__stats">
<Skeleton className="skeleton--stat-chip" />
<Skeleton className="skeleton--stat-chip" />
<Skeleton className="skeleton--stat-chip" />
</div>
</div>
</div>
<div className="feed-toolbar feed-toolbar--skeleton" aria-hidden>
<Skeleton className="skeleton--sort-tab" />
<Skeleton className="skeleton--sort-tab" />

View File

@@ -0,0 +1,121 @@
import { memo } from 'react';
import { GitFork, Star } from 'lucide-react';
import UserBadges from './UserBadges';
import UserLink from './UserLink';
import type { GiteaProject } from '../api/types';
import { formatTime } from '../utils/content';
interface Props {
project: GiteaProject;
}
/** 开源码桶列表行:结构对齐帖子列表;作者一律用论坛用户,不展示 Gitea login */
function ProjectListItem({ project }: Props) {
const owner = project.owner;
// 未绑定论坛用户的仓库不应出现在列表;兜底不渲染
if (!owner?.id) return null;
const title = project.name || project.full_name;
const initial = owner.nickname?.[0] || '?';
const remoteIso = project.updated_at_remote ?? undefined;
const timeLabel = remoteIso ? formatTime(remoteIso) : '';
const stars = project.stars_count ?? 0;
const forks = project.forks_count ?? 0;
const open = () => {
if (!project.html_url) return;
window.open(project.html_url, '_blank', 'noopener,noreferrer');
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
open();
}
};
const onTitleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
e.stopPropagation();
return;
}
e.preventDefault();
e.stopPropagation();
open();
};
return (
<div
className="post-row post-row--v2 project-list-item"
role="link"
tabIndex={0}
onClick={open}
onKeyDown={onKeyDown}
>
<UserLink
user={owner}
showAvatar={false}
showName={false}
stopPropagation
className="post-avatar user-link--avatar-only"
>
{owner.avatar
? <img src={owner.avatar} alt="" loading="lazy" decoding="async" />
: initial}
</UserLink>
<div className="post-main">
<div className="post-text">
<div className="post-title-row">
{project.language ? (
<span className="project-lang-badge" title="主要语言">{project.language}</span>
) : null}
<a
href={project.html_url}
className="post-title"
target="_blank"
rel="noopener noreferrer"
onClick={onTitleClick}
>
{title}
</a>
</div>
{project.description ? (
<p className="post-excerpt">{project.description}</p>
) : null}
</div>
<div className="post-meta">
<div className="post-meta-left">
<UserLink
user={owner}
stopPropagation
className="post-meta-author"
showBadges={false}
/>
<UserBadges user={owner} compact maxAchievement={3} className="project-list-badges" />
{timeLabel ? (
<>
<span className="post-meta-sep post-meta-sep--before-time" aria-hidden>·</span>
<span className="post-meta-time post-meta-time--created" title={remoteIso}>
{timeLabel}
</span>
</>
) : null}
</div>
<div className="post-stats">
<span className={`post-stat${stars === 0 ? ' post-stat--zero' : ''}`} title="Stars">
<Star aria-hidden />
{stars}
</span>
<span className={`post-stat${forks === 0 ? ' post-stat--zero' : ''}`} title="Forks">
<GitFork aria-hidden />
{forks}
</span>
</div>
</div>
</div>
</div>
);
}
export default memo(ProjectListItem);

View File

@@ -299,12 +299,9 @@ export default function HomePage() {
<div className="feed-top">
<div className="feed-top__bar">
<FeedHeader
boardId={boardId}
keyword={keyword}
tag={tag}
author={author}
boards={ctx?.boards ?? []}
stats={ctx?.stats ?? null}
postTotal={postTotal}
titleAs={isSiteHome ? 'h2' : 'h1'}
/>

View File

@@ -1,27 +1,15 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, ExternalLink, FolderGit2 } from 'lucide-react';
import { ArrowLeft, FolderGit2, Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { GiteaProject } from '../api/types';
import ProjectListItem from '../components/ProjectListItem';
import { InFlowSiteFooter } from '../components/SiteFooter';
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
import { InFlowSiteFooter } from '../components/SiteFooter';
function formatRemoteTime(raw?: string | null): string {
if (!raw) return '';
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export default function ProjectsPage() {
const nav = useNavigate();
@@ -30,24 +18,46 @@ export default function ProjectsPage() {
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [loading, setLoading] = useState(true);
const [queryInput, setQueryInput] = useState('');
const [query, setQuery] = useState('');
usePageSEO({
title: '项目',
description: '公开项目列表',
keywords: joinSEOKeywords('项目', getCachedSiteBranding().keywords),
title: '开源码桶',
description: '论坛会员在 Gitea 上的公开仓库',
keywords: joinSEOKeywords('开源码桶', '项目', getCachedSiteBranding().keywords),
canonicalPath: '/projects',
});
useEffect(() => {
const t = window.setTimeout(() => {
const next = queryInput.trim();
setQuery(prev => {
if (prev === next) return prev;
setPage(1);
return next;
});
}, 300);
return () => window.clearTimeout(t);
}, [queryInput]);
useEffect(() => {
let cancelled = false;
setLoading(true);
api.projects({ page, limit: 30 })
api.projects({ page, limit: 30, q: query || undefined })
.then(d => {
if (cancelled) return;
setList(Array.isArray(d.projects) ? d.projects : []);
setTotal(d.total ?? 0);
setTotalPages(d.total_pages ?? 0);
})
.catch(e => notify.error(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, [page]);
.catch(e => {
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载失败');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [page, query]);
return (
<div className="page-wrap">
@@ -62,6 +72,17 @@ export default function ProjectsPage() {
Gitea
{total > 0 ? ` · 共 ${total}` : ''}
</p>
<label className="projects-search">
<Search className="projects-search__icon" size={16} aria-hidden />
<input
type="search"
className="projects-search__input"
placeholder="搜索仓库名、描述或所有者…"
value={queryInput}
onChange={e => setQueryInput(e.target.value)}
autoComplete="off"
/>
</label>
</header>
{loading ? (
@@ -69,38 +90,18 @@ export default function ProjectsPage() {
) : list.length === 0 ? (
<div className="empty-state list-page-panel__empty">
<FolderGit2 className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
<p></p>
<p>{query ? '没有匹配的仓库' : '暂无同步到的公开项目'}</p>
<p className="page-desc" style={{ marginTop: 8 }}>
Gitea
{query
? '试试其他关键词,或清空搜索'
: '需管理员在「系统设置 → Gitea 同步」开启并执行同步后才会出现仓库'}
</p>
</div>
) : (
<>
<div className="content-surface projects-list">
<div className="content-surface post-list projects-list">
{list.map(p => (
<article key={p.id} className="project-row">
<div className="project-row-body">
<h2 className="project-row-title">{p.full_name || p.name}</h2>
{p.description ? (
<p className="project-row-desc">{p.description}</p>
) : null}
<div className="project-row-meta">
<span>{p.owner_login}</span>
{p.updated_at_remote && (
<span> {formatRemoteTime(p.updated_at_remote)}</span>
)}
</div>
</div>
<a
className="project-row-link"
href={p.html_url}
target="_blank"
rel="noopener noreferrer"
>
Gitea
<ExternalLink size={14} aria-hidden />
</a>
</article>
<ProjectListItem key={p.id} project={p} />
))}
</div>
{totalPages > 1 && (

View File

@@ -4132,57 +4132,64 @@ a.post-title:visited {
margin-top: 12px;
}
.projects-list .project-row {
.projects-search {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
border-bottom: 1px solid var(--color-border);
align-items: center;
gap: 8px;
margin-top: 12px;
max-width: 420px;
padding: 0 12px;
height: 36px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg);
}
.projects-list .project-row:last-child {
border-bottom: none;
.projects-search__icon {
flex-shrink: 0;
color: var(--color-text-4);
}
.project-row-body {
min-width: 0;
.projects-search__input {
flex: 1;
}
.project-row-title {
margin: 0 0 6px;
font-size: 15px;
font-weight: 600;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 13px;
color: var(--color-text);
}
.project-row-desc {
margin: 0 0 8px;
font-size: 13px;
line-height: 1.5;
color: var(--color-text-2);
display: -webkit-box;
.projects-search__input::placeholder {
color: var(--color-text-4);
}
/* 码桶行复用帖子列表结构,仅补充语言徽标与徽章间距 */
.projects-list .project-list-item .post-excerpt {
-webkit-line-clamp: 2;
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
}
.project-row-meta {
display: flex;
.projects-list .project-list-item .post-meta-left {
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: var(--color-text-3);
gap: 4px 6px;
overflow: visible;
}
.project-row-link {
.projects-list .project-list-item .post-meta-author {
max-width: none;
}
.project-lang-badge {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
font-size: 13px;
color: var(--color-primary);
text-decoration: none;
white-space: nowrap;
padding-top: 2px;
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
line-height: 1.4;
color: var(--color-text-2);
background: color-mix(in srgb, var(--color-border) 45%, transparent);
}
.project-row-link:hover {
text-decoration: underline;
.project-list-badges {
margin-left: 2px;
}
.projects-pager {
display: flex;