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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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} 接入真实数据。`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user