From e5e42b1f0dfe9306767f343a261f9a4cd564167b Mon Sep 17 00:00:00 2001 From: root Date: Sun, 27 Sep 2026 20:21:29 +0800 Subject: [PATCH] feat(web): W3-1 shared console routes, nav, api/sse/query-keys and shared components - routes.ts registers all workspaces under /console (+ detail routes, engine, audit, login) - api.ts: cookie session first (credentials include, CSRF header), Bearer only with stored key, 401 -> /login - sse.ts: shared EventSource with ?after=seq catch-up, backoff reconnect, per-type subscribe - shared components: data-table, filter-bar, detail-sheet, timeline, json-view, confirm-dialog, form-field, kv-list, stat-tile, error/loading states; ui tabs and textarea Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + apps/web/app/api/health.ts | 2 +- .../app/components/console/confirm-dialog.tsx | 85 +++++++++ .../web/app/components/console/data-table.tsx | 167 ++++++++++++++++++ .../app/components/console/detail-sheet.tsx | 71 ++++++++ .../app/components/console/empty-state.tsx | 59 ++++++- .../web/app/components/console/filter-bar.tsx | 90 ++++++++++ .../web/app/components/console/form-field.tsx | 54 ++++++ apps/web/app/components/console/json-view.tsx | 86 +++++++++ apps/web/app/components/console/kv-list.tsx | 29 +++ apps/web/app/components/console/nav.test.ts | 20 ++- apps/web/app/components/console/nav.ts | 88 +++++++-- apps/web/app/components/console/stat-tile.tsx | 67 +++++++ apps/web/app/components/console/timeline.tsx | 53 ++++++ apps/web/app/components/ui/tabs.tsx | 38 ++++ apps/web/app/components/ui/textarea.tsx | 20 +++ apps/web/app/lib/api.ts | 126 ++++++++++--- apps/web/app/lib/query-keys.ts | 45 +++++ apps/web/app/lib/sse.ts | 140 +++++++++++++++ apps/web/app/routes.ts | 44 +++-- apps/web/app/routes/console/agent.$runId.tsx | 6 + apps/web/app/routes/console/audit.tsx | 6 + apps/web/app/routes/console/engine.$runId.tsx | 6 + apps/web/app/routes/console/engine.tsx | 6 + apps/web/app/routes/console/layout.tsx | 8 +- apps/web/app/routes/console/library.$id.tsx | 6 + apps/web/app/routes/console/organize.$id.tsx | 6 + apps/web/app/routes/console/sites.$siteId.tsx | 6 + apps/web/app/routes/console/tasks.$id.tsx | 6 + apps/web/app/routes/index-redirect.tsx | 10 ++ apps/web/app/routes/login.tsx | 4 + apps/web/app/routes/not-found.tsx | 2 +- 32 files changed, 1286 insertions(+), 71 deletions(-) create mode 100644 apps/web/app/components/console/confirm-dialog.tsx create mode 100644 apps/web/app/components/console/data-table.tsx create mode 100644 apps/web/app/components/console/detail-sheet.tsx create mode 100644 apps/web/app/components/console/filter-bar.tsx create mode 100644 apps/web/app/components/console/form-field.tsx create mode 100644 apps/web/app/components/console/json-view.tsx create mode 100644 apps/web/app/components/console/kv-list.tsx create mode 100644 apps/web/app/components/console/stat-tile.tsx create mode 100644 apps/web/app/components/console/timeline.tsx create mode 100644 apps/web/app/components/ui/tabs.tsx create mode 100644 apps/web/app/components/ui/textarea.tsx create mode 100644 apps/web/app/lib/query-keys.ts create mode 100644 apps/web/app/lib/sse.ts create mode 100644 apps/web/app/routes/console/agent.$runId.tsx create mode 100644 apps/web/app/routes/console/audit.tsx create mode 100644 apps/web/app/routes/console/engine.$runId.tsx create mode 100644 apps/web/app/routes/console/engine.tsx create mode 100644 apps/web/app/routes/console/library.$id.tsx create mode 100644 apps/web/app/routes/console/organize.$id.tsx create mode 100644 apps/web/app/routes/console/sites.$siteId.tsx create mode 100644 apps/web/app/routes/console/tasks.$id.tsx create mode 100644 apps/web/app/routes/index-redirect.tsx create mode 100644 apps/web/app/routes/login.tsx diff --git a/.gitignore b/.gitignore index 3cacbd29..1a1ffd42 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ coverage/ .DS_Store .vite/ types/deno.d.ts +apps/web/test/screenshots/ diff --git a/apps/web/app/api/health.ts b/apps/web/app/api/health.ts index cd17f701..3114682c 100644 --- a/apps/web/app/api/health.ts +++ b/apps/web/app/api/health.ts @@ -6,7 +6,7 @@ import { apiData, TTL_SECOND } from "~/lib/api.ts"; export function useHealth() { return useQuery({ queryKey: ["health"], - queryFn: ({ signal }) => apiData("/api/v1/health", { signal }), + queryFn: ({ signal }) => apiData("/health", { signal, noAuthRedirect: true }), refetchInterval: 15 * TTL_SECOND, retry: false, }); diff --git a/apps/web/app/components/console/confirm-dialog.tsx b/apps/web/app/components/console/confirm-dialog.tsx new file mode 100644 index 00000000..dd29eb1f --- /dev/null +++ b/apps/web/app/components/console/confirm-dialog.tsx @@ -0,0 +1,85 @@ +import { LoaderCircle } from "lucide-react"; +import { type ReactNode, useState } from "react"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "~/components/ui/alert-dialog.tsx"; +import { Button } from "~/components/ui/button.tsx"; + +/** + * 确认弹窗。onConfirm 可返回 Promise:执行中按钮禁用并转圈,resolve 后自动关闭,reject 时保持打开 + * (错误提示由调用方 toast)。受控(open/onOpenChange)或非受控(trigger)两种用法。 + * 删除} title="删除实例?" + * description="…" confirmLabel="删除" destructive onConfirm={() => del.mutateAsync(id)} /> + */ +export function ConfirmDialog({ + trigger, + open: openProp, + onOpenChange, + title, + description, + confirmLabel = "确认", + cancelLabel = "取消", + destructive = false, + onConfirm, + children, +}: { + trigger?: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + title: ReactNode; + description?: ReactNode; + confirmLabel?: string; + cancelLabel?: string; + destructive?: boolean; + onConfirm: () => unknown | Promise; + /** 描述下方的额外内容(输入确认等) */ + children?: ReactNode; +}) { + const [inner, setInner] = useState(false); + const [pending, setPending] = useState(false); + const open = openProp ?? inner; + const setOpen = (o: boolean) => { + if (pending) return; + setInner(o); + onOpenChange?.(o); + }; + + const run = async () => { + setPending(true); + try { + await onConfirm(); + setPending(false); + setInner(false); + onOpenChange?.(false); + } catch { + setPending(false); + } + }; + + return ( + + {trigger ? {trigger} : null} + + + {title} + {description ? {description} : null} + + {children} + + {cancelLabel} + + + + + ); +} diff --git a/apps/web/app/components/console/data-table.tsx b/apps/web/app/components/console/data-table.tsx new file mode 100644 index 00000000..47ea7f83 --- /dev/null +++ b/apps/web/app/components/console/data-table.tsx @@ -0,0 +1,167 @@ +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { Button } from "~/components/ui/button.tsx"; +import { cn } from "~/lib/utils.ts"; +import { EmptyState, ErrorState, LoadingState } from "./empty-state.tsx"; + +export type DataColumn = { + /** 列 key(React key 用) */ + key: string; + header: ReactNode; + cell: (row: T) => ReactNode; + /** 窄屏隐藏该列,避免横向溢出 */ + hideBelow?: "sm" | "md" | "lg"; + align?: "left" | "right"; + className?: string; +}; + +const HIDE: Record["hideBelow"]>, string> = { + sm: "hidden sm:table-cell", + md: "hidden md:table-cell", + lg: "hidden lg:table-cell", +}; + +/** + * 数据表:内置 loading / error / empty 三态与"加载更多"游标翻页。 + * 外层自带 pg-slab 卡片(plain 时不带);表格在卡片内横向滚动,页面本身不溢出。 + * + * r.id} + * loading={q.isPending} error={q.error} onRetry={q.refetch} + * empty={{ title: "还没有记录" }} onRowClick={(r) => open(r.id)} + * hasMore={!!next} onLoadMore={...} loadingMore={...} /> + */ +export function DataTable({ + columns, + rows, + rowKey, + loading = false, + error, + onRetry, + empty, + onRowClick, + selectedKey, + hasMore = false, + onLoadMore, + loadingMore = false, + plain = false, + className, +}: { + columns: DataColumn[]; + rows: T[] | undefined; + rowKey: (row: T) => string; + loading?: boolean; + error?: unknown; + onRetry?: () => void; + empty?: { title: string; description?: ReactNode; icon?: LucideIcon; action?: ReactNode }; + onRowClick?: (row: T) => void; + selectedKey?: string | null; + hasMore?: boolean; + onLoadMore?: () => void; + loadingMore?: boolean; + plain?: boolean; + className?: string; +}) { + let body: ReactNode; + if (loading && !rows) { + body = ; + } else if (error && !rows) { + body = ; + } else if (!rows || rows.length === 0) { + body = ( + + ); + } else { + body = ( +
+ + + + {columns.map((c) => ( + + ))} + + + + {rows.map((row) => { + const key = rowKey(row); + return ( + onRowClick(row) : undefined} + onKeyDown={ + onRowClick + ? (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onRowClick(row); + } + } + : undefined + } + tabIndex={onRowClick ? 0 : undefined} + aria-selected={selectedKey != null ? selectedKey === key : undefined} + className={cn( + "border-b border-border/60 transition-colors duration-micro last:border-b-0", + onRowClick && + "cursor-pointer outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]", + selectedKey === key && "bg-foreground/[0.05]", + )} + > + {columns.map((c) => ( + + ))} + + ); + })} + +
+ {c.header} +
+ {c.cell(row)} +
+
+ ); + } + + return ( +
+ {body} + {rows && rows.length > 0 && (hasMore || error) ? ( +
+ {error ? ( + {String((error as Error).message ?? error)} + ) : null} + {hasMore && onLoadMore ? ( + + ) : null} +
+ ) : null} +
+ ); +} diff --git a/apps/web/app/components/console/detail-sheet.tsx b/apps/web/app/components/console/detail-sheet.tsx new file mode 100644 index 00000000..6c787c07 --- /dev/null +++ b/apps/web/app/components/console/detail-sheet.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from "react"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx"; +import { cn } from "~/lib/utils.ts"; + +/** + * 右侧详情抽屉(手机全宽)。内容区自带纵向滚动;footer 固定在底部放操作按钮。 + * !o && setId(null)} title="详情" description="…"> + * + * + */ +export function DetailSheet({ + open, + onOpenChange, + title, + description, + children, + footer, + width = "md", + className, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: ReactNode; + description?: ReactNode; + children: ReactNode; + footer?: ReactNode; + /** 桌面宽度:md ≈ 32rem,lg ≈ 44rem */ + width?: "md" | "lg"; + className?: string; +}) { + return ( + + + + {title} + {description ? {description} : null} + +
{children}
+ {footer ? ( +
+ {footer} +
+ ) : null} +
+
+ ); +} + +/** 抽屉/卡片内的小节:大写小标签 + 内容 */ +export function DetailSection({ + title, + children, + actions, +}: { + title: string; + children: ReactNode; + actions?: ReactNode; +}) { + return ( +
+
+

{title}

+ {actions} +
+ {children} +
+ ); +} diff --git a/apps/web/app/components/console/empty-state.tsx b/apps/web/app/components/console/empty-state.tsx index 169e960c..9d77cfc6 100644 --- a/apps/web/app/components/console/empty-state.tsx +++ b/apps/web/app/components/console/empty-state.tsx @@ -1,6 +1,7 @@ import type { LucideIcon } from "lucide-react"; -import { Inbox } from "lucide-react"; +import { AlertTriangle, Inbox, RotateCw } from "lucide-react"; import type { ReactNode } from "react"; +import { Button } from "~/components/ui/button.tsx"; import { cn } from "~/lib/utils.ts"; /** 空态:图标 + 标题 + 说明 + 可选操作。用于"尚无数据"与"功能建设中"占位 */ @@ -22,7 +23,11 @@ export function EmptyState({ }) { return (
@@ -33,3 +38,53 @@ export function EmptyState({
); } + +/** 错误态:图标 + 标题 + 错误消息 + 可选重试。放在卡片里时用 plain */ +export function ErrorState({ + title = "加载失败", + error, + onRetry, + plain = false, + className, +}: { + title?: string; + error?: unknown; + onRetry?: () => void; + plain?: boolean; + className?: string; +}) { + const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined; + return ( +
+ + + +

{title}

+ {message ?

{message}

: null} + {onRetry ? ( + + ) : null} +
+ ); +} + +/** 加载态:若干行骨架条 */ +export function LoadingState({ rows = 3, className }: { rows?: number; className?: string }) { + return ( +
+ {Array.from({ length: rows }, (_, i) => ( +
+ ))} +
+ ); +} diff --git a/apps/web/app/components/console/filter-bar.tsx b/apps/web/app/components/console/filter-bar.tsx new file mode 100644 index 00000000..ad3b3ef8 --- /dev/null +++ b/apps/web/app/components/console/filter-bar.tsx @@ -0,0 +1,90 @@ +import { Search, X } from "lucide-react"; +import type { ReactNode } from "react"; +import { Button } from "~/components/ui/button.tsx"; +import { Input } from "~/components/ui/input.tsx"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; +import { cn } from "~/lib/utils.ts"; + +/** + * 筛选栏:可选搜索框 + 任意筛选控件(children)+ 可选"清除"。窄屏自动换行。 + * + * + * + */ +export function FilterBar({ + search, + children, + onReset, + actions, + className, +}: { + search?: { value: string; onChange: (v: string) => void; placeholder?: string }; + children?: ReactNode; + /** 有值时显示"清除筛选" */ + onReset?: () => void; + /** 右侧额外操作(刷新等) */ + actions?: ReactNode; + className?: string; +}) { + return ( +
+ {search ? ( +
+ + search.onChange(e.target.value)} + placeholder={search.placeholder ?? "搜索"} + className="pl-9" + aria-label={search.placeholder ?? "搜索"} + /> +
+ ) : null} + {children} + {onReset ? ( + + ) : null} + {actions ?
{actions}
: null} +
+ ); +} + +export type FilterOption = { value: string; label: string }; + +/** 下拉筛选。value 为 "" 表示"全部"(allLabel) */ +export function FilterSelect({ + label, + value, + onChange, + options, + allLabel = "全部", + className, +}: { + label: string; + value: string; + onChange: (v: string) => void; + options: FilterOption[]; + allLabel?: string; + className?: string; +}) { + const ALL = "__all__"; + return ( + + ); +} diff --git a/apps/web/app/components/console/form-field.tsx b/apps/web/app/components/console/form-field.tsx new file mode 100644 index 00000000..06235624 --- /dev/null +++ b/apps/web/app/components/console/form-field.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from "react"; +import { Label } from "~/components/ui/label.tsx"; +import { cn } from "~/lib/utils.ts"; + +/** + * 表单字段:标签 + 控件 + 说明/错误。错误行固定占位,出错时布局不跳。 + * 控件自己设 id={htmlFor} 与 aria-invalid={!!error}。 + * + * + * + */ +export function FormField({ + label, + htmlFor, + hint, + error, + required = false, + children, + className, +}: { + label: ReactNode; + htmlFor?: string; + hint?: ReactNode; + error?: string | null; + required?: boolean; + children: ReactNode; + className?: string; +}) { + return ( +
+ + {children} +

+ {error || hint || ""} +

+
+ ); +} + +/** 把 zod safeParse 的错误摊平成 { 字段路径: 第一条消息 } */ +export function zodFieldErrors(issues: { path: PropertyKey[]; message: string }[]): Record { + const out: Record = {}; + for (const i of issues) { + const k = i.path.map(String).join("."); + if (!(k in out)) out[k] = i.message; + } + return out; +} diff --git a/apps/web/app/components/console/json-view.tsx b/apps/web/app/components/console/json-view.tsx new file mode 100644 index 00000000..b395740e --- /dev/null +++ b/apps/web/app/components/console/json-view.tsx @@ -0,0 +1,86 @@ +import { Check, ChevronRight, Copy } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { cn } from "~/lib/utils.ts"; + +/** + * 只读 JSON 展示:等宽、自动换行、限高滚动,右上角复制。 + * label 存在时可折叠(defaultOpen 控制初始展开)。null/undefined 显示"无"。 + */ +export function JsonView({ + value, + label, + defaultOpen = true, + maxHeight = "20rem", + tone = "neutral", + className, +}: { + value: unknown; + label?: string; + defaultOpen?: boolean; + maxHeight?: string; + /** destructive:错误负载 */ + tone?: "neutral" | "destructive"; + className?: string; +}) { + const [open, setOpen] = useState(defaultOpen); + const [copied, setCopied] = useState(false); + const empty = value === null || value === undefined; + const text = empty ? "" : typeof value === "string" ? value : JSON.stringify(value, null, 2); + + const copy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + } catch { + toast.error("复制失败"); + } + }; + + return ( +
+ {label ? ( + + ) : null} + {open || !label ? ( + empty ? ( +

无

+ ) : ( +
+ +
+              {text}
+            
+
+ ) + ) : null} +
+ ); +} diff --git a/apps/web/app/components/console/kv-list.tsx b/apps/web/app/components/console/kv-list.tsx new file mode 100644 index 00000000..e9736d47 --- /dev/null +++ b/apps/web/app/components/console/kv-list.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; +import { cn } from "~/lib/utils.ts"; + +export type KvItem = { + label: ReactNode; + value: ReactNode; + /** 等宽(ID、哈希、路径) */ + mono?: boolean; + /** 占满整行(长文本) */ + wide?: boolean; +}; + +/** 键值列表:窄屏单列,sm 起两列。空值显示 — */ +export function KvList({ items, columns = 2, className }: { items: KvItem[]; columns?: 1 | 2; className?: string }) { + return ( +
+ {items.map((it, i) => ( +
+
{it.label}
+
+ {it.value === null || it.value === undefined || it.value === "" ? "—" : it.value} +
+
+ ))} +
+ ); +} diff --git a/apps/web/app/components/console/nav.test.ts b/apps/web/app/components/console/nav.test.ts index 286e76d6..473123fe 100644 --- a/apps/web/app/components/console/nav.test.ts +++ b/apps/web/app/components/console/nav.test.ts @@ -1,15 +1,23 @@ import { describe, expect, it } from "vitest"; -import { findNavItem, NAV_ITEMS } from "./nav.ts"; +import { consolePath, findNavItem, NAV_ITEMS } from "./nav.ts"; describe("控制台导航", () => { - it("12 个工作区,路径唯一", () => { - expect(NAV_ITEMS).toHaveLength(12); - expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(12); + it("12 个工作区 + 引擎面板 + 审计,路径唯一且都在 /console 下", () => { + expect(NAV_ITEMS).toHaveLength(14); + expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(14); + expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true); }); it("按最长前缀匹配当前项", () => { - expect(findNavItem("/")?.label).toBe("首页"); - expect(findNavItem("/library/abc")?.label).toBe("媒体库"); + expect(findNavItem("/console")?.label).toBe("首页"); + expect(findNavItem("/console/")?.label).toBe("首页"); + expect(findNavItem("/console/library/abc")?.label).toBe("媒体库"); + expect(findNavItem("/console/engine/r1")?.label).toBe("工作流引擎"); expect(findNavItem("/nope")).toBeUndefined(); }); + + it("consolePath 拼路径并编码", () => { + expect(consolePath()).toBe("/console"); + expect(consolePath("library", "a b")).toBe("/console/library/a%20b"); + }); }); diff --git a/apps/web/app/components/console/nav.ts b/apps/web/app/components/console/nav.ts index 78e61653..574347a3 100644 --- a/apps/web/app/components/console/nav.ts +++ b/apps/web/app/components/console/nav.ts @@ -1,4 +1,5 @@ import { + Activity, Bot, CalendarClock, Download, @@ -10,6 +11,7 @@ import { Library, type LucideIcon, Rss, + ScrollText, Settings, Tags, } from "lucide-react"; @@ -17,58 +19,114 @@ import { export type NavItem = { to: string; label: string; icon: LucideIcon; description: string }; export type NavGroup = { title: string; items: NavItem[] }; -/** 控制台 12 个工作区(任务书 §3.1 第 10 条 / REQ §13.1) */ +/** 控制台路由前缀 */ +export const CONSOLE_BASE = "/console"; + +/** 拼控制台路径:consolePath("library", id) → /console/library/ */ +export function consolePath(...segments: (string | number)[]): string { + const rest = segments.map((s) => encodeURIComponent(String(s))).join("/"); + return rest ? `${CONSOLE_BASE}/${rest}` : CONSOLE_BASE; +} + +/** 控制台 12 个工作区(任务书 §3.1 第 10 条 / REQ §13.1)+ 引擎面板与审计两个运维页 */ export const NAV_GROUPS: NavGroup[] = [ { title: "概览", items: [ { - to: "/", + to: "/console", label: "首页", icon: House, description: "自动化概览、当前工作、需要我处理、缓存与保留、来源健康、最近产出", }, - { to: "/inbox", label: "待处理中心", icon: Inbox, description: "按作品与根因合并的待决定事项,Agent 已先行调查" }, + { + to: "/console/inbox", + label: "待处理中心", + icon: Inbox, + description: "按作品与根因合并的待决定事项,Agent 已先行调查", + }, ], }, { title: "媒体", items: [ - { to: "/library", label: "媒体库", icon: Library, description: "作品、内容单元、版本目标与实际路径" }, - { to: "/identity", label: "身份与元数据", icon: Fingerprint, description: "外部身份映射、待识别池与元数据来源" }, - { to: "/organize", label: "整理记录", icon: FileStack, description: "Copy/Move 入库前后路径与逐文件结果" }, + { to: "/console/library", label: "媒体库", icon: Library, description: "作品、内容单元、版本目标与实际路径" }, + { + to: "/console/identity", + label: "身份与元数据", + icon: Fingerprint, + description: "外部身份映射、待识别池与元数据来源", + }, + { + to: "/console/organize", + label: "整理记录", + icon: FileStack, + description: "Copy/Move 入库前后路径与逐文件结果", + }, ], }, { title: "获取", items: [ { - to: "/subscriptions", + to: "/console/subscriptions", label: "订阅与收录规则", icon: CalendarClock, description: "单次收录与持续追踪,版本目标与预算", }, { - to: "/preferences", + to: "/console/preferences", label: "偏好标签", icon: Tags, description: "软偏好与硬规则,手动取消的偏好不会被自动加回", }, - { to: "/sites", label: "PT 站点工作台", icon: Globe, description: "统一格式浏览、搜索站点资源并关联本地库存" }, - { to: "/sources", label: "来源 / Feed / 快照", icon: Rss, description: "RSS 与站点快照的抓取状态、新鲜度与健康" }, - { to: "/tasks", label: "下载与任务", icon: Download, description: "下载、整理、核验的业务时间线与子任务" }, + { + to: "/console/sites", + label: "PT 站点工作台", + icon: Globe, + description: "统一格式浏览、搜索站点资源并关联本地库存", + }, + { + to: "/console/sources", + label: "来源 / Feed / 快照", + icon: Rss, + description: "RSS 与站点快照的抓取状态、新鲜度与健康", + }, + { + to: "/console/tasks", + label: "下载与任务", + icon: Download, + description: "下载、整理、核验的业务时间线与子任务", + }, ], }, { title: "助手与系统", items: [ - { to: "/agent", label: "Agent 会话", icon: Bot, description: "用自然语言交代目标,Agent 在授权内执行并汇报" }, { - to: "/settings", + to: "/console/agent", + label: "Agent 会话", + icon: Bot, + description: "用自然语言交代目标,Agent 在授权内执行并汇报", + }, + { + to: "/console/settings", label: "系统设置", icon: Settings, description: "下载器与媒体服务器实例、路径映射、API Key、Pi 上游与自动化开关", }, + { + to: "/console/engine", + label: "工作流引擎", + icon: Activity, + description: "持久任务引擎的运行记录、步骤尝试与信号(只读诊断,仅管理员)", + }, + { + to: "/console/audit", + label: "审计日志", + icon: ScrollText, + description: "登录、设置、密钥与实例变更的操作留痕", + }, ], }, ]; @@ -78,8 +136,8 @@ export const NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items); /** 按路径找当前导航项(最长前缀匹配) */ export function findNavItem(pathname: string): NavItem | undefined { return ( - NAV_ITEMS.find((i) => i.to === pathname) ?? - NAV_ITEMS.filter((i) => i.to !== "/" && pathname.startsWith(`${i.to}/`)).sort( + NAV_ITEMS.find((i) => i.to === pathname.replace(/\/+$/, "")) ?? + NAV_ITEMS.filter((i) => i.to !== CONSOLE_BASE && pathname.startsWith(`${i.to}/`)).sort( (a, b) => b.to.length - a.to.length, )[0] ); diff --git a/apps/web/app/components/console/stat-tile.tsx b/apps/web/app/components/console/stat-tile.tsx new file mode 100644 index 00000000..e95cf4de --- /dev/null +++ b/apps/web/app/components/console/stat-tile.tsx @@ -0,0 +1,67 @@ +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { cn } from "~/lib/utils.ts"; +import type { StatusTone } from "./status-badge.tsx"; + +const TONE_TEXT: Record = { + success: "text-success", + warning: "text-warning", + destructive: "text-destructive", + neutral: "text-foreground", +}; + +/** + * 数字磁贴:标签 + 大号数值 + 说明。tone 只给数值上色,标签/说明仍要用文字说清含义。 + * plain 用于已在卡片内(不再叠 pg-slab)。onClick 存在时整块可点(筛选联动)。 + */ +export function StatTile({ + label, + value, + hint, + icon: Icon, + tone = "neutral", + active = false, + onClick, + plain = false, + className, +}: { + label: ReactNode; + value: ReactNode; + hint?: ReactNode; + icon?: LucideIcon; + tone?: StatusTone; + active?: boolean; + onClick?: () => void; + plain?: boolean; + className?: string; +}) { + const content = ( + <> +
+ {Icon ? : null} + {label} +
+
{value}
+ {hint ?
{hint}
: null} + + ); + const base = cn( + "min-w-0 rounded-xl px-4 py-3 text-left", + plain ? "border border-border/70" : "pg-slab", + active && "ring-2 ring-ring/40", + className, + ); + if (onClick) { + return ( + + ); + } + return
{content}
; +} diff --git a/apps/web/app/components/console/timeline.tsx b/apps/web/app/components/console/timeline.tsx new file mode 100644 index 00000000..abc40d4a --- /dev/null +++ b/apps/web/app/components/console/timeline.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from "react"; +import { cn, formatDateTime } from "~/lib/utils.ts"; +import type { StatusTone } from "./status-badge.tsx"; + +export type TimelineItem = { + id: string; + /** ISO 时间;显示为本地时间 */ + at?: string | null; + title: ReactNode; + description?: ReactNode; + /** 圆点语气,与 StatusBadge 一致;标题里仍要有文字说明状态 */ + tone?: StatusTone; + /** 右侧附加信息(耗时、次数等) */ + meta?: ReactNode; + /** 展开区(json-view 等) */ + children?: ReactNode; +}; + +const DOT: Record = { + success: "bg-success", + warning: "bg-warning", + destructive: "bg-destructive", + neutral: "bg-muted-foreground/60", +}; + +/** 纵向时间线(业务时间线、步骤尝试、审计轨迹)。items 按展示顺序传入 */ +export function Timeline({ items, className }: { items: TimelineItem[]; className?: string }) { + return ( +
    + {items.map((it, i) => ( +
  1. + {i < items.length - 1 ? ( + + ) : null} + + + +
    +
    +
    {it.title}
    + {it.meta ?
    {it.meta}
    : null} +
    + {it.at ?

    {formatDateTime(it.at)}

    : null} + {it.description ? ( +
    {it.description}
    + ) : null} + {it.children ?
    {it.children}
    : null} +
    +
  2. + ))} +
+ ); +} diff --git a/apps/web/app/components/ui/tabs.tsx b/apps/web/app/components/ui/tabs.tsx new file mode 100644 index 00000000..3bf9aad3 --- /dev/null +++ b/apps/web/app/components/ui/tabs.tsx @@ -0,0 +1,38 @@ +import { Tabs as TabsPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "~/lib/utils.ts"; + +// 分段标签:列表横向可滚(scroll-bleed 保留描边不被裁),选中态用静态 pill-selected +function Tabs({ className, ...props }: React.ComponentProps) { + return ; +} + +function TabsList({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsTrigger({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ className, ...props }: React.ComponentProps) { + return ; +} + +export { Tabs, TabsContent, TabsList, TabsTrigger }; diff --git a/apps/web/app/components/ui/textarea.tsx b/apps/web/app/components/ui/textarea.tsx new file mode 100644 index 00000000..735891fe --- /dev/null +++ b/apps/web/app/components/ui/textarea.tsx @@ -0,0 +1,20 @@ +import type * as React from "react"; + +import { cn } from "~/lib/utils.ts"; + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +