diff --git a/backend/app/api/v1/stats_jobs.py b/backend/app/api/v1/stats_jobs.py index 562fb51a..4fe35184 100644 --- a/backend/app/api/v1/stats_jobs.py +++ b/backend/app/api/v1/stats_jobs.py @@ -3,6 +3,10 @@ 不走 DuckDB —— job_execution_logs 在 OLTP DB(PG/SQLite), 和 stats.py 的 DuckDB 分析路径独立。复用 json_cache 做轻量缓存。 + +2026-10-19(#121):admin-only。流水线健康是运维信息,消费端已迁至 +管理后台 /admin/jobs(前端迁移见同 issue);此前挂在用户侧 /stats/jobs +只是历史摆位,用户侧并无入口。router 级守卫与 users.py 同模式。 """ from __future__ import annotations @@ -12,7 +16,7 @@ from fastapi import APIRouter, Depends, Query from fastapi.responses import Response -from app.api.v1.auth import get_current_user +from app.api.v1.auth import get_current_admin_user from app.core.config import settings from app.core.database import async_session from app.repositories.job_execution_log_repo import JobExecutionLogRepository @@ -21,7 +25,7 @@ router = APIRouter( prefix="/stats/jobs", tags=["stats"], - dependencies=[Depends(get_current_user)], + dependencies=[Depends(get_current_admin_user)], ) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f9364797..e4d9ec7b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "next": "^16.3.8", "react": "19.2.4", "react-dom": "19.2.4", + "sonner": "^2.0.8", "zustand": "^5.0.14" }, "devDependencies": { @@ -6555,6 +6556,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sonner": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", + "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index d94e660d..e82e99c2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "next": "^16.3.8", "react": "19.2.4", "react-dom": "19.2.4", + "sonner": "^2.0.8", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/frontend/scripts/check-navigation-access.js b/frontend/scripts/check-navigation-access.js index 6b53bd99..385d2089 100644 --- a/frontend/scripts/check-navigation-access.js +++ b/frontend/scripts/check-navigation-access.js @@ -85,7 +85,7 @@ for (const pathName of ['/daily', '/weekly', '/monthly', '/stats', '/my-topics', assert.strictEqual(canAccessPath(pathName, user, enabledFeatures), true, `${pathName} should allow user`); } -for (const pathName of ['/admin', '/admin/sources', '/admin/model-eval', '/admin/contents', '/admin/mother-topics']) { +for (const pathName of ['/admin', '/admin/sources', '/admin/model-eval', '/admin/contents', '/admin/mother-topics', '/admin/jobs']) { assert(ADMIN_ONLY_PATHS.includes(pathName), `ADMIN_ONLY_PATHS should include ${pathName}`); assert.strictEqual(requiredAccessForPath(pathName), 'admin'); assert.strictEqual(canAccessPath(pathName, null), false, `${pathName} should require admin login`); diff --git a/frontend/src/app/admin/contents/page.tsx b/frontend/src/app/admin/contents/page.tsx index 6983931a..e582b174 100644 --- a/frontend/src/app/admin/contents/page.tsx +++ b/frontend/src/app/admin/contents/page.tsx @@ -1,18 +1,19 @@ 'use client'; -import React, { useState } from 'react'; -import { Download, Newspaper } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Copy, Download, ExternalLink, Newspaper } from 'lucide-react'; import { CATEGORIES, SOURCE_TYPE_COLOR_MAP } from '@/lib/design-tokens'; import { contentsApi } from '@/lib/api'; -import { Badge, Button, Panel, Toolbar, cx } from '@/components/ui'; +import { Badge, Button } from '@/components/ui'; import { Pagination } from '@/components/Pagination'; -import { ErrorState, LoadingState, EmptyState } from '@/components/StateView'; import { useFetch } from '@/hooks/useFetch'; import { AdminPageShell, AdminPageHeader } from '@/components/admin-ui'; +import { AdminEmptyState, AdminFilterChips, AdminFilters, AdminTable, toast } from '@/components/admin'; +import type { AdminFilterChip, AdminTableColumn } from '@/components/admin'; import type { ContentItem } from '@/types'; import { timeAgo } from '@/lib/datetime'; -// ─── Helpers ── +// ─── 常量 ── const CONTENT_STATUS_TONE: Record = { pending: 'neutral', @@ -20,7 +21,39 @@ const CONTENT_STATUS_TONE: Record = { error: 'red', }; -const PAGE_SIZE = 50; +const CONTENT_STATUS_LABEL: Record = { + pending: '待处理', + analyzed: '已分析', + error: '错误', +}; + +const DEFAULT_PAGE_SIZE = 50; +const PAGE_SIZE_OPTIONS = [10, 20, 50] as const; + +/* ─── URL query 同步(#121 四页试点之一)── + * 挂载时从 location.search 恢复初始状态;此后筛选/搜索/页码变化用 + * history.replaceState 覆写 query——不产生历史记录、不触发导航, + * 刻意不用 useSearchParams 以避开 Suspense 边界。 + * 默认值(第 1 页、全部分类、空搜索、每页 50)不写入 query,保持 URL 干净。 */ + +type ContentsQueryState = { page: number; pageSize: number; category: string; q: string }; + +function defaultContentsQueryState(): ContentsQueryState { + return { page: 1, pageSize: DEFAULT_PAGE_SIZE, category: '全部', q: '' }; +} + +function readInitialStateFromLocation(): ContentsQueryState { + const sp = new URLSearchParams(window.location.search); + const page = Math.max(1, Number.parseInt(sp.get('page') ?? '', 10) || 1); + const rawPageSize = Number.parseInt(sp.get('page_size') ?? '', 10); + const pageSize = (PAGE_SIZE_OPTIONS as readonly number[]).includes(rawPageSize) + ? rawPageSize + : DEFAULT_PAGE_SIZE; + const rawCategory = sp.get('category') ?? '全部'; + const category = (CATEGORIES as readonly string[]).includes(rawCategory) ? rawCategory : '全部'; + const q = sp.get('q') ?? ''; + return { page, pageSize, category, q }; +} /* ─── Page Component ── */ @@ -61,32 +94,228 @@ function exportContentsAsCSV(items: ContentItem[]): void { } export default function ContentsPage() { - const [page, setPage] = useState(1); - const [category, setCategory] = useState('全部'); + // 两段式恢复:SSR 与首帧一律用默认值(防 hydration mismatch——惰性读 + // location 会让带参数直达时服务端/客户端首帧不一致),挂载后再从 URL 恢复 + const [initial] = useState(defaultContentsQueryState); + const [page, setPage] = useState(initial.page); + const [pageSize, setPageSize] = useState(initial.pageSize); + const [category, setCategory] = useState(initial.category); + const [q, setQ] = useState(initial.q); + const [didRestore, setDidRestore] = useState(false); + + // 挂载后恢复 URL 状态(两段式的第二段),随后放行首拉 + useEffect(() => { + const restored = readInitialStateFromLocation(); + if (restored.page !== initial.page) setPage(restored.page); + if (restored.pageSize !== initial.pageSize) setPageSize(restored.pageSize); + if (restored.category !== initial.category) setCategory(restored.category); + if (restored.q !== initial.q) setQ(restored.q); + setDidRestore(true); + // 仅挂载时执行一次 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); type ListPayload = { items: ContentItem[]; total: number }; const { data, loading, error, refetch } = useFetch( async () => { - const params: Record = { page, page_size: PAGE_SIZE, admin_view: true }; + const params: Record = { page, page_size: pageSize, admin_view: true }; if (category && category !== '全部') params.category = category; + if (q.trim()) params.q = q.trim(); const res = await contentsApi.list(params); return { items: (res?.items || []) as ContentItem[], total: res?.total ?? (res?.items?.length ?? 0), }; }, - [page, category], + [page, pageSize, category, q], + // 恢复完成前不拉,避免默认值先打一发再被 URL 值重打的double fetch + { enabled: didRestore }, ); const items = data?.items ?? []; const total = data?.total ?? 0; - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + // URL query 覆写:与恢复逻辑对称,非默认值才落 query(恢复完成前不写, + // 防止把直达 URL 清成默认态) + useEffect(() => { + if (!didRestore) return; + const sp = new URLSearchParams(); + if (page > 1) sp.set('page', String(page)); + if (q.trim()) sp.set('q', q.trim()); + if (category !== '全部') sp.set('category', category); + if (pageSize !== DEFAULT_PAGE_SIZE) sp.set('page_size', String(pageSize)); + const qs = sp.toString(); + window.history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname); + }, [didRestore, page, pageSize, category, q]); + + // 恢复的页码可能越过当前筛选下的总页数(如书签里存着 page=99),收回最后一页 + useEffect(() => { + if (!loading && page > totalPages) setPage(totalPages); + }, [loading, page, totalPages]); + + /* ─── 筛选与动作 ── */ + + const hasActiveFilters = q.trim() !== '' || category !== '全部'; + + const handleSearchChange = (value: string) => { + setQ(value); + setPage(1); + }; const handleCategoryChange = (cat: string) => { setCategory(cat); setPage(1); }; + const handlePageSizeChange = (size: number) => { + setPageSize(size); + setPage(1); + }; + + const clearAllFilters = () => { + setQ(''); + setCategory('全部'); + setPage(1); + }; + + const chips: AdminFilterChip[] = []; + if (q.trim()) { + const keyword = q.trim(); + chips.push({ + key: 'q', + label: `搜索:${keyword.length > 16 ? `${keyword.slice(0, 16)}…` : keyword}`, + onRemove: () => handleSearchChange(''), + }); + } + if (category !== '全部') { + chips.push({ + key: 'category', + label: `分类:${category}`, + onRemove: () => handleCategoryChange('全部'), + }); + } + + const handleExportCsv = () => { + exportContentsAsCSV(items); + toast.success(`已导出本页 ${items.length} 条到 CSV`); + }; + + const handleCopyLink = async (item: ContentItem) => { + if (!item.url) return; + try { + await navigator.clipboard.writeText(item.url); + toast.success('原文链接已复制'); + } catch { + // 剪贴板被拒绝等场景:给可感知的失败反馈,不静默吞掉 + toast.error('复制失败,请打开原文后复制地址栏链接'); + } + }; + + /* ─── 列定义 ── */ + + const columns: AdminTableColumn[] = [ + { + key: 'title', + header: '标题', + render: (item) => + item.url ? ( + + {item.title || '无标题'} + + ) : ( + + {item.title || '无标题'} + + ), + }, + { + key: 'source', + header: '来源', + width: 170, + render: (item) => { + const color = SOURCE_TYPE_COLOR_MAP[item.source_type] || { bg: '#F3F4F6', color: '#6B7280' }; + return ( +
+ {item.source_name} + + {item.source_type} + +
+ ); + }, + }, + { + key: 'category', + header: '分类', + width: 96, + render: (item) => ( + + {item.category} + + ), + }, + { + key: 'tags', + header: '标签', + width: 200, + render: (item) => ( +
+ {item.tags && item.tags.length > 0 ? ( + item.tags.slice(0, 3).map((tag) => ( + + {tag} + + )) + ) : ( + - + )} + {item.tags && item.tags.length > 3 && ( + +{item.tags.length - 3} + )} +
+ ), + }, + { + key: 'published_at', + header: '发布时间', + width: 110, + render: (item) => {timeAgo(item.published_at)}, + }, + { + key: 'status', + header: '状态', + width: 88, + render: (item) => { + const statusKey = item.status || 'pending'; + const statusTone = CONTENT_STATUS_TONE[statusKey] || CONTENT_STATUS_TONE.pending; + return ( + + {CONTENT_STATUS_LABEL[statusKey] || statusKey} + + ); + }, + }, + { + key: 'actions', + header: '操作', + width: 92, + sticky: 'right', + render: (item) => , + }, + ]; + return ( 从各信源采集到的原始内容 · 共 {total} 条} /> - {/* Category Filter Bar */} - - {CATEGORIES.map((cat) => { - const active = category === cat; - return ( - - ); - })} - - - {/* Error Banner */} - {error && ( -
- void refetch()} panel={false} /> -
- )} - - {/* Loading State */} - {loading && ( - - )} - - {/* Table */} - {!loading && items.length > 0 && ( -
- + ) : undefined + } + > + + - {/* Empty State */} - {items.length === 0 && ( - - )} + {/* 已选条件 chips:单个移除 / 清空全部(无 chips 时整行不渲染) */} + +
- {/* Rows */} - {items.map((item) => ( - - ))} - - - )} + {/* 三态(加载/错误/空)由 AdminTable 内建,页面层不自造 */} + item.id} + loading={loading} + error={error} + onRetry={() => void refetch()} + empty={ + + 清空筛选 + + ) : undefined + } + /> + } + minWidth={980} + /> - {/* Pagination */} + {/* 分页:后端 /contents 支持 page_size(1–200),条数选择器随之落地 */} {!loading && total > 0 && ( 第 {page} / {totalPages} 页,共 {total} 条 @@ -190,82 +407,41 @@ export default function ContentsPage() { ); } -// ─── Content Row ─── - -function ContentRow({ item }: { item: ContentItem }) { - const statusKey = item.status || 'pending'; - const statusTone = CONTENT_STATUS_TONE[statusKey] || CONTENT_STATUS_TONE.pending; - const sourceTypeColor = SOURCE_TYPE_COLOR_MAP[item.source_type] || { bg: '#F3F4F6', color: '#6B7280' }; - const statusLabel: Record = { - pending: '待处理', - analyzed: '已分析', - error: '错误', - }; +/* ─── 行操作 ─── + * 本页只有两个高频动作(打开原文 / 复制链接),≤2 全部直接可见, + * 无需「···」折叠菜单;两者皆非破坏性,也没有需要二次确认的入口。 */ +function ContentRowActions({ + item, + onCopyLink, +}: { + item: ContentItem; + onCopyLink: (item: ContentItem) => void; +}) { + if (!item.url) { + return —; + } return ( -
- {/* 标题 */} -
- {item.url ? ( - - {item.title || '无标题'} - - ) : ( - {item.title || '无标题'} - )} -
- - {/* 来源 */} -
- - {item.source_name} - - - {item.source_type} - -
- - {/* 分类 */} -
- - {item.category} - -
- - {/* 标签 */} -
- {item.tags && item.tags.length > 0 - ? item.tags.slice(0, 3).map((tag) => ( - - {tag} - - )) - : - - } - {item.tags && item.tags.length > 3 && ( - +{item.tags.length - 3} - )} -
- - {/* 发布时间 */} -
- {timeAgo(item.published_at)} -
- - {/* 状态 */} -
- - {statusLabel[statusKey] || statusKey} - -
+
+ + + +
); } diff --git a/frontend/src/app/admin/jobs/page.tsx b/frontend/src/app/admin/jobs/page.tsx new file mode 100644 index 00000000..785e3a35 --- /dev/null +++ b/frontend/src/app/admin/jobs/page.tsx @@ -0,0 +1,360 @@ +'use client'; + +import React, { useState } from 'react'; +import { + Activity, + AlertTriangle, + CheckCircle2, + Clock, + ListChecks, + RefreshCw, + Timer, + XCircle, +} from 'lucide-react'; + +import { Badge, Button, Panel, cx, type Tone } from '@/components/ui'; +import { AdminPageHeader, AdminPageShell } from '@/components/admin-ui'; +import { ErrorState } from '@/components/StateView'; +import { useFetch } from '@/hooks/useFetch'; +import { statsJobsApi, type JobStatsByJobKey, type JobStatsResponse } from '@/lib/api'; +import { timeAgoShort as formatRelativeTime } from '@/lib/datetime'; + +const DAY_OPTIONS: Array<{ value: number; label: string }> = [ + { value: 1, label: '今日' }, + { value: 7, label: '7 天' }, + { value: 30, label: '30 天' }, + { value: 90, label: '90 天' }, +]; + +const JOB_STATUS_TONE: Record = { + SUCCESS: 'teal', + FAILED: 'red', + TIMEOUT: 'amber', + SKIPPED: 'neutral', + RUNNING: 'primary', +}; + +const STATUS_BAR_COLOR: Record = { + SUCCESS: '#10b981', + FAILED: '#ef4444', + TIMEOUT: '#f59e0b', + SKIPPED: '#94a3b8', + RUNNING: '#3b82f6', +}; + +function formatDuration(ms: number | null | undefined): string { + if (ms == null || ms <= 0) return '-'; + if (ms < 1000) return `${ms}ms`; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + const minutes = seconds / 60; + if (minutes < 60) return `${minutes.toFixed(1)}min`; + return `${(minutes / 60).toFixed(1)}h`; +} + +function successTone(rate: number): Tone { + if (rate >= 0.95) return 'teal'; + if (rate >= 0.8) return 'amber'; + return 'red'; +} + +function lastStatusTone(status: string | null | undefined): Tone { + if (!status) return 'neutral'; + return JOB_STATUS_TONE[status] || 'neutral'; +} + +export default function JobStatsPage() { + const [days, setDays] = useState(7); + const [selectedJobKey, setSelectedJobKey] = useState(null); + + const { data, loading, error, refetch } = useFetch( + () => statsJobsApi.get(days, selectedJobKey ?? undefined), + [days, selectedJobKey], + { initialLoading: false }, + ); + + const totals = data?.totals; + const byStatusMax = data + ? Math.max(...data.by_status.map((row) => row.count), 1) + : 1; + + return ( + + + 来自 job_execution_logs 表的运行数据,覆盖所有用{' '} + @track_job 装饰的全局任务。 + + } + actions={ +
+
+ {DAY_OPTIONS.map((opt) => ( + + ))} +
+ +
+ } + /> + + {error && ( +
+ void refetch()} panel={false} /> +
+ )} + + {/* ── KPI 卡片 ── */} +
+ } + label="总运行数" + value={totals ? totals.total_runs.toLocaleString() : '-'} + hint={data ? `${data.period.days} 天内` : ''} + /> + } + label="成功率" + value={totals ? `${(totals.success_rate * 100).toFixed(1)}%` : '-'} + hint={totals ? `${totals.success_count} 成功 / ${totals.failed_count + totals.timeout_count} 失败` : ''} + tone={successTone(totals?.success_rate ?? 1)} + /> + } + label="平均耗时" + value={formatDuration(totals?.avg_duration_ms)} + hint={totals ? `最长 ${formatDuration(totals.max_duration_ms)}` : ''} + /> + } + label="最近失败" + value={data ? data.recent_failures.length.toString() : '-'} + hint={totals ? `${totals.failed_count + totals.timeout_count} 次` : '近 10 条'} + /> +
+ + {/* ── 状态分布 ── */} + +

