chore: scaffold monorepo and migrate uhd_web design system (W0-A)

- pnpm workspace: apps/{web,server}, packages/{contracts,db,domain,adapters,tasks,agent,testkit}
- server: Hono + zod-openapi, API Key auth with scopes, redacting pino logger, SSE event bus with replay, worker heartbeat
- db: Kysely + pg, file migrator, 0001_init (settings/api_key/audit_event/outbox_event)
- web: React Router 7 SPA console shell with 12 workspace placeholders, uhd_web tokens/ui/guard scripts
- docs: ADR-001 (Node over Deno), dev runbook, design system rules

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
umtandClaude Fable 5.1 committed 2026-09-27 17:36:26 +08:00
commit b22c440139
138 files changed
+14455

No files matched your search

@@ -0,0 +1,80 @@
import { KeyRound } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { clearAuth, getToken, setToken } from "~/lib/api";
/** 设置本机使用的 API Key(存 localStorage,首版单管理员) */
export function ApiKeyDialog() {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const hasKey = typeof window !== "undefined" && !!getToken();
return (
<Dialog
open={open}
onOpenChange={(o) => {
setOpen(o);
if (o) setValue("");
}}
>
<DialogTrigger asChild>
<Button
variant="outline"
size="icon-sm"
aria-label="设置 API Key"
title={hasKey ? "已设置 API Key" : "设置 API Key"}
>
<KeyRound className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>API Key</DialogTitle>
<DialogDescription>控制台用它访问服务端接口,只保存在本浏览器。</DialogDescription>
</DialogHeader>
<Input
type="password"
autoComplete="off"
placeholder={hasKey ? "已设置,输入新值以替换" : "粘贴 API Key"}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<DialogFooter>
{hasKey ? (
<Button
variant="ghost"
onClick={() => {
clearAuth();
setOpen(false);
toast.success("已清除 API Key");
}}
>
清除
</Button>
) : null}
<Button
disabled={!value.trim()}
onClick={() => {
setToken(value.trim());
setOpen(false);
toast.success("已保存 API Key");
}}
>
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,35 @@
import type { LucideIcon } from "lucide-react";
import { Inbox } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "~/lib/utils";
/** 空态:图标 + 标题 + 说明 + 可选操作。用于"尚无数据"与"功能建设中"占位 */
export function EmptyState({
icon: Icon = Inbox,
title,
description,
action,
plain = false,
className,
}: {
icon?: LucideIcon;
title: string;
description?: ReactNode;
action?: ReactNode;
/** 放在已有卡片内时用 plain,避免玻璃叠玻璃 */
plain?: boolean;
className?: string;
}) {
return (
<div
className={cn("pg-slab flex flex-col items-center justify-center rounded-xl px-6 py-14 text-center", 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" />
</span>
<p className="text-lead font-medium text-foreground">{title}</p>
{description ? <p className="mt-1.5 max-w-md text-caption text-muted-foreground">{description}</p> : null}
{action ? <div className="mt-5">{action}</div> : null}
</div>
);
}
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { findNavItem, NAV_ITEMS } from "./nav";
describe("控制台导航", () => {
it("12 个工作区,路径唯一", () => {
expect(NAV_ITEMS).toHaveLength(12);
expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(12);
});
it("按最长前缀匹配当前项", () => {
expect(findNavItem("/")?.label).toBe("首页");
expect(findNavItem("/library/abc")?.label).toBe("媒体库");
expect(findNavItem("/nope")).toBeUndefined();
});
});
+86
View File
@@ -0,0 +1,86 @@
import {
Bot,
CalendarClock,
Download,
FileStack,
Fingerprint,
Globe,
House,
Inbox,
Library,
type LucideIcon,
Rss,
Settings,
Tags,
} from "lucide-react";
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 NAV_GROUPS: NavGroup[] = [
{
title: "概览",
items: [
{
to: "/",
label: "首页",
icon: House,
description: "自动化概览、当前工作、需要我处理、缓存与保留、来源健康、最近产出",
},
{ to: "/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 入库前后路径与逐文件结果" },
],
},
{
title: "获取",
items: [
{
to: "/subscriptions",
label: "订阅与收录规则",
icon: CalendarClock,
description: "单次收录与持续追踪,版本目标与预算",
},
{
to: "/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: "下载、整理、核验的业务时间线与子任务" },
],
},
{
title: "助手与系统",
items: [
{ to: "/agent", label: "Agent 会话", icon: Bot, description: "用自然语言交代目标,Agent 在授权内执行并汇报" },
{
to: "/settings",
label: "系统设置",
icon: Settings,
description: "下载器与媒体服务器实例、路径映射、API Key、Pi 上游与自动化开关",
},
],
},
];
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(
(a, b) => b.to.length - a.to.length,
)[0]
);
}
@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import { cn } from "~/lib/utils";
/** 控制台页面标题:标题 + 一句说明 + 右侧操作区 */
export function PageHeader({
title,
description,
actions,
className,
}: {
title: string;
description?: ReactNode;
actions?: ReactNode;
className?: string;
}) {
return (
<div className={cn("mb-6 flex flex-wrap items-end justify-between gap-4", className)}>
<div className="min-w-0">
<h1 className="text-title-lg font-semibold tracking-tight text-foreground">{title}</h1>
{description ? <p className="mt-1 text-body text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div>
);
}
@@ -0,0 +1,37 @@
import type { ReactNode } from "react";
import { cn } from "~/lib/utils";
export type StatusTone = "success" | "warning" | "destructive" | "neutral";
// 状态色只表示状态,不做装饰(docs/design/interface-materials.md)
const TONE: Record<StatusTone, { box: string; dot: string }> = {
success: { box: "border-success/30 bg-success/10 text-success", dot: "bg-success" },
warning: { box: "border-warning/30 bg-warning/10 text-warning", dot: "bg-warning" },
destructive: { box: "border-destructive/30 bg-destructive/10 text-destructive", dot: "bg-destructive" },
neutral: { box: "border-border bg-foreground/[0.04] text-muted-foreground", dot: "bg-muted-foreground" },
};
/** 状态徽章:文字必填,不只靠颜色传达状态 */
export function StatusBadge({
tone = "neutral",
children,
className,
}: {
tone?: StatusTone;
children: ReactNode;
className?: string;
}) {
const t = TONE[tone];
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-meta font-medium whitespace-nowrap",
t.box,
className,
)}
>
<span aria-hidden className={cn("h-1.5 w-1.5 rounded-full", t.dot)} />
{children}
</span>
);
}
@@ -0,0 +1,21 @@
import { Construction } from "lucide-react";
import { useLocation } from "react-router";
import { EmptyState } from "./empty-state";
import { findNavItem } from "./nav";
import { PageHeader } from "./page-header";
/** 工作区占位页:标题与说明取自导航表,正文为"建设中"空态 */
export function WorkspacePlaceholder({ milestone = "W3" }: { milestone?: string }) {
const { pathname } = useLocation();
const item = findNavItem(pathname);
return (
<>
<PageHeader title={item?.label ?? "未命名页面"} description={item?.description} />
<EmptyState
icon={item?.icon ?? Construction}
title="这个工作区还在建设中"
description={`计划在 ${milestone} 接入真实数据。`}
/>
</>
);
}