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 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 20:21:29 +08:00
1 parent a24bda952b
commit e5e42b1f0d
32 files changed
+1286 -71

No files matched your search

@@ -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)两种用法。
* <ConfirmDialog trigger={<Button variant="destructive">删除</Button>} 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<unknown>;
/** 描述下方的额外内容(输入确认等) */
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 (
<AlertDialog open={open} onOpenChange={setOpen}>
{trigger ? <AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger> : null}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
</AlertDialogHeader>
{children}
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>{cancelLabel}</AlertDialogCancel>
<Button variant={destructive ? "destructive" : "default"} onClick={run} disabled={pending}>
{pending ? <LoaderCircle className="animate-spin" /> : null}
{confirmLabel}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -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<T> = {
/** 列 key(React key 用) */
key: string;
header: ReactNode;
cell: (row: T) => ReactNode;
/** 窄屏隐藏该列,避免横向溢出 */
hideBelow?: "sm" | "md" | "lg";
align?: "left" | "right";
className?: string;
};
const HIDE: Record<NonNullable<DataColumn<unknown>["hideBelow"]>, string> = {
sm: "hidden sm:table-cell",
md: "hidden md:table-cell",
lg: "hidden lg:table-cell",
};
/**
* 数据表:内置 loading / error / empty 三态与"加载更多"游标翻页。
* 外层自带 pg-slab 卡片(plain 时不带);表格在卡片内横向滚动,页面本身不溢出。
*
* <DataTable columns={cols} rows={q.data?.items} rowKey={(r) => 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<T>({
columns,
rows,
rowKey,
loading = false,
error,
onRetry,
empty,
onRowClick,
selectedKey,
hasMore = false,
onLoadMore,
loadingMore = false,
plain = false,
className,
}: {
columns: DataColumn<T>[];
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 = <LoadingState rows={5} className="p-4" />;
} else if (error && !rows) {
body = <ErrorState plain error={error} onRetry={onRetry} />;
} else if (!rows || rows.length === 0) {
body = (
<EmptyState
plain
icon={empty?.icon}
title={empty?.title ?? "暂无数据"}
description={empty?.description}
action={empty?.action}
className="py-12"
/>
);
} else {
body = (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border">
{columns.map((c) => (
<th
key={c.key}
scope="col"
className={cn(
"px-4 py-2.5 text-meta font-medium whitespace-nowrap text-muted-foreground",
c.align === "right" && "text-right",
c.hideBelow && HIDE[c.hideBelow],
c.className,
)}
>
{c.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => {
const key = rowKey(row);
return (
<tr
key={key}
onClick={onRowClick ? () => 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) => (
<td
key={c.key}
className={cn(
"px-4 py-2.5 align-middle",
c.align === "right" && "text-right",
c.hideBelow && HIDE[c.hideBelow],
c.className,
)}
>
{c.cell(row)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
return (
<div className={cn("min-w-0 overflow-hidden rounded-xl", !plain && "pg-slab", className)}>
{body}
{rows && rows.length > 0 && (hasMore || error) ? (
<div className="flex items-center justify-center gap-3 border-t border-border/60 px-4 py-3">
{error ? (
<span className="text-meta text-destructive">{String((error as Error).message ?? error)}</span>
) : null}
{hasMore && onLoadMore ? (
<Button variant="outline" size="sm" onClick={onLoadMore} disabled={loadingMore}>
{loadingMore ? "加载中…" : "加载更多"}
</Button>
) : null}
</div>
) : null}
</div>
);
}
@@ -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 固定在底部放操作按钮。
* <DetailSheet open={!!id} onOpenChange={(o) => !o && setId(null)} title="详情" description="…">
* <KvList items={…} />
* </DetailSheet>
*/
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className={cn("w-full gap-0 p-0", width === "lg" ? "sm:max-w-2xl" : "sm:max-w-lg", className)}
>
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
<SheetTitle className="truncate text-lead">{title}</SheetTitle>
{description ? <SheetDescription className="break-words">{description}</SheetDescription> : null}
</SheetHeader>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">{children}</div>
{footer ? (
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
{footer}
</div>
) : null}
</SheetContent>
</Sheet>
);
}
/** 抽屉/卡片内的小节:大写小标签 + 内容 */
export function DetailSection({
title,
children,
actions,
}: {
title: string;
children: ReactNode;
actions?: ReactNode;
}) {
return (
<section>
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">{title}</h3>
{actions}
</div>
{children}
</section>
);
}
@@ -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 (
<div
className={cn("pg-slab flex flex-col items-center justify-center rounded-xl px-6 py-14 text-center", className)}
className={cn(
"flex flex-col items-center justify-center rounded-xl px-6 py-14 text-center",
!plain && "pg-slab",
className,
)}
>
<span className="mb-4 grid h-11 w-11 place-items-center rounded-full bg-foreground/[0.05] text-muted-foreground">
<Icon className="h-5 w-5" />
@@ -33,3 +38,53 @@ export function EmptyState({
</div>
);
}
/** 错误态:图标 + 标题 + 错误消息 + 可选重试。放在卡片里时用 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 (
<div
role="alert"
className={cn(
"flex flex-col items-center justify-center rounded-xl px-6 py-12 text-center",
!plain && "pg-slab",
className,
)}
>
<span className="mb-4 grid h-11 w-11 place-items-center rounded-full bg-destructive/10 text-destructive">
<AlertTriangle className="h-5 w-5" />
</span>
<p className="text-lead font-medium text-foreground">{title}</p>
{message ? <p className="mt-1.5 max-w-md text-caption break-words text-muted-foreground">{message}</p> : null}
{onRetry ? (
<Button variant="outline" size="sm" className="mt-5" onClick={onRetry}>
<RotateCw />
重试
</Button>
) : null}
</div>
);
}
/** 加载态:若干行骨架条 */
export function LoadingState({ rows = 3, className }: { rows?: number; className?: string }) {
return (
<div role="status" aria-busy="true" aria-label="加载中" className={cn("space-y-2.5 py-2", className)}>
{Array.from({ length: rows }, (_, i) => (
<div key={i} className="h-9 animate-pulse rounded-tight bg-foreground/[0.06]" />
))}
</div>
);
}
@@ -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)+ 可选"清除"。窄屏自动换行。
* <FilterBar search={{ value: q, onChange: setQ, placeholder: "搜索名称" }} onReset={reset}>
* <FilterSelect label="状态" value={s} onChange={setS} options={[...]} />
* </FilterBar>
*/
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 (
<div className={cn("mb-4 flex flex-wrap items-center gap-2", className)}>
{search ? (
<div className="relative w-full sm:w-64">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search.value}
onChange={(e) => search.onChange(e.target.value)}
placeholder={search.placeholder ?? "搜索"}
className="pl-9"
aria-label={search.placeholder ?? "搜索"}
/>
</div>
) : null}
{children}
{onReset ? (
<Button variant="ghost" size="sm" onClick={onReset}>
<X />
清除筛选
</Button>
) : null}
{actions ? <div className="ml-auto flex items-center gap-2">{actions}</div> : null}
</div>
);
}
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 (
<Select value={value || ALL} onValueChange={(v) => onChange(v === ALL ? "" : v)}>
<SelectTrigger size="sm" aria-label={label} className={cn("min-w-32", className)}>
<span className="text-muted-foreground">{label}</span>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value={ALL}>{allLabel}</SelectItem>
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -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}。
* <FormField label="名称" htmlFor="name" error={errors.name} hint="…">
* <Input id="name" aria-invalid={!!errors.name} … />
* </FormField>
*/
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 (
<div className={cn("grid min-w-0 gap-1.5", className)}>
<Label htmlFor={htmlFor} className="text-caption font-medium text-foreground">
{label}
{required ? <span className="text-destructive">*</span> : null}
</Label>
{children}
<p
className={cn("min-h-4 text-meta", error ? "text-destructive" : "text-muted-foreground")}
role={error ? "alert" : undefined}
>
{error || hint || ""}
</p>
</div>
);
}
/** 把 zod safeParse 的错误摊平成 { 字段路径: 第一条消息 } */
export function zodFieldErrors(issues: { path: PropertyKey[]; message: string }[]): Record<string, string> {
const out: Record<string, string> = {};
for (const i of issues) {
const k = i.path.map(String).join(".");
if (!(k in out)) out[k] = i.message;
}
return out;
}
@@ -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 (
<div className={cn("min-w-0", className)}>
{label ? (
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="mb-1.5 flex cursor-pointer items-center gap-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase hover:text-foreground"
>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform duration-base", open && "rotate-90")} />
{label}
</button>
) : null}
{open || !label ? (
empty ? (
<p className="text-caption text-muted-foreground">无</p>
) : (
<div
className={cn(
"relative rounded-lg border bg-foreground/[0.03]",
tone === "destructive" ? "border-destructive/30" : "border-border",
)}
>
<button
type="button"
onClick={copy}
aria-label="复制"
className="absolute top-1.5 right-1.5 grid h-7 w-7 cursor-pointer place-items-center rounded-full text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
<pre
className={cn(
"overflow-auto p-3 pr-10 font-mono text-meta leading-relaxed break-all whitespace-pre-wrap",
tone === "destructive" ? "text-destructive" : "text-foreground",
)}
style={{ maxHeight }}
>
{text}
</pre>
</div>
)
) : null}
</div>
);
}
@@ -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 (
<dl className={cn("grid gap-x-6 gap-y-3", columns === 2 && "sm:grid-cols-2", className)}>
{items.map((it, i) => (
<div key={i} className={cn("min-w-0", it.wide && "sm:col-span-2")}>
<dt className="text-meta text-muted-foreground">{it.label}</dt>
<dd
className={cn("mt-0.5 text-sm break-words text-foreground", it.mono && "font-mono text-caption break-all")}
>
{it.value === null || it.value === undefined || it.value === "" ? "—" : it.value}
</dd>
</div>
))}
</dl>
);
}
+14 -6
View File
@@ -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");
});
});
+73 -15
View File
@@ -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/<id> */
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]
);
@@ -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<StatusTone, string> = {
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 = (
<>
<div className="flex items-center gap-1.5 text-meta text-muted-foreground">
{Icon ? <Icon className="h-3.5 w-3.5" /> : null}
<span className="truncate">{label}</span>
</div>
<div className={cn("mt-1 text-title-sm font-semibold tabular-nums", TONE_TEXT[tone])}>{value}</div>
{hint ? <div className="mt-0.5 truncate text-meta text-muted-foreground">{hint}</div> : 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 (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(base, "cursor-pointer transition-colors duration-micro hover:bg-foreground/[0.04]")}
>
{content}
</button>
);
}
return <div className={base}>{content}</div>;
}
@@ -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<StatusTone, string> = {
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 (
<ol className={cn("relative space-y-4", className)}>
{items.map((it, i) => (
<li key={it.id} className="relative flex gap-3 pl-1">
{i < items.length - 1 ? (
<span aria-hidden className="absolute top-4 bottom-[-1rem] left-[0.6875rem] w-px bg-border" />
) : null}
<span aria-hidden className="relative mt-1.5 grid h-3 w-3 shrink-0 place-items-center">
<span className={cn("h-2.5 w-2.5 rounded-full ring-4 ring-background", DOT[it.tone ?? "neutral"])} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
<div className="min-w-0 text-sm font-medium break-words text-foreground">{it.title}</div>
{it.meta ? <div className="shrink-0 text-meta text-muted-foreground">{it.meta}</div> : null}
</div>
{it.at ? <p className="text-meta text-muted-foreground tabular-nums">{formatDateTime(it.at)}</p> : null}
{it.description ? (
<div className="mt-1 text-caption break-words text-muted-foreground">{it.description}</div>
) : null}
{it.children ? <div className="mt-2">{it.children}</div> : null}
</div>
</li>
))}
</ol>
);
}