+ + 状态分布 +

+ {data && data.by_status.length > 0 ? ( +
+ {data.by_status.map((row) => ( +
+ + {row.status} + +
+
+
+ + {row.count.toLocaleString()} + +
+ ))} +
+ ) : ( +

暂无数据

+ )} + + + {/* ── Per-job_key 表格 ── */} + +
+

+ + 各任务运行情况 +

+ {selectedJobKey && ( + + )} +
+ {data && data.by_job_key.length > 0 ? ( +
+ + + + + + + + + + + + + {data.by_job_key.map((row) => ( + + setSelectedJobKey( + selectedJobKey === row.job_key ? null : row.job_key, + ) + } + /> + ))} + +
任务运行成功率平均耗时最近状态最近运行
+
+ ) : ( +

暂无数据

+ )} +
+ + {/* ── 最近失败 ── */} + +

+ + 最近失败 / 超时 + + (最多 10 条) + +

+ {data && data.recent_failures.length > 0 ? ( +
    + {data.recent_failures.map((row, idx) => ( +
  • +
    + {row.job_key} + + {row.status} + + {formatRelativeTime(row.started_at)} + · {formatDuration(row.duration_ms)} + +
    + {row.error_message && ( +
    +                    {row.error_message}
    +                  
    + )} +
  • + ))} +
+ ) : ( +

+ {data ? '🎉 该时间窗口内无失败记录' : ''} +

+ )} +
+ + ); +} + +function KpiCard({ + icon, + label, + value, + hint, + tone = 'neutral', +}: { + icon: React.ReactNode; + label: string; + value: string; + hint?: string; + tone?: Tone; +}) { + return ( +
+
+ {icon} + {label} +
+
+ {value} +
+ {hint && ( + + {hint} + + )} +
+ ); +} + +function JobKeyRow({ + row, + isSelected, + onClick, +}: { + row: JobStatsByJobKey; + isSelected: boolean; + onClick: () => void; +}) { + return ( + + + {row.job_key} + + + {row.runs} + + + + {(row.success_rate * 100).toFixed(1)}% + + + + {formatDuration(row.avg_duration_ms)} + + + + {row.last_status || '-'} + + + + {formatRelativeTime(row.last_run_at)} + + + ); +} diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index b75b5a24..d61454f7 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -1,12 +1,13 @@ 'use client'; -import React from 'react'; +import React, { useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { Loader2, ShieldCheck } from 'lucide-react'; import { useAppContext } from '@/components/ClientLayout'; import AdminSidebar from '@/components/AdminSidebar'; import AdminTopBar from '@/components/AdminTopBar'; import { Panel } from '@/components/ui'; +import { AdminToaster } from '@/components/admin'; export default function AdminLayout({ children, @@ -16,6 +17,15 @@ export default function AdminLayout({ const { currentUser, authLoading } = useAppContext(); const router = useRouter(); + // 未登录跳转放 effect:渲染期调 router.replace 会触发 + // 「Cannot update a component (Router) while rendering」(React 19 报错), + // 渲染期只出占位 + useEffect(() => { + if (!authLoading && !currentUser) { + router.replace('/login'); + } + }, [authLoading, currentUser, router]); + // 加载中:显示骨架 if (authLoading) { return ( @@ -28,11 +38,8 @@ export default function AdminLayout({ ); } - // 未登录:跳转登录页 + // 未登录:渲染跳转占位(跳转动作见上方 effect) if (!currentUser) { - if (typeof window !== 'undefined') { - router.replace('/login'); - } return (
@@ -48,8 +55,8 @@ export default function AdminLayout({ return (
-
- +
+

需要管理员权限

@@ -77,6 +84,8 @@ export default function AdminLayout({ {children}

+ {/* 操作反馈 Toast:系统级消息仍走 AdminNoticeBanner(#121 边界) */} +
); } diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index d909335e..154e442a 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -68,14 +68,14 @@ export default function AdminDashboardPage() { onClick={() => router.push(card.href)} className="group text-left" > - +
-
+
- {card.label} + {card.label}
-

+

{card.description}

diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx index 26069915..932fab83 100644 --- a/frontend/src/app/admin/settings/page.tsx +++ b/frontend/src/app/admin/settings/page.tsx @@ -1,14 +1,14 @@ 'use client'; import React, { useEffect, useState } from 'react'; +import type { LucideIcon } from 'lucide-react'; import { CheckCircle2, KeyRound, Loader2, LogIn, Mail, Plus, Server, Settings, Trash2, Webhook } from 'lucide-react'; -import { useAppContext } from '@/components/ClientLayout'; import { settingsApi } from '@/lib/api'; import type { EmailProviderConfig, NotificationWebhookConfig, OAuthProviderItem, WebhookItem, WebhookItemUpdate } from '@/lib/api/_analytics'; import { NOTIFICATION_EVENT_TYPES } from '@/lib/api/_analytics'; -import { Badge, Button, Panel } from '@/components/ui'; +import { Badge, Button, Panel, cx } from '@/components/ui'; import { AdminPageShell, AdminPageHeader, AdminNoticeBanner } from '@/components/admin-ui'; -import { LoadingState } from '@/components/StateView'; +import { AdminBlockSkeleton, AdminEmptyState, toast } from '@/components/admin'; const DEFAULT_FROM_NAME = 'TopicEye'; @@ -26,8 +26,95 @@ const SMTP_NOTES: string[] = [ '部分邮箱(如 QQ、163)需使用授权码而非登录密码', ]; +/** 通知推送说明项(原独立说明 Panel 合并进分组卡脚注) */ +const WEBHOOK_NOTES: React.ReactNode[] = [ + '支持配置多个 webhook,每个独立配置推送事件类型', + '支持飞书 / 钉钉 / Slack 群机器人 incoming webhook', + 'webhook URL 含 token,以加密方式存储,不会明文保存', + '推送场景:信源失败告警 / 日报生成完成 / 周报生成完成 / 今日精选推送 / 测试发送', + <> + 环境变量 ALERT_WEBHOOK_URL 仍生效(运维通道,不参与事件过滤) + , +]; + +/** 分组锚点导航:id 与下方分组卡 Panel 的 id 一一对应 */ +const SECTION_LINKS = [ + { id: 'settings-email', label: '邮件服务' }, + { id: 'settings-oauth', label: '第三方登录' }, + { id: 'settings-webhook', label: '通知推送' }, +] as const; + +// ── 表单基样式(#121 新原语:ink token + admin-accent 聚焦,替代散落的 gray/primary 旧写法) ── +const FIELD_LABEL_CLASS = 'mb-1.5 block text-xs font-medium text-ink-500'; +const INPUT_CLASS = + 'h-9 w-full rounded-xs border border-ink-200 bg-white px-3 text-[13px] text-ink-900 placeholder:text-ink-500 focus:border-admin-accent focus:outline-none'; +const INPUT_ICON_WRAPPER_CLASS = + 'flex items-center rounded-xs border border-ink-200 bg-white px-3 focus-within:border-admin-accent focus-within:outline-none'; + +/** 分组卡:带锚点 id 的 Panel,统一「图标 + 标题 + 右侧状态/操作」头部 */ +function SettingsSection({ + id, + icon: Icon, + title, + actions, + description, + children, +}: { + id: string; + icon: LucideIcon; + title: string; + actions?: React.ReactNode; + description?: React.ReactNode; + children: React.ReactNode; +}) { + return ( + +
+
+ +

{title}

+
+ {actions &&
{actions}
} +
+ {description &&

{description}

} + {children} +
+ ); +} + +/** 表单字段:统一 label + 控件结构 */ +function Field({ + label, + children, + className, +}: { + label: string; + children: React.ReactNode; + className?: string; +}) { + return ( + + ); +} + +/** 卡内说明脚注:合并原独立「配置说明」Panel */ +function SectionNotes({ title, items }: { title: string; items: readonly React.ReactNode[] }) { + return ( +
+

{title}

+
    + {items.map((item, i) => ( +
  • {item}
  • + ))} +
+
+ ); +} + export default function AdminSettingsPage() { - const { currentUser } = useAppContext(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [config, setConfig] = useState(null); @@ -38,7 +125,6 @@ export default function AdminSettingsPage() { const [smtpUsername, setSmtpUsername] = useState(''); const [smtpPassword, setSmtpPassword] = useState(''); const [smtpUseSsl, setSmtpUseSsl] = useState(false); - const [notice, setNotice] = useState(null); const [error, setError] = useState(null); // 通知推送 webhook 状态(多 webhook 列表) @@ -47,16 +133,17 @@ export default function AdminSettingsPage() { const [webhookSaving, setWebhookSaving] = useState(false); const [webhookTesting, setWebhookTesting] = useState(false); const [webhookTestResult, setWebhookTestResult] = useState(null); - const [webhookNotice, setWebhookNotice] = useState(null); const [webhookError, setWebhookError] = useState(null); // 第三方登录(OAuth)provider 配置状态 const [oauthProviders, setOauthProviders] = useState([]); const [oauthForms, setOauthForms] = useState>({}); const [oauthSaving, setOauthSaving] = useState(null); - const [oauthNotice, setOauthNotice] = useState(null); const [oauthError, setOauthError] = useState(null); + // 锚点导航当前高亮分组 + const [activeSection, setActiveSection] = useState(SECTION_LINKS[0].id); + const syncOauthForms = (providers: OAuthProviderItem[]) => { setOauthProviders(providers); setOauthForms( @@ -98,6 +185,33 @@ export default function AdminSettingsPage() { .finally(() => setLoading(false)); }, []); + // 锚点导航高亮:分组卡进入视口上部激活带即点亮。 + // 用 IntersectionObserver 而非 window scroll——本页滚动发生在 AdminPageShell + // 内部滚动容器里,scroll 事件不会冒泡到 window。 + useEffect(() => { + const sections = SECTION_LINKS + .map(({ id }) => document.getElementById(id)) + .filter((el): el is HTMLElement => el !== null); + if (sections.length === 0) return; + + const visible = new Set(); + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) visible.add(entry.target.id); + else visible.delete(entry.target.id); + } + // 激活带内可见的组取文档序最后一组:下滑时新组一进带即点亮 + const next = [...SECTION_LINKS].reverse().find(({ id }) => visible.has(id)); + if (next) setActiveSection(next.id); + }, + // 激活带 = 视口上部约 1/3,其余留给阅读区 + { rootMargin: '0px 0px -66% 0px' }, + ); + sections.forEach((section) => observer.observe(section)); + return () => observer.disconnect(); + }, []); + // 切换 SSL 时联动常用端口 const handleSslToggle = (checked: boolean) => { setSmtpUseSsl(checked); @@ -108,7 +222,6 @@ export default function AdminSettingsPage() { const handleSave = async () => { setSaving(true); setError(null); - setNotice(null); try { await settingsApi.updateEmailProvider({ provider: 'smtp', @@ -121,7 +234,7 @@ export default function AdminSettingsPage() { smtp_use_ssl: smtpUseSsl, }); setSmtpPassword(''); - setNotice('保存成功'); + toast.success('已保存'); const fresh = await settingsApi.getEmailProvider(); setConfig(fresh); } catch (err) { @@ -134,10 +247,9 @@ export default function AdminSettingsPage() { const handleSaveWebhook = async () => { setWebhookSaving(true); setWebhookError(null); - setWebhookNotice(null); try { await settingsApi.updateNotificationWebhook({ webhooks }); - setWebhookNotice('保存成功'); + toast.success('已保存'); const fresh = await settingsApi.getNotificationWebhook(); setWebhookConfig(fresh); setWebhooks( @@ -166,14 +278,13 @@ export default function AdminSettingsPage() { if (!form) return; setOauthSaving(provider); setOauthError(null); - setOauthNotice(null); try { await settingsApi.updateOAuthProvider(provider, { client_id: form.client_id, client_secret: form.client_secret, enabled: form.enabled, }); - setOauthNotice(`${OAUTH_PROVIDER_LABELS[provider] || provider} 已保存`); + toast.success('已保存'); const fresh = await settingsApi.getOAuthProviders(); syncOauthForms(fresh.providers); } catch (err) { @@ -232,410 +343,429 @@ export default function AdminSettingsPage() { } }; - // admin 守卫已收敛到 app/admin/layout.tsx - - if (loading) { - return ( - - - - ); - } + // admin 守卫已收敛到 app/admin/layout.tsx;本页 loading 期间保持 + // 分组卡结构渲染骨架(布局稳定防跳变),不做全屏 spinner 早退。 return ( - + - -
-

邮件服务

- {config?.smtp_password_configured ? ( +
+ {/* 分组卡主列 */} +
+ {error && ( + setError(null)}>{error} + )} + + {/* ── 邮件服务 ── */} + 已配置 ) : ( 未配置 - )} -
- - {/* 通用字段:发件人 */} - - - - - {/* SMTP 配置 */} - + ))} + > + {loading ? ( + + ) : ( + <> + +
+ + setFromEmail(e.target.value)} + type="email" + placeholder="noreply@yourdomain.com" + className="h-9 min-w-0 flex-1 bg-transparent px-2 text-[13px] text-ink-900 outline-none placeholder:text-ink-500" + /> +
+
-
- -
-
-

- 勾选 SSL 通常用 465;不勾选则用 STARTTLS(587)。会自动联动端口。 -

- - - -
); } diff --git a/frontend/src/app/admin/sources/_modals.tsx b/frontend/src/app/admin/sources/_modals.tsx index fc6236e8..96b3721c 100644 --- a/frontend/src/app/admin/sources/_modals.tsx +++ b/frontend/src/app/admin/sources/_modals.tsx @@ -18,12 +18,25 @@ import React from 'react'; import { Upload } from 'lucide-react'; import { Badge, Button, Panel } from '@/components/ui'; import { AdminModal, AdminModalFooter } from '@/components/admin-ui'; -import SourceForm, { type FormState } from '@/components/SourceForm'; +import SourceForm, { emptyForm, type FormState } from '@/components/SourceForm'; import type { BackendSource } from '@/components/SourceRow'; import { sourceTypeLabel } from '@/lib/source-sync-board'; import type { SourceBatchImportItem } from '@/lib/api'; import { useDialogFocus } from '@/components/useDialogFocus'; +/** + * 脏检测:与挂载时快照比较(两个模态都是条件挂载,每次打开重新取基线)。 + * 添加态再叠加「与空表单比较」——add 关闭不重置 form,重开时残留值仍算脏。 + */ +function useFormDirty(form: FormState, extraCleanBaseline?: FormState) { + const mountBaseline = React.useRef(form); + return React.useMemo(() => { + if (JSON.stringify(form) === JSON.stringify(mountBaseline.current)) return false; + if (extraCleanBaseline && JSON.stringify(form) === JSON.stringify(extraCleanBaseline)) return false; + return true; + }, [form, extraCleanBaseline]); +} + export function AddSourceModal({ form, setForm, @@ -37,8 +50,10 @@ export function AddSourceModal({ onCreate: () => void; onClose: () => void; }) { + // #121:表单脏时 ESC/遮罩关闭先过 AdminModal 内联确认,防误丢输入 + const isDirty = useFormDirty(form, emptyForm); return ( - + + {open && pos && ( +
{ + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return; + event.preventDefault(); + const buttons = Array.from( + menuRef.current?.querySelectorAll('[role="menuitem"]') ?? [], + ); + if (buttons.length === 0) return; + const activeIndex = buttons.findIndex((button) => button === document.activeElement); + const nextIndex = + event.key === 'ArrowDown' + ? (activeIndex + 1) % buttons.length + : (activeIndex - 1 + buttons.length) % buttons.length; + buttons[nextIndex]?.focus(); + }} + > + {items.map((item) => { + const Icon = item.icon; + return ( + + ); + })} +
+ )} +
+ ); +} + +// ─── 破坏性动作二次确认 ─── +// 删除 / 暂停(禁用类)统一收口到这一个确认弹窗,替代原生 confirm()。 + +interface ConfirmState { + title: string; + message: React.ReactNode; + confirmLabel: string; + onConfirm: () => void | Promise; +} + // ─── Page Component ─── export default function SourcesPage() { const { currentUser, updateEnabledFeatures, favoriteTargets, favoriteTargetPendingKeys, toggleFavoriteTarget, refreshCounts } = useAppContext(); + // 两段式恢复:SSR 与首帧一律用默认值(防 hydration mismatch——惰性读 + // location 会在带参数直达时让服务端/客户端首帧不一致),挂载后再从 URL + // 恢复一次;didRestore 门让首拉只发一次且 URL 不被默认值覆写清掉。 + const [initial] = useState(defaultSourcesQueryState); const [sources, setSources] = useState([]); const [mapSources, setMapSources] = useState([]); const [sourceFavoriteKeys, setSourceFavoriteKeys] = useState>(new Set()); const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [pageSize] = useState(20); - const [viewMode, setViewMode] = useState<'map' | 'sync' | 'list'>('map'); - const [searchKeyword, setSearchKeyword] = useState(''); - const [filterType, setFilterType] = useState(''); - const [filterEnabled, setFilterEnabled] = useState(undefined); + const [page, setPage] = useState(initial.page); + const [pageSize, setPageSize] = useState(initial.pageSize); + const [viewMode, setViewMode] = useState<'map' | 'sync' | 'list'>(initial.view); + const [searchKeyword, setSearchKeyword] = useState(initial.q); + const [filterType, setFilterType] = useState(initial.type); + const [filterEnabled, setFilterEnabled] = useState(initial.enabled); const [loading, setLoading] = useState(true); + // 列表加载失败 → AdminTable 内联错误 + 重试;系统级/变更类错误仍走顶部 AdminNoticeBanner + const [listError, setListError] = useState(null); const [error, setError] = useState(null); const [showAddModal, setShowAddModal] = useState(false); const [editingSource, setEditingSource] = useState(null); @@ -60,7 +333,7 @@ export default function SourcesPage() { const [newInstanceUrl, setNewInstanceUrl] = useState(''); const opmlInputRef = useRef(null); const batchImportInputRef = useRef(null); - const [, setImportingOPML] = useState(false); + const [importingOPML, setImportingOPML] = useState(false); const [showBatchImport, setShowBatchImport] = useState(false); const [evidenceProfileSource, setEvidenceProfileSource] = useState(null); const [batchImportContent, setBatchImportContent] = useState(''); @@ -68,12 +341,14 @@ export default function SourcesPage() { const [batchImportPreview, setBatchImportPreview] = useState([]); const [batchImportPreviewing, setBatchImportPreviewing] = useState(false); const [batchImporting, setBatchImporting] = useState(false); + const [confirmState, setConfirmState] = useState(null); + const [confirmBusy, setConfirmBusy] = useState(false); // ─── Fetch sources ─── const fetchSources = useCallback(async (p: number = 1): Promise => { try { setLoading(true); - setError(null); + setListError(null); const res = await sourcesApi.list({ page: p, page_size: pageSize, @@ -87,7 +362,7 @@ export default function SourcesPage() { setPage(p); return true; } catch (err: unknown) { - setError(err instanceof Error ? err.message : '加载信源列表失败'); + setListError(err instanceof Error ? err.message : '加载信源列表失败'); return false; } finally { setLoading(false); @@ -144,11 +419,49 @@ export default function SourcesPage() { } }, []); + // 挂载后恢复 URL 状态(两段式的第二段),随后放行首拉 + const [didRestore, setDidRestore] = useState(false); + useEffect(() => { + const restored = readInitialStateFromLocation(); + if (restored.page !== initial.page) setPage(restored.page); + if (restored.pageSize !== initial.pageSize) setPageSize(restored.pageSize); + if (restored.view !== initial.view) setViewMode(restored.view); + if (restored.q !== initial.q) setSearchKeyword(restored.q); + if (restored.type !== initial.type) setFilterType(restored.type); + if (restored.enabled !== initial.enabled) setFilterEnabled(restored.enabled); + setDidRestore(true); + // 仅挂载时执行一次 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 首拉与筛选/每页条数变化的重拉。翻页不进这个 effect(由分页器直接调 + // fetchSources),避免翻一个页码连带全量信源地图重拉。 + useEffect(() => { + if (!didRestore) return; + void fetchSources(page); + void fetchSourceMap(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [didRestore, fetchSources, fetchSourceMap]); + + // RSSHub 实例不随筛选变化,只在挂载时拉一次 useEffect(() => { - fetchSources(1); - fetchSourceMap(); fetchRSSHubInstances(); - }, [fetchSources, fetchSourceMap, fetchRSSHubInstances]); + }, [fetchRSSHubInstances]); + + // URL query 覆写:与恢复逻辑对称,非默认值才落 query(恢复完成前不写, + // 防止把直达 URL 清成默认态) + useEffect(() => { + if (!didRestore) return; + const sp = new URLSearchParams(); + if (searchKeyword.trim()) sp.set('q', searchKeyword.trim()); + if (filterType) sp.set('type', filterType); + if (filterEnabled !== undefined) sp.set('enabled', filterEnabled ? '1' : '0'); + if (page > 1) sp.set('page', String(page)); + if (pageSize !== DEFAULT_PAGE_SIZE) sp.set('page_size', String(pageSize)); + if (viewMode !== 'map') sp.set('view', viewMode); + const qs = sp.toString(); + window.history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname); + }, [didRestore, searchKeyword, filterType, filterEnabled, page, pageSize, viewMode]); useEffect(() => { const ids = Array.from(new Set([...sources, ...mapSources].map((source) => source.id))); @@ -180,8 +493,30 @@ export default function SourcesPage() { return () => { cancelled = true; }; }, [sources, mapSources]); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + // 恢复的页码可能越过当前筛选下的总页数(书签里存着 page=99),收回最后一页 + useEffect(() => { + if (!loading && page > totalPages) void fetchSources(totalPages); + }, [loading, page, totalPages, fetchSources]); + + // ─── 确认弹窗执行 ─── + const runConfirm = async () => { + if (!confirmState || confirmBusy) return; + setConfirmBusy(true); + try { + await confirmState.onConfirm(); + setConfirmState(null); + } catch { + // 各 onConfirm 自带错误上抛(AdminNoticeBanner),这里只防止确认框卡死 + } finally { + setConfirmBusy(false); + } + }; + // ─── Toggle instance enabled ─── const toggleInstance = async (url: string) => { + const target = rsshubInstances.find((i) => i.url === url); const updated = rsshubInstances.map((i) => i.url === url ? { ...i, enabled: !i.enabled } : i ); @@ -189,6 +524,7 @@ export default function SourcesPage() { try { setRsshubSaving(true); await settingsApi.updateRSSHubInstances(updated); + toast.success(`实例已${target?.enabled ? '禁用' : '启用'}`); } catch (err: unknown) { setRsshubError(err instanceof Error ? err.message : '更新失败'); setRsshubInstances((prev) => prev.map((i) => i.url === url ? { ...i, enabled: !i.enabled } : i)); @@ -214,6 +550,7 @@ export default function SourcesPage() { try { setRsshubSaving(true); await settingsApi.updateRSSHubInstances(updated); + toast.success('实例已添加'); } catch (err: unknown) { setRsshubError(err instanceof Error ? err.message : '添加失败'); setRsshubInstances((prev) => prev.filter((i) => i.url !== url)); @@ -222,20 +559,32 @@ export default function SourcesPage() { } }; - // ─── Delete instance ─── - const deleteInstance = async (url: string) => { - if (!confirm(`删除实例 ${url}?`)) return; - const prev = rsshubInstances; - setRsshubInstances((prev) => prev.filter((i) => i.url !== url)); - try { - setRsshubSaving(true); - await settingsApi.updateRSSHubInstances(rsshubInstances.filter((i) => i.url !== url)); - } catch (err: unknown) { - setRsshubError(err instanceof Error ? err.message : '删除失败'); - setRsshubInstances(prev); - } finally { - setRsshubSaving(false); - } + // ─── Delete instance(破坏性动作:二次确认) ─── + const deleteInstance = (url: string) => { + setConfirmState({ + title: '删除 RSSHub 实例', + message: ( + <> + 确定删除实例 {url} 吗? + 使用该实例路由的信源会在下次同步时自动切换到其它可用实例。 + + ), + confirmLabel: '删除实例', + onConfirm: async () => { + const prev = rsshubInstances; + setRsshubInstances((current) => current.filter((i) => i.url !== url)); + try { + setRsshubSaving(true); + await settingsApi.updateRSSHubInstances(rsshubInstances.filter((i) => i.url !== url)); + toast.success('实例已删除'); + } catch (err: unknown) { + setRsshubError(err instanceof Error ? err.message : '删除失败'); + setRsshubInstances(prev); + } finally { + setRsshubSaving(false); + } + }, + }); }; // ─── Create source ─── @@ -260,7 +609,8 @@ export default function SourcesPage() { } as CreateSourceRequest); setShowAddModal(false); setForm(emptyForm); - await fetchSources(); + toast.success('信源已添加'); + await fetchSources(page); await fetchSourceMap(); } catch (err: unknown) { setError(err instanceof Error ? err.message : '添加信源失败'); @@ -276,10 +626,9 @@ export default function SourcesPage() { setImportingOPML(true); try { const result = await sourcesApi.importOPML(file); - // Show success as a temporary rsshubError display (reuses existing banner) - setRsshubError(result.message); - fetchSources(); - fetchSourceMap(); + toast.success(result.message); + void fetchSources(page); + void fetchSourceMap(); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'OPML 导入失败'); } finally { @@ -328,11 +677,11 @@ export default function SourcesPage() { content: batchImportContent, category: batchImportCategory || '批量导入', }); - setRsshubError(result.message); + toast.success(result.message); setShowBatchImport(false); setBatchImportContent(''); setBatchImportPreview([]); - await fetchSources(); + await fetchSources(page); await fetchSourceMap(); } catch (err: unknown) { setError(err instanceof Error ? err.message : '批量导入失败'); @@ -363,7 +712,8 @@ export default function SourcesPage() { } as UpdateSourceRequest); setEditingSource(null); setForm(emptyForm); - await fetchSources(); + toast.success('信源已更新'); + await fetchSources(page); await fetchSourceMap(); } catch (err: unknown) { setError(err instanceof Error ? err.message : '更新信源失败'); @@ -387,6 +737,11 @@ export default function SourcesPage() { }); }; + const openAddModal = () => { + setForm(emptyForm); + setShowAddModal(true); + }; + // ─── Sync source ─── const handleSync = async (id: number) => { try { @@ -403,7 +758,7 @@ export default function SourcesPage() { ...prev, [id]: `获取 ${fetched} 条,新增 ${newCount} 条`, })); - await fetchSources(); + await fetchSources(page); await fetchSourceMap(); } catch (err: unknown) { setSyncResults((prev) => ({ @@ -427,24 +782,9 @@ export default function SourcesPage() { // 批量选择:多选 + 批量启用/暂停 const [selectedIds, setSelectedIds] = useState>(new Set()); const [batchProcessing, setBatchProcessing] = useState(false); - // 批量操作结果反馈(成功 / 部分失败)。失败必须可见——见 handleBatchToggle。 + // 批量操作结果反馈(部分失败必须持久可见——见 handleBatchToggle)。 const [batchResult, setBatchResult] = useState<{ tone: 'teal' | 'red'; text: string } | null>(null); - const handleSelectSource = (source: BackendSource, checked: boolean) => { - setSelectedIds((prev) => { - const next = new Set(prev); - if (checked) next.add(source.id); - else next.delete(source.id); - return next; - }); - }; - const handleSelectAllVisible = (sources: BackendSource[], checked: boolean) => { - setSelectedIds((prev) => { - const next = new Set(prev); - if (checked) sources.forEach((s) => next.add(s.id)); - else sources.forEach((s) => next.delete(s.id)); - return next; - }); - }; + const handleBatchToggle = async (enabled: boolean) => { if (selectedIds.size === 0 || batchProcessing) return; setBatchProcessing(true); @@ -460,22 +800,46 @@ export default function SourcesPage() { ); // 执行完成后重建选择态:失败项保持选中以便直接重试。 setSelectedIds((prev) => recomputeSelectionAfterBatch(prev, ids, failedIds)); - await fetchSources(); + await fetchSources(page); await fetchSourceMap(); - setBatchResult(describeBatchResult(ids.length, failedIds, enabled)); + const result = describeBatchResult(ids.length, failedIds, enabled); + if (failedIds.length > 0) { + // 部分失败:失败项保持选中可重试,需要持久可见 → 横幅(Toast 转瞬即逝不够) + setBatchResult(result); + } else { + toast.success(result.text); + } } finally { setBatchProcessing(false); } }; - const handleToggleEnabled = async (source: BackendSource) => { + + // 批量暂停属禁用类破坏性动作,先过二次确认;批量启用不拦截 + const requestBatchPause = () => { + if (selectedIds.size === 0 || batchProcessing) return; + setConfirmState({ + title: '批量暂停信源', + message: ( + <> + 将暂停 {selectedIds.size} 个已选信源的抓取,暂停期间不采集新内容, + 可随时重新勾选启用。 + + ), + confirmLabel: '批量暂停', + onConfirm: () => handleBatchToggle(false), + }); + }; + + const runToggleEnabled = async (source: BackendSource) => { if (togglingIds.has(source.id)) return; const nextEnabled = !source.enabled; try { setTogglingIds((prev) => new Set(prev).add(source.id)); await sourcesApi.update(source.id, { enabled: nextEnabled }); - await fetchSources(); + await fetchSources(page); await fetchSourceMap(); + toast.success(nextEnabled ? `已启用「${source.name}」` : `已暂停「${source.name}」`); } catch (err: unknown) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -487,23 +851,52 @@ export default function SourcesPage() { } }; - // ─── Delete source ─── - const handleDelete = async (id: number) => { - if (!confirm('确定要删除此信源吗?')) return; - try { - setDeletingIds((prev) => new Set(prev).add(id)); - await sourcesApi.delete(id); - setSources((prev) => prev.filter((s) => s.id !== id)); - setMapSources((prev) => prev.filter((s) => s.id !== id)); - } catch (err: unknown) { - setError(err instanceof Error ? err.message : '删除失败'); - } finally { - setDeletingIds((prev) => { - const next = new Set(prev); - next.delete(id); - return next; - }); + // 暂停属禁用类破坏性动作,二次确认后才执行;启用不拦截 + const handleToggleEnabled = (source: BackendSource) => { + if (!source.enabled) { + void runToggleEnabled(source); + return; } + setConfirmState({ + title: '暂停信源抓取', + message: ( + <> + 暂停「{source.name}」后将停止采集新内容,可随时在行尾菜单中重新启用。 + + ), + confirmLabel: '暂停抓取', + onConfirm: () => runToggleEnabled(source), + }); + }; + + // ─── Delete source(破坏性动作:二次确认) ─── + const handleDelete = (source: BackendSource) => { + setConfirmState({ + title: '删除信源', + message: ( + <> + 确定删除信源「{source.name}」吗?删除后停止抓取且不可恢复,已入库内容不受影响。 + + ), + confirmLabel: '删除', + onConfirm: async () => { + try { + setDeletingIds((prev) => new Set(prev).add(source.id)); + await sourcesApi.delete(source.id); + setSources((prev) => prev.filter((s) => s.id !== source.id)); + setMapSources((prev) => prev.filter((s) => s.id !== source.id)); + toast.success(`已删除「${source.name}」`); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : '删除失败'); + } finally { + setDeletingIds((prev) => { + const next = new Set(prev); + next.delete(source.id); + return next; + }); + } + }, + }); }; const handleWeightChange = async (id: number, weight: number) => { @@ -697,129 +1090,364 @@ export default function SourcesPage() { const syncBoard = useMemo(() => buildSourceSyncBoard(mapSources, syncingIds, new Date()), [mapSources, syncingIds]); - return ( - - {/* Header */} -
-
-
- {selectedIds.size > 0 && ( -
- {selectedIds.size} - 已选 - - - - | - -
+ // ─── 筛选与 chips ─── + + const hasActiveFilters = searchKeyword.trim() !== '' || filterType !== '' || filterEnabled !== undefined; + + const handleSearchChange = (value: string) => { + setSearchKeyword(value); + setPage(1); + }; + + const handleTypeChange = (value: string) => { + setFilterType(value); + setPage(1); + }; + + const handleEnabledChange = (value: string) => { + setFilterEnabled(value === '' ? undefined : value === 'yes'); + setPage(1); + }; + + const clearAllFilters = () => { + setSearchKeyword(''); + setFilterType(''); + setFilterEnabled(undefined); + setPage(1); + }; + + const handlePageSizeChange = (size: number) => { + setPageSize(size); + setPage(1); + }; + + const filterChips: AdminFilterChip[] = []; + if (searchKeyword.trim()) { + const keyword = searchKeyword.trim(); + filterChips.push({ + key: 'q', + label: `搜索:${keyword.length > 16 ? `${keyword.slice(0, 16)}…` : keyword}`, + onRemove: () => handleSearchChange(''), + }); + } + if (filterType) { + filterChips.push({ + key: 'type', + label: `类型:${filterType}`, + onRemove: () => handleTypeChange(''), + }); + } + if (filterEnabled !== undefined) { + filterChips.push({ + key: 'enabled', + label: `状态:${filterEnabled ? '已启用' : '已禁用'}`, + onRemove: () => handleEnabledChange(''), + }); + } + + // ─── 列定义(操作列右侧粘性;高频动作同步/编辑直接可见,其余收「···」菜单) ─── + + const columns: AdminTableColumn[] = [ + { + key: 'name', + header: '名称', + render: (source) => { + const displayUrl = source.url ? redactSourceUrlForDisplay(source.url) : ''; + return ( +
+ + {source.name} + + {displayUrl && ( + + {displayUrl} + )}
- 共 {total} 个信源 · 活跃 {activeCount} 个} - /> -
- - - - - - - -
- - {/* Search & Filter Bar */} - - setSearchKeyword(e.target.value)} - className="h-9 w-56 rounded-sm border border-gray-200 bg-white px-3.5 text-[13px] outline-none transition focus:border-primary-border focus:ring-2 focus:ring-primary-light" - /> + ); + }, + }, + { + key: 'type', + header: '类型', + width: 92, + render: (source) => ( + {source.source_type} + ), + }, + { + key: 'category', + header: '分类', + width: 110, + render: (source) => {source.category || '未分类'}, + }, + { + key: 'lastSync', + header: '最后同步', + width: 168, + render: (source) => { + const sourceSyncing = syncingIds.has(source.id) || source.status === 'syncing'; + return ( +
+ + {sourceSyncing ? '同步中' : source.sync_error ? '同步失败' : timeAgo(source.last_sync_at)} + + {source.sync_error && ( + + {source.sync_error} + + )} + {syncResults[source.id] && ( + + {syncResults[source.id]} + + )} +
+ ); + }, + }, + { + key: 'interval', + header: '采集频率', + width: 104, + render: (source) => ( - - {(searchKeyword || filterType || filterEnabled !== undefined) && ( - + ))} + + {(source.weight - 3) * 8 > 0 ? '+' : ''}{(source.weight - 3) * 8} + +
+ ), + }, + { + key: 'status', + header: '状态', + width: 96, + render: (source) => { + const isActive = source.status === 'active' && source.enabled; + return ( + + + ); + }, + }, + { + key: 'actions', + header: '操作', + width: 170, + sticky: 'right', + render: (source) => { + const sourceSyncing = syncingIds.has(source.id) || source.status === 'syncing'; + const sourceDisabled = !source.enabled || source.status === 'disabled'; + const favoriteKey = getFavoriteTargetKey({ target_type: 'source', target_id: source.id }); + const isFavorite = favoriteTargets.has(favoriteKey) || sourceFavoriteKeys.has(favoriteKey); + return ( +
+ {/* 高频动作 ≤2 直接可见:同步 / 编辑 */} + + + {/* 其余动作收进行尾「···」菜单;暂停/删除在菜单内二次确认 */} + setEvidenceProfileSource(source), + }, + { + key: 'favorite', + label: isFavorite ? '移出收藏' : '收藏信源', + icon: Star, + disabled: favoriteTargetPendingKeys.has(favoriteKey), + onSelect: () => void handleToggleSourceFavorite(source), + }, + { + key: 'toggle', + label: source.enabled ? '暂停抓取' : '启用抓取', + icon: source.enabled ? Pause : Play, + disabled: togglingIds.has(source.id), + onSelect: () => handleToggleEnabled(source), + }, + { + key: 'delete', + label: '删除信源', + icon: Trash2, + danger: true, + disabled: deletingIds.has(source.id), + onSelect: () => handleDelete(source), + }, + ]} + /> +
+ ); + }, + }, + ]; + + const batchBar = ( +
+ 已选 {selectedIds.size} 个信源 + +
+ ); + + return ( + + {/* Header */} + 共 {total} 个信源 · 活跃 {activeCount} 个} + actions={ + <> + + + + + } + /> + + + + {/* 筛选行:搜索(300ms 防抖内建)+ 类型/状态下拉;已选条件 chips 单移除 / 清空全部 */} +
+ + + + + +
-
+
{[ { key: 'map' as const, label: '信源地图', icon: Network }, @@ -833,6 +1461,7 @@ export default function SourcesPage() { type="button" key={item.key} onClick={() => setViewMode(item.key)} + aria-pressed={active} className={cx( 'inline-flex items-center gap-1.5 rounded-xs border border-transparent px-3 py-1.5 text-[13px] transition', active ? 'bg-white font-black text-primary shadow-sm' : 'font-semibold text-gray-500 hover:text-gray-800', @@ -849,7 +1478,7 @@ export default function SourcesPage() {
- {/* Error Banner */} + {/* Error Banner(系统级 / 变更类错误;列表加载失败走 AdminTable 内联错误) */} {rsshubError && ( setRsshubError(null)}>{rsshubError} )} @@ -886,7 +1515,12 @@ export default function SourcesPage() { {tab.label} 0 时红色加粗(#121 统计条口径) + : tab.value === 'error' && tab.count > 0 + ? 'bg-red-light font-bold text-red-600' + : 'bg-gray-100', )}> {tab.count} @@ -933,30 +1567,60 @@ export default function SourcesPage() { onAdd={addInstance} /> {currentUser?.role === 'admin' && } - void fetchSources(p)} - /> + + {/* 信源列表:三态(骨架 / 内联错误+重试 / 空态)由 AdminTable 内建,操作列右侧粘性 */} +
+ + source.id} + loading={loading} + error={listError} + onRetry={() => void fetchSources(page)} + empty={ + + 清空筛选 + + ) : ( + + ) + } + /> + } + selectable + selected={selectedIds} + onSelectedChange={(next) => setSelectedIds(new Set(Array.from(next, Number)))} + batchBar={batchBar} + minWidth={1040} + /> +
+ + {/* 分页:/sources 支持 page_size(1–200),每页条数选择器随之落地 */} + {!loading && total > 0 && ( + void fetchSources(typeof next === 'function' ? next(page) : next)} + pageSize={pageSize} + pageSizeOptions={PAGE_SIZE_OPTIONS} + onPageSizeChange={handlePageSizeChange} + summary={ + <> + 第 {(page - 1) * pageSize + 1}-{Math.min(page * pageSize, total)} 条,共 {total} 条 + + } + /> + )} )} @@ -984,6 +1648,21 @@ export default function SourcesPage() { {evidenceProfileSource && ( setEvidenceProfileSource(null)} /> )} + + {/* 破坏性动作二次确认(删除 / 暂停类统一入口,替代原生 confirm) */} + {confirmState && ( + { if (!confirmBusy) setConfirmState(null); }}> +

{confirmState.message}

+ + + + +
+ )} ); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index b9c73a2a..b844b9b7 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -41,6 +41,29 @@ --color-page: #F7F7F8; --color-text: #1A1A2E; + /* ── 管理后台(工具面)token —— 只加不改,前台不受影响 ── + * 圆桌 2026-10-18(issue #121):后台走工具面路线——ink 纯墨深侧边栏、 + * 品牌橙为唯一点缀;语义色(danger/warn)与品牌色双通道隔离不互借。 + * ink 三档 + 200/100 浅档供浅底分隔与表头使用。 + */ + --color-ink-900: #1D1D20; + --color-ink-800: #2A2A2E; + --color-ink-700: #3F3F46; + --color-ink-500: #71717A; + --color-ink-300: #D4D4D8; + --color-ink-200: #E4E4E7; + --color-ink-100: #F4F4F5; + + /* 后台点缀色:指向品牌橙,语义上只承担「导航激活 / 主操作」。 */ + --color-admin-accent: #FF6B35; + + /* 深底徽章/警示:文字用 300 系亮色保证对 ink-900 的 4.5:1 对比度, + * 底为同色 12% 透明。与浅底语义色(red/amber 系)分属两个使用面。 */ + --color-admin-danger-text: #FCA5A5; + --color-admin-danger-bg: rgba(239, 68, 68, 0.12); + --color-admin-warn-text: #FCD34D; + --color-admin-warn-bg: rgba(217, 119, 6, 0.12); + --font-sans: var(--font-dm-sans), -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; --font-mono: var(--font-dm-mono), ui-monospace, monospace; --font-serif-display: var(--font-display), "Noto Serif SC", "Songti SC", "STSong", "SimSun", serif; @@ -165,6 +188,35 @@ svg { background-clip: content-box; } +/* 骨架屏 shimmer:1.6s 循环;reduced-motion 时降级为静态块(见下方媒体查询) */ +@keyframes admin-shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +.admin-shimmer { + background-image: linear-gradient( + 90deg, + rgba(29, 29, 32, 0.06) 25%, + rgba(29, 29, 32, 0.1) 50%, + rgba(29, 29, 32, 0.06) 75% + ); + background-size: 200% 100%; + animation: admin-shimmer 1.6s linear infinite; +} + +@media (prefers-reduced-motion: reduce) { + .admin-shimmer { + animation: none; + background-image: none; + background-color: rgba(29, 29, 32, 0.06); + } +} + /* Radar ping animation */ @keyframes radar-ping { 0% { diff --git a/frontend/src/app/stats/jobs/page.tsx b/frontend/src/app/stats/jobs/page.tsx index fcbe7406..b1f74f3a 100644 --- a/frontend/src/app/stats/jobs/page.tsx +++ b/frontend/src/app/stats/jobs/page.tsx @@ -1,358 +1,11 @@ -'use client'; - -import React, { useState } from 'react'; -import { - Activity, - AlertTriangle, - CheckCircle2, - Clock, - ListChecks, - RefreshCw, - Timer, - XCircle, -} from 'lucide-react'; - -import { Badge, Button, Panel, cx, type Tone } from '@/components/ui'; -import { ErrorState } from '@/components/StateView'; -import { useFetch } from '@/hooks/useFetch'; -import { statsJobsApi, type JobStatsByJobKey, type JobStatsResponse } from '@/lib/api'; -import { timeAgoShort as formatRelativeTime } from '@/lib/datetime'; - -const DAY_OPTIONS: Array<{ value: number; label: string }> = [ - { value: 1, label: '今日' }, - { value: 7, label: '7 天' }, - { value: 30, label: '30 天' }, - { value: 90, label: '90 天' }, -]; - -const JOB_STATUS_TONE: Record = { - SUCCESS: 'teal', - FAILED: 'red', - TIMEOUT: 'amber', - SKIPPED: 'neutral', - RUNNING: 'primary', -}; - -const STATUS_BAR_COLOR: Record = { - SUCCESS: '#10b981', - FAILED: '#ef4444', - TIMEOUT: '#f59e0b', - SKIPPED: '#94a3b8', - RUNNING: '#3b82f6', -}; - -function formatDuration(ms: number | null | undefined): string { - if (ms == null || ms <= 0) return '-'; - if (ms < 1000) return `${ms}ms`; - const seconds = ms / 1000; - if (seconds < 60) return `${seconds.toFixed(1)}s`; - const minutes = seconds / 60; - if (minutes < 60) return `${minutes.toFixed(1)}min`; - return `${(minutes / 60).toFixed(1)}h`; -} - -function successTone(rate: number): Tone { - if (rate >= 0.95) return 'teal'; - if (rate >= 0.8) return 'amber'; - return 'red'; -} - -function lastStatusTone(status: string | null | undefined): Tone { - if (!status) return 'neutral'; - return JOB_STATUS_TONE[status] || 'neutral'; -} - -export default function JobStatsPage() { - const [days, setDays] = useState(7); - const [selectedJobKey, setSelectedJobKey] = useState(null); - - const { data, loading, error, refetch } = useFetch( - () => statsJobsApi.get(days, selectedJobKey ?? undefined), - [days, selectedJobKey], - { initialLoading: false }, - ); - - const totals = data?.totals; - const byStatusMax = data - ? Math.max(...data.by_status.map((row) => row.count), 1) - : 1; - - return ( -
-
-
-

