feat: 去掉 Feed 顶栏统计并强化开源码桶
按帖子列表风格展示码桶,补齐语言/Star/Fork 与论坛作者关联回填,避免未绑定仓库与空列表。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -29,10 +29,11 @@ export const api = {
|
|||||||
pages: () => request<{ pages: SitePageSummary[] }>('/api/pages'),
|
pages: () => request<{ pages: SitePageSummary[] }>('/api/pages'),
|
||||||
page: (slug: string) => request<{ page: SitePage }>(`/api/pages/${encodeURIComponent(slug)}`),
|
page: (slug: string) => request<{ page: SitePage }>(`/api/pages/${encodeURIComponent(slug)}`),
|
||||||
boards: () => request<{ boards: Board[] }>('/api/boards'),
|
boards: () => request<{ boards: Board[] }>('/api/boards'),
|
||||||
projects: (params?: { page?: number; limit?: number }) => {
|
projects: (params?: { page?: number; limit?: number; q?: string }) => {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (params?.page) q.set('page', String(params.page));
|
if (params?.page) q.set('page', String(params.page));
|
||||||
if (params?.limit) q.set('limit', String(params.limit));
|
if (params?.limit) q.set('limit', String(params.limit));
|
||||||
|
if (params?.q?.trim()) q.set('q', params.q.trim());
|
||||||
const qs = q.toString();
|
const qs = q.toString();
|
||||||
return request<{ projects: GiteaProject[]; total: number; page: number; total_pages: number }>(
|
return request<{ projects: GiteaProject[]; total: number; page: number; total_pages: number }>(
|
||||||
`/api/projects${qs ? `?${qs}` : ''}`,
|
`/api/projects${qs ? `?${qs}` : ''}`,
|
||||||
|
|||||||
@@ -470,8 +470,21 @@ export interface GiteaProject {
|
|||||||
full_name: string;
|
full_name: string;
|
||||||
description: string;
|
description: string;
|
||||||
html_url: string;
|
html_url: string;
|
||||||
|
language?: string;
|
||||||
|
stars_count?: number;
|
||||||
|
forks_count?: number;
|
||||||
updated_at_remote?: string | null;
|
updated_at_remote?: string | null;
|
||||||
forum_user_id?: number;
|
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;
|
synced_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,80 +1,41 @@
|
|||||||
import { Users, FileText, LayoutGrid } from 'lucide-react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { Board, ForumStats } from '../api/types';
|
|
||||||
import { navigateFeed } from '../utils/feedCache';
|
import { navigateFeed } from '../utils/feedCache';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
boardId: number;
|
|
||||||
keyword: string;
|
keyword: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
titleOnly?: boolean;
|
|
||||||
boards: Board[];
|
|
||||||
stats: ForumStats | null;
|
|
||||||
postTotal: number;
|
postTotal: number;
|
||||||
/** 搜索页用 h1;首页/板块页中间栏不再展示标题 */
|
/** 搜索页用 h1;首页/板块页中间栏不再展示标题 */
|
||||||
titleAs?: 'h1' | 'h2';
|
titleAs?: 'h1' | 'h2';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FeedHeader({
|
export default function FeedHeader({
|
||||||
boardId,
|
|
||||||
keyword,
|
keyword,
|
||||||
tag = '',
|
tag = '',
|
||||||
author = '',
|
author = '',
|
||||||
boards,
|
|
||||||
stats,
|
|
||||||
postTotal,
|
postTotal,
|
||||||
titleAs = 'h1',
|
titleAs = 'h1',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const board = boards.find(b => b.id === boardId);
|
|
||||||
|
|
||||||
const isSearch = !!(keyword || author);
|
const isSearch = !!(keyword || author);
|
||||||
const isTag = !!tag;
|
const isTag = !!tag;
|
||||||
const filtered = isSearch || isTag;
|
const filtered = isSearch || isTag;
|
||||||
const inBoard = !filtered && boardId > 0 && !!board;
|
|
||||||
const TitleTag = titleAs;
|
const TitleTag = titleAs;
|
||||||
|
|
||||||
let title = '';
|
let title = '';
|
||||||
if (isTag) title = `标签:${tag}`;
|
if (isTag) title = `标签:${tag}`;
|
||||||
else if (isSearch) title = '搜索结果';
|
else if (isSearch) title = '搜索结果';
|
||||||
|
|
||||||
|
// 无标题且无操作时不占位
|
||||||
|
if (!filtered) return null;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="feed-head__title">
|
||||||
{title ? <TitleTag>{title}</TitleTag> : null}
|
{title ? <TitleTag>{title}</TitleTag> : null}
|
||||||
{!filtered && inBoard && (
|
<span className="feed-head__meta feed-head__meta--count">共 {postTotal} 条</span>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{isTag && (
|
{isTag && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import PostListSkeleton from './PostListSkeleton';
|
import PostListSkeleton from './PostListSkeleton';
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
|
|
||||||
/** 首页 Feed 初始骨架(标题区 + 排序栏 + 列表) */
|
/** 首页 Feed 初始骨架(排序栏 + 列表) */
|
||||||
export default function FeedPageSkeleton() {
|
export default function FeedPageSkeleton() {
|
||||||
const { limits } = useForumLimits();
|
const { limits } = useForumLimits();
|
||||||
|
|
||||||
@@ -11,15 +11,6 @@ export default function FeedPageSkeleton() {
|
|||||||
<div className="feed-panel">
|
<div className="feed-panel">
|
||||||
<div className="feed-top">
|
<div className="feed-top">
|
||||||
<div className="feed-top__bar">
|
<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>
|
<div className="feed-toolbar feed-toolbar--skeleton" aria-hidden>
|
||||||
<Skeleton className="skeleton--sort-tab" />
|
<Skeleton className="skeleton--sort-tab" />
|
||||||
<Skeleton className="skeleton--sort-tab" />
|
<Skeleton className="skeleton--sort-tab" />
|
||||||
|
|||||||
121
frontend/src/components/ProjectListItem.tsx
Normal file
121
frontend/src/components/ProjectListItem.tsx
Normal 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);
|
||||||
@@ -299,12 +299,9 @@ export default function HomePage() {
|
|||||||
<div className="feed-top">
|
<div className="feed-top">
|
||||||
<div className="feed-top__bar">
|
<div className="feed-top__bar">
|
||||||
<FeedHeader
|
<FeedHeader
|
||||||
boardId={boardId}
|
|
||||||
keyword={keyword}
|
keyword={keyword}
|
||||||
tag={tag}
|
tag={tag}
|
||||||
author={author}
|
author={author}
|
||||||
boards={ctx?.boards ?? []}
|
|
||||||
stats={ctx?.stats ?? null}
|
|
||||||
postTotal={postTotal}
|
postTotal={postTotal}
|
||||||
titleAs={isSiteHome ? 'h2' : 'h1'}
|
titleAs={isSiteHome ? 'h2' : 'h1'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,27 +1,15 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { GiteaProject } from '../api/types';
|
import type { GiteaProject } from '../api/types';
|
||||||
|
import ProjectListItem from '../components/ProjectListItem';
|
||||||
|
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||||
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
|
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() {
|
export default function ProjectsPage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
@@ -30,24 +18,46 @@ export default function ProjectsPage() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(0);
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [queryInput, setQueryInput] = useState('');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
usePageSEO({
|
usePageSEO({
|
||||||
title: '项目',
|
title: '开源码桶',
|
||||||
description: '公开项目列表',
|
description: '论坛会员在 Gitea 上的公开仓库',
|
||||||
keywords: joinSEOKeywords('项目', getCachedSiteBranding().keywords),
|
keywords: joinSEOKeywords('开源码桶', '项目', getCachedSiteBranding().keywords),
|
||||||
canonicalPath: '/projects',
|
canonicalPath: '/projects',
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
setLoading(true);
|
||||||
api.projects({ page, limit: 30 })
|
api.projects({ page, limit: 30, q: query || undefined })
|
||||||
.then(d => {
|
.then(d => {
|
||||||
|
if (cancelled) return;
|
||||||
setList(Array.isArray(d.projects) ? d.projects : []);
|
setList(Array.isArray(d.projects) ? d.projects : []);
|
||||||
setTotal(d.total ?? 0);
|
setTotal(d.total ?? 0);
|
||||||
setTotalPages(d.total_pages ?? 0);
|
setTotalPages(d.total_pages ?? 0);
|
||||||
})
|
})
|
||||||
.catch(e => notify.error(e instanceof Error ? e.message : '加载失败'))
|
.catch(e => {
|
||||||
.finally(() => setLoading(false));
|
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载失败');
|
||||||
}, [page]);
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [page, query]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-wrap">
|
<div className="page-wrap">
|
||||||
@@ -62,6 +72,17 @@ export default function ProjectsPage() {
|
|||||||
论坛会员在 Gitea 上的公开仓库
|
论坛会员在 Gitea 上的公开仓库
|
||||||
{total > 0 ? ` · 共 ${total} 个` : ''}
|
{total > 0 ? ` · 共 ${total} 个` : ''}
|
||||||
</p>
|
</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>
|
</header>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -69,38 +90,18 @@ export default function ProjectsPage() {
|
|||||||
) : list.length === 0 ? (
|
) : list.length === 0 ? (
|
||||||
<div className="empty-state list-page-panel__empty">
|
<div className="empty-state list-page-panel__empty">
|
||||||
<FolderGit2 className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
<FolderGit2 className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||||
<p>暂无同步到的公开项目</p>
|
<p>{query ? '没有匹配的仓库' : '暂无同步到的公开项目'}</p>
|
||||||
<p className="page-desc" style={{ marginTop: 8 }}>
|
<p className="page-desc" style={{ marginTop: 8 }}>
|
||||||
管理员可在「系统设置 → Gitea 同步」配置后执行同步
|
{query
|
||||||
|
? '试试其他关键词,或清空搜索'
|
||||||
|
: '需管理员在「系统设置 → Gitea 同步」开启并执行同步后才会出现仓库'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="content-surface projects-list">
|
<div className="content-surface post-list projects-list">
|
||||||
{list.map(p => (
|
{list.map(p => (
|
||||||
<article key={p.id} className="project-row">
|
<ProjectListItem key={p.id} project={p} />
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
|
|||||||
@@ -4132,57 +4132,64 @@ a.post-title:visited {
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.projects-list .project-row {
|
.projects-search {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 8px;
|
||||||
gap: 16px;
|
margin-top: 12px;
|
||||||
padding: 16px 18px;
|
max-width: 420px;
|
||||||
border-bottom: 1px solid var(--color-border);
|
padding: 0 12px;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
.projects-list .project-row:last-child {
|
.projects-search__icon {
|
||||||
border-bottom: none;
|
flex-shrink: 0;
|
||||||
|
color: var(--color-text-4);
|
||||||
}
|
}
|
||||||
.project-row-body {
|
.projects-search__input {
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
min-width: 0;
|
||||||
.project-row-title {
|
border: none;
|
||||||
margin: 0 0 6px;
|
outline: none;
|
||||||
font-size: 15px;
|
background: transparent;
|
||||||
font-weight: 600;
|
font-size: 13px;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.project-row-desc {
|
.projects-search__input::placeholder {
|
||||||
margin: 0 0 8px;
|
color: var(--color-text-4);
|
||||||
font-size: 13px;
|
}
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--color-text-2);
|
/* 码桶行复用帖子列表结构,仅补充语言徽标与徽章间距 */
|
||||||
display: -webkit-box;
|
.projects-list .project-list-item .post-excerpt {
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.project-row-meta {
|
.projects-list .project-list-item .post-meta-left {
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 4px 6px;
|
||||||
font-size: 12px;
|
overflow: visible;
|
||||||
color: var(--color-text-3);
|
|
||||||
}
|
}
|
||||||
.project-row-link {
|
.projects-list .project-list-item .post-meta-author {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
.project-lang-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 13px;
|
padding: 1px 6px;
|
||||||
color: var(--color-primary);
|
border-radius: 4px;
|
||||||
text-decoration: none;
|
font-size: 11px;
|
||||||
white-space: nowrap;
|
font-weight: 500;
|
||||||
padding-top: 2px;
|
line-height: 1.4;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
background: color-mix(in srgb, var(--color-border) 45%, transparent);
|
||||||
}
|
}
|
||||||
.project-row-link:hover {
|
.project-list-badges {
|
||||||
text-decoration: underline;
|
margin-left: 2px;
|
||||||
}
|
}
|
||||||
.projects-pager {
|
.projects-pager {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -730,6 +730,7 @@ func (h *Handlers) APIAdminUpdateOIDCSettings(c *gin.Context) {
|
|||||||
func (h *Handlers) APIProjects(c *gin.Context) {
|
func (h *Handlers) APIProjects(c *gin.Context) {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
size, _ := strconv.Atoi(c.DefaultQuery("limit", c.DefaultQuery("size", "30")))
|
size, _ := strconv.Atoi(c.DefaultQuery("limit", c.DefaultQuery("size", "30")))
|
||||||
|
q := strings.TrimSpace(c.Query("q"))
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
@@ -743,11 +744,12 @@ func (h *Handlers) APIProjects(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"projects": []any{}, "total": 0, "page": page, "total_pages": 0})
|
c.JSON(http.StatusOK, gin.H{"projects": []any{}, "total": 0, "page": page, "total_pages": 0})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
list, total, err := h.Gitea.ListPublic(page, size)
|
list, total, err := h.Gitea.ListPublic(page, size, q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
list = service.AttachGiteaOwners(list, h.Badge)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"projects": list,
|
"projects": list,
|
||||||
"total": total,
|
"total": total,
|
||||||
|
|||||||
@@ -4,19 +4,22 @@ import "time"
|
|||||||
|
|
||||||
// GiteaRepo 从 Gitea 同步的公开仓库缓存(侧栏 /projects 读取)
|
// GiteaRepo 从 Gitea 同步的公开仓库缓存(侧栏 /projects 读取)
|
||||||
type GiteaRepo struct {
|
type GiteaRepo struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
GiteaID int64 `gorm:"uniqueIndex;not null" json:"gitea_id"`
|
GiteaID int64 `gorm:"uniqueIndex;not null" json:"gitea_id"`
|
||||||
OwnerLogin string `gorm:"size:128;not null;index" json:"owner_login"`
|
OwnerLogin string `gorm:"size:128;not null;index" json:"owner_login"`
|
||||||
Name string `gorm:"size:255;not null" json:"name"`
|
Name string `gorm:"size:255;not null" json:"name"`
|
||||||
FullName string `gorm:"size:512;not null" json:"full_name"`
|
FullName string `gorm:"size:512;not null" json:"full_name"`
|
||||||
Description string `gorm:"size:2048;default:''" json:"description"`
|
Description string `gorm:"size:2048;default:''" json:"description"`
|
||||||
HTMLURL string `gorm:"size:1024;not null" json:"html_url"`
|
HTMLURL string `gorm:"size:1024;not null" json:"html_url"`
|
||||||
Private bool `gorm:"default:false" json:"private"`
|
Language string `gorm:"size:64;default:''" json:"language"`
|
||||||
UpdatedAtRemote *time.Time `json:"updated_at_remote"`
|
StarsCount int `gorm:"default:0" json:"stars_count"`
|
||||||
ForumUserID *uint `gorm:"index" json:"forum_user_id"`
|
ForksCount int `gorm:"default:0" json:"forks_count"`
|
||||||
SyncedAt time.Time `json:"synced_at"`
|
Private bool `gorm:"default:false" json:"private"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
UpdatedAtRemote *time.Time `json:"updated_at_remote"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
ForumUserID *uint `gorm:"index" json:"forum_user_id"`
|
||||||
|
SyncedAt time.Time `json:"synced_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (GiteaRepo) TableName() string {
|
func (GiteaRepo) TableName() string {
|
||||||
|
|||||||
251
service/gitea.go
251
service/gitea.go
@@ -21,18 +21,34 @@ var (
|
|||||||
ErrGiteaSyncBusy = errors.New("同步正在进行中,请稍后再试")
|
ErrGiteaSyncBusy = errors.New("同步正在进行中,请稍后再试")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GiteaOwnerView 仓库关联的论坛用户摘要(列表展示头像/徽标)
|
||||||
|
type GiteaOwnerView struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Role model.Role `json:"role"`
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
Exp int `json:"exp"`
|
||||||
|
Level int `json:"level"`
|
||||||
|
Badges []model.UserBadgeView `json:"badges,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// GiteaRepoView 前台展示
|
// GiteaRepoView 前台展示
|
||||||
type GiteaRepoView struct {
|
type GiteaRepoView struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
GiteaID int64 `json:"gitea_id"`
|
GiteaID int64 `json:"gitea_id"`
|
||||||
OwnerLogin string `json:"owner_login"`
|
OwnerLogin string `json:"owner_login"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
UpdatedAtRemote *time.Time `json:"updated_at_remote"`
|
Language string `json:"language"`
|
||||||
ForumUserID *uint `json:"forum_user_id,omitempty"`
|
StarsCount int `json:"stars_count"`
|
||||||
SyncedAt time.Time `json:"synced_at"`
|
ForksCount int `json:"forks_count"`
|
||||||
|
UpdatedAtRemote *time.Time `json:"updated_at_remote"`
|
||||||
|
ForumUserID *uint `json:"forum_user_id,omitempty"`
|
||||||
|
Owner *GiteaOwnerView `json:"owner,omitempty"`
|
||||||
|
SyncedAt time.Time `json:"synced_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GiteaService 从 Gitea API 同步会员公开仓库
|
// GiteaService 从 Gitea API 同步会员公开仓库
|
||||||
@@ -92,8 +108,8 @@ func (g *GiteaService) Stop() {
|
|||||||
g.wg.Wait()
|
g.wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListPublic 列出已同步的公开仓库
|
// ListPublic 列出已绑定论坛用户的公开仓库;q 模糊匹配仓库字段与论坛昵称/用户名
|
||||||
func (g *GiteaService) ListPublic(page, size int) ([]GiteaRepoView, int64, error) {
|
func (g *GiteaService) ListPublic(page, size int, q string) ([]GiteaRepoView, int64, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
@@ -103,14 +119,27 @@ func (g *GiteaService) ListPublic(page, size int) ([]GiteaRepoView, int64, error
|
|||||||
if size > 100 {
|
if size > 100 {
|
||||||
size = 100
|
size = 100
|
||||||
}
|
}
|
||||||
|
// 打开列表时自愈:按 owner_login≈username 回填缺失的 forum_user_id
|
||||||
|
BackfillForumUserIDs()
|
||||||
|
|
||||||
|
q = strings.TrimSpace(q)
|
||||||
|
db := model.DB.Model(&model.GiteaRepo{}).Where("private = ? AND forum_user_id IS NOT NULL AND forum_user_id > 0", false)
|
||||||
|
if q != "" {
|
||||||
|
like := "%" + escapeLikePattern(q) + "%"
|
||||||
|
db = db.Where(
|
||||||
|
`(full_name LIKE ? ESCAPE '\' OR description LIKE ? ESCAPE '\' OR owner_login LIKE ? ESCAPE '\'
|
||||||
|
OR forum_user_id IN (
|
||||||
|
SELECT id FROM users WHERE nickname LIKE ? ESCAPE '\' OR username LIKE ? ESCAPE '\'
|
||||||
|
))`,
|
||||||
|
like, like, like, like, like,
|
||||||
|
)
|
||||||
|
}
|
||||||
var total int64
|
var total int64
|
||||||
q := model.DB.Model(&model.GiteaRepo{}).Where("private = ?", false)
|
if err := db.Count(&total).Error; err != nil {
|
||||||
if err := q.Count(&total).Error; err != nil {
|
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
var rows []model.GiteaRepo
|
var rows []model.GiteaRepo
|
||||||
err := model.DB.Where("private = ?", false).
|
err := db.Order("updated_at_remote desc, id desc").
|
||||||
Order("updated_at_remote desc, id desc").
|
|
||||||
Offset((page - 1) * size).
|
Offset((page - 1) * size).
|
||||||
Limit(size).
|
Limit(size).
|
||||||
Find(&rows).Error
|
Find(&rows).Error
|
||||||
@@ -124,6 +153,176 @@ func (g *GiteaService) ListPublic(page, size int) ([]GiteaRepoView, int64, error
|
|||||||
return out, total, nil
|
return out, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BackfillForumUserIDs 将缺失 forum_user_id 的公开仓按 owner_login(忽略大小写)匹配论坛 username
|
||||||
|
func BackfillForumUserIDs() int {
|
||||||
|
var rows []model.GiteaRepo
|
||||||
|
if err := model.DB.Where("private = ? AND (forum_user_id IS NULL OR forum_user_id = 0)", false).
|
||||||
|
Find(&rows).Error; err != nil || len(rows) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var users []model.User
|
||||||
|
if err := model.DB.Select("id", "username").Where("banned = ?", false).Find(&users).Error; err != nil || len(users) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
byLogin := make(map[string]uint, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(u.Username))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byLogin[key] = u.ID
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for i := range rows {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(rows[i].OwnerLogin))
|
||||||
|
uid, ok := byLogin[key]
|
||||||
|
if !ok || uid == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := model.DB.Model(&rows[i]).Update("forum_user_id", uid).Error; err != nil {
|
||||||
|
log.Printf("[gitea] 回填 forum_user_id 失败 repo=%s: %v", rows[i].FullName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
log.Printf("[gitea] 回填 forum_user_id:%d 条", n)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachGiteaOwners 为列表项批量填充论坛用户摘要;丢弃无法解析到论坛用户的条目。
|
||||||
|
// 优先 forum_user_id;缺失时按 owner_login≈username 兜底,并回写 forum_user_id。
|
||||||
|
func AttachGiteaOwners(list []GiteaRepoView, badge *BadgeService) []GiteaRepoView {
|
||||||
|
if len(list) == 0 {
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
idSet := make(map[uint]struct{})
|
||||||
|
ids := make([]uint, 0, len(list))
|
||||||
|
loginSet := make(map[string]struct{})
|
||||||
|
logins := make([]string, 0, len(list))
|
||||||
|
for _, item := range list {
|
||||||
|
if item.ForumUserID != nil && *item.ForumUserID > 0 {
|
||||||
|
id := *item.ForumUserID
|
||||||
|
if _, ok := idSet[id]; !ok {
|
||||||
|
idSet[id] = struct{}{}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(item.OwnerLogin))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := loginSet[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
loginSet[key] = struct{}{}
|
||||||
|
logins = append(logins, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
byID := make(map[uint]*model.User)
|
||||||
|
byLogin := make(map[string]*model.User)
|
||||||
|
ptrs := make([]*model.User, 0, len(ids)+len(logins))
|
||||||
|
|
||||||
|
if len(ids) > 0 {
|
||||||
|
var users []model.User
|
||||||
|
if err := model.DB.Where("id IN ? AND banned = ?", ids, false).Find(&users).Error; err == nil {
|
||||||
|
for i := range users {
|
||||||
|
u := &users[i]
|
||||||
|
byID[u.ID] = u
|
||||||
|
ptrs = append(ptrs, u)
|
||||||
|
key := strings.ToLower(strings.TrimSpace(u.Username))
|
||||||
|
if key != "" {
|
||||||
|
byLogin[key] = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(logins) > 0 {
|
||||||
|
var users []model.User
|
||||||
|
if err := model.DB.Where("banned = ? AND LOWER(username) IN ?", false, logins).Find(&users).Error; err == nil {
|
||||||
|
for i := range users {
|
||||||
|
u := &users[i]
|
||||||
|
key := strings.ToLower(strings.TrimSpace(u.Username))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := byLogin[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byLogin[key] = u
|
||||||
|
byID[u.ID] = u
|
||||||
|
ptrs = append(ptrs, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ptrs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 去重 ptrs
|
||||||
|
seenPtr := make(map[uint]struct{}, len(ptrs))
|
||||||
|
uniq := make([]*model.User, 0, len(ptrs))
|
||||||
|
for _, u := range ptrs {
|
||||||
|
if u == nil || u.ID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seenPtr[u.ID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenPtr[u.ID] = struct{}{}
|
||||||
|
uniq = append(uniq, u)
|
||||||
|
}
|
||||||
|
if badge != nil {
|
||||||
|
badge.AttachBadgeSummaries(uniq, 3)
|
||||||
|
} else {
|
||||||
|
for _, u := range uniq {
|
||||||
|
u.Level = model.LevelFromExp(u.Exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]GiteaRepoView, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
item := list[i]
|
||||||
|
var u *model.User
|
||||||
|
if item.ForumUserID != nil && *item.ForumUserID > 0 {
|
||||||
|
u = byID[*item.ForumUserID]
|
||||||
|
}
|
||||||
|
if u == nil {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(item.OwnerLogin))
|
||||||
|
u = byLogin[key]
|
||||||
|
if u != nil {
|
||||||
|
uid := u.ID
|
||||||
|
item.ForumUserID = &uid
|
||||||
|
// 回写缺失关联,便于下次列表过滤命中
|
||||||
|
_ = model.DB.Model(&model.GiteaRepo{}).Where("id = ?", item.ID).
|
||||||
|
Update("forum_user_id", uid).Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nick := strings.TrimSpace(u.Nickname)
|
||||||
|
if nick == "" {
|
||||||
|
nick = u.Username
|
||||||
|
}
|
||||||
|
item.Owner = &GiteaOwnerView{
|
||||||
|
ID: u.ID,
|
||||||
|
Nickname: nick,
|
||||||
|
Avatar: u.Avatar,
|
||||||
|
Role: u.Role,
|
||||||
|
Verified: u.Verified,
|
||||||
|
Exp: u.Exp,
|
||||||
|
Level: model.LevelFromExp(u.Exp),
|
||||||
|
Badges: u.Badges,
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// SyncRepos 按论坛用户名拉取 Gitea 公开仓并 upsert
|
// SyncRepos 按论坛用户名拉取 Gitea 公开仓并 upsert
|
||||||
func (g *GiteaService) SyncRepos() (int, error) {
|
func (g *GiteaService) SyncRepos() (int, error) {
|
||||||
cfg := g.settings.GiteaSyncConfig()
|
cfg := g.settings.GiteaSyncConfig()
|
||||||
@@ -144,6 +343,9 @@ func (g *GiteaService) SyncRepos() (int, error) {
|
|||||||
g.mu.Unlock()
|
g.mu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// 同步前先回填历史缺失关联
|
||||||
|
BackfillForumUserIDs()
|
||||||
|
|
||||||
var users []model.User
|
var users []model.User
|
||||||
if err := model.DB.Where("banned = ?", false).Select("id", "username").Find(&users).Error; err != nil {
|
if err := model.DB.Where("banned = ?", false).Select("id", "username").Find(&users).Error; err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -183,6 +385,9 @@ func (g *GiteaService) SyncRepos() (int, error) {
|
|||||||
FullName: gr.FullName,
|
FullName: gr.FullName,
|
||||||
Description: truncStr(gr.Description, 2048),
|
Description: truncStr(gr.Description, 2048),
|
||||||
HTMLURL: gr.HTMLURL,
|
HTMLURL: gr.HTMLURL,
|
||||||
|
Language: truncStr(gr.Language, 64),
|
||||||
|
StarsCount: gr.StarsCount,
|
||||||
|
ForksCount: gr.ForksCount,
|
||||||
Private: false,
|
Private: false,
|
||||||
UpdatedAtRemote: parseGiteaTime(gr.UpdatedAt),
|
UpdatedAtRemote: parseGiteaTime(gr.UpdatedAt),
|
||||||
ForumUserID: &uid,
|
ForumUserID: &uid,
|
||||||
@@ -203,9 +408,12 @@ func (g *GiteaService) SyncRepos() (int, error) {
|
|||||||
"full_name": row.FullName,
|
"full_name": row.FullName,
|
||||||
"description": row.Description,
|
"description": row.Description,
|
||||||
"html_url": row.HTMLURL,
|
"html_url": row.HTMLURL,
|
||||||
|
"language": row.Language,
|
||||||
|
"stars_count": row.StarsCount,
|
||||||
|
"forks_count": row.ForksCount,
|
||||||
"private": false,
|
"private": false,
|
||||||
"updated_at_remote": row.UpdatedAtRemote,
|
"updated_at_remote": row.UpdatedAtRemote,
|
||||||
"forum_user_id": row.ForumUserID,
|
"forum_user_id": uid, // 写死 uint,避免 *uint 进 map 未落库
|
||||||
"synced_at": row.SyncedAt,
|
"synced_at": row.SyncedAt,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
log.Printf("[gitea] 更新仓库失败 %s: %v", gr.FullName, err)
|
log.Printf("[gitea] 更新仓库失败 %s: %v", gr.FullName, err)
|
||||||
@@ -231,6 +439,9 @@ func (g *GiteaService) SyncRepos() (int, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步后再回填一次(覆盖 owner_login 大小写等边角)
|
||||||
|
BackfillForumUserIDs()
|
||||||
|
|
||||||
log.Printf("[gitea] 同步完成:upsert %d 个公开仓库", upserted)
|
log.Printf("[gitea] 同步完成:upsert %d 个公开仓库", upserted)
|
||||||
return upserted, nil
|
return upserted, nil
|
||||||
}
|
}
|
||||||
@@ -241,6 +452,9 @@ type giteaAPIRepo struct {
|
|||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
StarsCount int `json:"stars_count"`
|
||||||
|
ForksCount int `json:"forks_count"`
|
||||||
Private bool `json:"private"`
|
Private bool `json:"private"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
Owner struct {
|
Owner struct {
|
||||||
@@ -310,6 +524,9 @@ func toGiteaRepoView(r model.GiteaRepo) GiteaRepoView {
|
|||||||
FullName: r.FullName,
|
FullName: r.FullName,
|
||||||
Description: r.Description,
|
Description: r.Description,
|
||||||
HTMLURL: r.HTMLURL,
|
HTMLURL: r.HTMLURL,
|
||||||
|
Language: r.Language,
|
||||||
|
StarsCount: r.StarsCount,
|
||||||
|
ForksCount: r.ForksCount,
|
||||||
UpdatedAtRemote: r.UpdatedAtRemote,
|
UpdatedAtRemote: r.UpdatedAtRemote,
|
||||||
ForumUserID: r.ForumUserID,
|
ForumUserID: r.ForumUserID,
|
||||||
SyncedAt: r.SyncedAt,
|
SyncedAt: r.SyncedAt,
|
||||||
|
|||||||
Reference in New Issue
Block a user