- - 抓取/调度任务监控 -

-

- 来自 job_execution_logs 表的运行数据。 - 覆盖所有用 @track_job 装饰的全局任务。 -

-
-
-
- {DAY_OPTIONS.map((opt) => ( - - ))} -
- -
-
- - {error && ( -
- void refetch()} panel={false} /> -
- )} - - {/* ── KPI 卡片 ── */} -
- } - label="总运行数" - value={totals ? totals.total_runs.toLocaleString() : '-'} - hint={data ? `${data.period.days} 天内` : ''} - /> - } - label="成功率" - value={totals ? `${(totals.success_rate * 100).toFixed(1)}%` : '-'} - hint={totals ? `${totals.success_count} 成功 / ${totals.failed_count + totals.timeout_count} 失败` : ''} - tone={successTone(totals?.success_rate ?? 1)} - /> - } - label="平均耗时" - value={formatDuration(totals?.avg_duration_ms)} - hint={totals ? `最长 ${formatDuration(totals.max_duration_ms)}` : ''} - /> - } - label="最近失败" - value={data ? data.recent_failures.length.toString() : '-'} - hint={totals ? `${totals.failed_count + totals.timeout_count} 次` : '近 10 条'} - /> -
- - {/* ── 状态分布 ── */} - -

- - 状态分布 -

- {data && data.by_status.length > 0 ? ( -
- {data.by_status.map((row) => ( -
- - {row.status} - -
-
-
- - {row.count.toLocaleString()} - -
- ))} -
- ) : ( -

暂无数据

- )} - - - {/* ── Per-job_key 表格 ── */} - -
-

- - 各任务运行情况 -

- {selectedJobKey && ( - - )} -
- {data && data.by_job_key.length > 0 ? ( -
- - - - - - - - - - - - - {data.by_job_key.map((row) => ( - - setSelectedJobKey( - selectedJobKey === row.job_key ? null : row.job_key, - ) - } - /> - ))} - -
任务运行成功率平均耗时最近状态最近运行
-
- ) : ( -

暂无数据

- )} -
- - {/* ── 最近失败 ── */} - -

- - 最近失败 / 超时 - - (最多 10 条) - -

- {data && data.recent_failures.length > 0 ? ( -
    - {data.recent_failures.map((row, idx) => ( -
  • -
    - {row.job_key} - - {row.status} - - {formatRelativeTime(row.started_at)} - · {formatDuration(row.duration_ms)} - -
    - {row.error_message && ( -
    -                    {row.error_message}
    -                  
    - )} -
  • - ))} -
- ) : ( -

- {data ? '🎉 该时间窗口内无失败记录' : ''} -

- )} -
-
- ); -} - -function KpiCard({ - icon, - label, - value, - hint, - tone = 'neutral', -}: { - icon: React.ReactNode; - label: string; - value: string; - hint?: string; - tone?: Tone; -}) { - return ( -
-
- {icon} - {label} -
-
- {value} -
- {hint && ( - - {hint} - - )} -
- ); -} - -function JobKeyRow({ - row, - isSelected, - onClick, -}: { - row: JobStatsByJobKey; - isSelected: boolean; - onClick: () => void; -}) { - return ( - - - {row.job_key} - - - {row.runs} - - - - {(row.success_rate * 100).toFixed(1)}% - - - - {formatDuration(row.avg_duration_ms)} - - - - {row.last_status || '-'} - - - - {formatRelativeTime(row.last_run_at)} - - - ); +import { redirect } from 'next/navigation'; + +/** + * 旧路径跳转桩(2026-10-19,owner 验收反馈): + * 任务统计本是流水线健康信息,归属管理后台。页面已迁至 /admin/jobs + * (后台「系统」组「任务监控」),此处保留服务端跳转(redirect() 为 + * 307 临时跳转),让历史收藏/文档链接不失效。 + */ +export default function LegacyJobsStatsPage() { + redirect('/admin/jobs'); } diff --git a/frontend/src/components/AdminSidebar.tsx b/frontend/src/components/AdminSidebar.tsx index 159844e1..e46ea6f0 100644 --- a/frontend/src/components/AdminSidebar.tsx +++ b/frontend/src/components/AdminSidebar.tsx @@ -4,13 +4,11 @@ import React from 'react'; import { usePathname, useRouter } from 'next/navigation'; import { Activity, - ArrowLeft, BarChart3, BookOpen, BrainCircuit, GitMerge, LayoutDashboard, - LogOut, MessageSquareWarning, Newspaper, RadioTower, @@ -24,7 +22,7 @@ import { } from 'lucide-react'; import { cx } from '@/components/ui'; import { useAppContext } from '@/components/ClientLayout'; -import { ADMIN_NAV_ITEMS as ADMIN_NAV_ENTRIES } from '@/lib/admin-nav'; +import { ADMIN_NAV_ITEMS as ADMIN_NAV_ENTRIES, ADMIN_NAV_GROUP_LABELS } from '@/lib/admin-nav'; interface AdminNavItem { id: string; @@ -37,7 +35,6 @@ interface AdminNavItem { // 缺映射时响亮抛错而不是静默渲染空白——「挂起/未挂载/被跳过」必须显式报错。 const ADMIN_NAV_ICONS: Record = { dashboard: LayoutDashboard, - monitor: Activity, sources: RadioTower, contents: Newspaper, 'content-events': GitMerge, @@ -45,6 +42,7 @@ const ADMIN_NAV_ICONS: Record = { 'model-eval': BrainCircuit, 'mother-topics': BookOpen, updates: Rocket, + monitor: Activity, prompts: ScrollText, 'scoring-dashboard': BarChart3, evidence: ShieldCheck, @@ -54,7 +52,7 @@ const ADMIN_NAV_ICONS: Record = { }; // admin 全量导航(含不在 NAV_SPACES 里的 /admin/contents、/admin/mother-topics)。 -// href/label 来自 @/lib/admin-nav 的唯一清单,顶栏面包屑由同一份派生(#88)。 +// href/label/group 来自 @/lib/admin-nav 的唯一清单,顶栏面包屑由同一份派生(#88)。 const ADMIN_NAV_ITEMS: AdminNavItem[] = ADMIN_NAV_ENTRIES.map((entry) => { const icon = ADMIN_NAV_ICONS[entry.id]; if (!icon) { @@ -63,107 +61,101 @@ const ADMIN_NAV_ITEMS: AdminNavItem[] = ADMIN_NAV_ENTRIES.map((entry) => { return { id: entry.id, label: entry.label, href: entry.href, icon }; }); +// 分组渲染结构:概览独立置顶,其余按清单 group 字段聚组(#121 圆桌定稿: +// 领域名词组名 + 频率排序,默认全展开不折叠——折叠把识别变回回忆)。 +const NAV_GROUPS: { key: 'content' | 'eval' | 'users' | 'system'; items: AdminNavItem[] }[] = ( + ['content', 'eval', 'users', 'system'] as const +).map((key) => ({ + key, + items: ADMIN_NAV_ITEMS.filter((item) => ADMIN_NAV_ENTRIES.find((e) => e.id === item.id)?.group === key), +})); + +const PINNED_ITEM = ADMIN_NAV_ITEMS[0]; // 概览 + export default function AdminSidebar() { const pathname = usePathname(); const router = useRouter(); - const { currentUser, authLoading, logout } = useAppContext(); + const { currentUser } = useAppContext(); const isActive = (href: string) => { if (href === '/admin') return pathname === '/admin'; return pathname === href || pathname.startsWith(`${href}/`); }; - const handleLogout = () => { - if (logout) { - void logout(); - } + const renderItem = (item: AdminNavItem) => { + const active = isActive(item.href); + const Icon = item.icon; + return ( + + ); }; return ( -
+
{/* Brand */} -
+
-
- +
+
-
- 管理后台 -
-
- ADMIN CONSOLE -
+
管理后台
+
ADMIN CONSOLE
- {/* Back to user side */} -
- -
- {/* Navigation */} -