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

+3
View File
@@ -0,0 +1,3 @@
# @umt/server
Node 服务进程:Hono HTTP API(前缀 `/api/v1`,OpenAPI 见 `/api/openapi.json`、`/api/docs`)、SSE 事件流与 worker 入口。`--role api|worker|all` 决定本进程承担哪部分,默认 `all` 单进程运行。业务规则不写在这里,只做编排、鉴权、审计与协议适配。
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@umt/server",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch --env-file-if-exists=../../.env src/main.ts",
"start": "node --env-file-if-exists=../../.env dist/main.js",
"build": "node scripts/build.mjs",
"typecheck": "tsc -p tsconfig.json",
"test": "vitest run"
},
"dependencies": {
"@hono/node-server": "^2.1.1",
"@hono/swagger-ui": "^0.6.1",
"@hono/zod-openapi": "^1.6.3",
"@umt/contracts": "workspace:*",
"@umt/db": "workspace:*",
"hono": "^4.13.9",
"kysely": "^0.29.6",
"pg": "^8.23.0",
"pino": "^10.3.1",
"zod": "^4.3.6"
},
"devDependencies": {
"esbuild": "^0.28.2",
"pino-pretty": "^13.1.3"
}
}
+42
View File
@@ -0,0 +1,42 @@
// 打包 server:工作区包(@umt/*)以源码内联,npm 依赖保持 external 运行时从 node_modules 加载。
// external 依赖必须在本包 package.json 的 dependencies 里声明(工作区包的运行时依赖也要在此重复声明),
// 否则运行时解析不到,构建直接报错。
import { readFileSync } from "node:fs";
import { builtinModules } from "node:module";
import { build } from "esbuild";
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
const deps = new Set(Object.keys(pkg.dependencies ?? {}));
const builtins = new Set(builtinModules);
const pkgName = (p) => (p.startsWith("@") ? p.split("/").slice(0, 2).join("/") : p.split("/")[0]);
await build({
entryPoints: ["src/main.ts"],
outfile: "dist/main.js",
bundle: true,
platform: "node",
target: "node24",
format: "esm",
sourcemap: true,
logLevel: "info",
plugins: [
{
name: "external-npm",
setup(b) {
b.onResolve({ filter: /^[^./]/ }, (args) => {
if (args.path.startsWith("node:") || builtins.has(args.path)) return { path: args.path, external: true };
const name = pkgName(args.path);
if (name.startsWith("@umt/")) return undefined;
if (!deps.has(name)) {
return {
errors: [
{ text: `运行时依赖 ${name} 未在 apps/server/package.json dependencies 中声明(来自 ${args.importer})` },
],
};
}
return { path: args.path, external: true };
});
},
},
],
});
+28
View File
@@ -0,0 +1,28 @@
import { z } from "zod";
const LogLevel = z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]);
export const ConfigSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().min(1).max(65535).default(4380),
DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, "必须是 postgres:// 连接串"),
SECRET_MASTER_KEY: z.string().min(16, "SECRET_MASTER_KEY 至少 16 个字符"),
LOG_LEVEL: LogLevel.default("info"),
/** 仅开发模式生效的管理员 Key */
DEV_ADMIN_KEY: z.string().min(8).optional(),
/** worker 心跳间隔 */
WORKER_HEARTBEAT_MS: z.coerce.number().int().min(1000).default(30_000),
});
export type Config = z.infer<typeof ConfigSchema>;
/** 从环境变量加载并校验配置;失败时抛出可读错误(不含变量值) */
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const parsed = ConfigSchema.safeParse(env);
if (!parsed.success) {
const lines = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`);
throw new Error(`配置无效:\n${lines.join("\n")}`);
}
return parsed.data;
}
export const isDev = (c: Pick<Config, "NODE_ENV">) => c.NODE_ENV !== "production";
+114
View File
@@ -0,0 +1,114 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import type { DB } from "@umt/db";
import { type Kysely, sql } from "@umt/db";
import { cors } from "hono/cors";
import { HTTPException } from "hono/http-exception";
import { requestId } from "hono/request-id";
import type { Logger } from "pino";
import type { Config } from "../config";
import { isDev } from "../config";
import { redactString } from "../logger";
import { type ApiKeyStore, type AuthEnv, apiKeyAuth, kyselyApiKeyStore, requireScope } from "./auth";
import { ApiException, errorBody } from "./errors";
import { healthRoutes } from "./routes/health";
import { mountOpenApi } from "./routes/openapi";
import { type EventBus, sseRoutes } from "./sse";
export interface AppDeps {
config: Pick<Config, "NODE_ENV" | "DEV_ADMIN_KEY">;
logger: Logger;
bus: EventBus;
version: string;
role: "api" | "worker" | "all";
startedAt?: number;
db?: Kysely<DB>;
/** 测试可注入;默认用 db 构造 */
apiKeyStore?: ApiKeyStore;
}
export const API_PREFIX = "/api/v1";
export function createApp(deps: AppDeps) {
const { config, logger, bus, db } = deps;
const dev = isDev(config);
const app = new OpenAPIHono<AuthEnv>({
// zod 校验失败统一成 validation_failed 信封
defaultHook: (result, c) => {
if (!result.success) {
const msg = result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
return c.json(errorBody("validation_failed", msg), 422);
}
},
});
app.use("*", requestId());
// 访问日志:路径中的 passkey/token 参数脱敏
app.use("*", async (c, next) => {
const t0 = performance.now();
await next();
logger.info(
{
reqId: c.get("requestId"),
method: c.req.method,
url: redactString(c.req.url.replace(/^https?:\/\/[^/]+/, "")),
status: c.res.status,
ms: Math.round(performance.now() - t0),
},
"http",
);
});
if (dev) {
app.use("/api/*", cors({ origin: ["http://localhost:5180", "http://127.0.0.1:5180"], credentials: true }));
}
app.onError((err, c) => {
if (err instanceof ApiException) return c.json(errorBody(err.code, err.message), err.status as 400);
if (err instanceof HTTPException) {
const code = err.status === 401 ? "unauthorized" : err.status === 404 ? "not_found" : "bad_request";
return c.json(errorBody(code, err.message), err.status);
}
logger.error({ err, reqId: c.get("requestId") }, "unhandled error");
return c.json(errorBody("internal", "服务器内部错误"), 500);
});
app.notFound((c) => c.json(errorBody("not_found", `未找到 ${c.req.method} ${c.req.path}`), 404));
// ── 免鉴权 ──
app.route(
API_PREFIX,
healthRoutes({
version: deps.version,
role: deps.role,
startedAt: deps.startedAt ?? Date.now(),
...(db
? {
pingDb: async () => {
await sql`select 1`.execute(db);
return true;
},
}
: {}),
}),
);
mountOpenApi(app, deps.version);
// ── 需鉴权:/api/v1 下除 health 外全部 ──
const store = deps.apiKeyStore ?? (db ? kyselyApiKeyStore(db) : null);
const auth = apiKeyAuth({ store, devAdminKey: dev ? config.DEV_ADMIN_KEY : undefined });
app.use(`${API_PREFIX}/*`, async (c, next) => {
if (c.req.path === `${API_PREFIX}/health`) return next();
return auth(c, next);
});
app.use(`${API_PREFIX}/events`, requireScope("read"));
app.route(API_PREFIX, sseRoutes({ bus }));
// 鉴权自检:返回当前调用者
app.get(`${API_PREFIX}/auth/whoami`, requireScope("read"), (c) => {
const p = c.get("principal");
return c.json({ ok: true, data: { id: p.id, name: p.name, kind: p.kind, scopes: p.scopes } });
});
return app;
}
export type App = ReturnType<typeof createApp>;
+99
View File
@@ -0,0 +1,99 @@
import { createHash, timingSafeEqual } from "node:crypto";
import type { ApiScope } from "@umt/contracts";
import type { DB, Kysely } from "@umt/db";
import type { MiddlewareHandler } from "hono";
import { ApiException } from "./errors";
export interface Principal {
kind: "api_key" | "dev_admin";
id: string;
name: string;
scopes: string[];
}
export interface ApiKeyRecord {
id: string;
name: string;
scopes: string[];
revokedAt: Date | null;
}
/** API Key 查询接口,便于测试替换 */
export interface ApiKeyStore {
findByHash(hash: string): Promise<ApiKeyRecord | null>;
touch(id: string): Promise<void>;
}
export const hashApiKey = (key: string) => createHash("sha256").update(key, "utf8").digest("hex");
export function kyselyApiKeyStore(db: Kysely<DB>): ApiKeyStore {
return {
async findByHash(hash) {
const row = await db
.selectFrom("api_key")
.select(["id", "name", "scopes", "revoked_at"])
.where("key_hash", "=", hash)
.executeTakeFirst();
return row ? { id: row.id, name: row.name, scopes: row.scopes, revokedAt: row.revoked_at } : null;
},
async touch(id) {
await db.updateTable("api_key").set({ last_used_at: new Date() }).where("id", "=", id).execute();
},
};
}
export interface AuthOptions {
store: ApiKeyStore | null;
/** 仅开发模式传入 */
devAdminKey?: string | undefined;
}
export type AuthEnv = { Variables: { principal: Principal } };
function safeEqual(a: string, b: string) {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
/** Bearer API Key 鉴权:成功后在 c.var.principal 写入调用者 */
export function apiKeyAuth(opts: AuthOptions): MiddlewareHandler<AuthEnv> {
return async (c, next) => {
const header = c.req.header("authorization") ?? "";
const m = /^Bearer\s+(\S+)$/i.exec(header);
if (!m?.[1]) throw new ApiException("unauthorized", "缺少 Bearer API Key");
const key = m[1];
if (opts.devAdminKey && safeEqual(key, opts.devAdminKey)) {
c.set("principal", { kind: "dev_admin", id: "dev-admin", name: "dev-admin", scopes: ["admin"] });
return next();
}
if (!opts.store) throw new ApiException("unauthorized", "API Key 无效");
const rec = await opts.store.findByHash(hashApiKey(key));
if (!rec || rec.revokedAt) throw new ApiException("unauthorized", "API Key 无效或已撤销");
c.set("principal", { kind: "api_key", id: rec.id, name: rec.name, scopes: rec.scopes });
// last_used_at 尽力更新,失败不影响请求
opts.store.touch(rec.id).catch(() => {});
return next();
};
}
/** scope 蕴含关系:admin 覆盖全部;organize.execute 蕴含 organize.read */
export function hasScope(granted: readonly string[], required: ApiScope): boolean {
if (granted.includes("admin")) return true;
if (granted.includes(required)) return true;
if (required === "organize.read" && granted.includes("organize.execute")) return true;
return false;
}
/** 要求调用者至少具备其中一个 scope */
export function requireScope(...anyOf: ApiScope[]): MiddlewareHandler<AuthEnv> {
return async (c, next) => {
const p = c.get("principal");
if (!p) throw new ApiException("unauthorized", "未鉴权");
if (!anyOf.some((s) => hasScope(p.scopes, s))) {
throw new ApiException("forbidden", `需要 scope: ${anyOf.join(" | ")}`);
}
return next();
};
}
+15
View File
@@ -0,0 +1,15 @@
import { API_ERROR_STATUS, type ApiErrorCode } from "@umt/contracts";
/** 业务可抛出的 API 错误,由 app.onError 统一转换为信封 */
export class ApiException extends Error {
readonly status: number;
constructor(
readonly code: ApiErrorCode,
message: string,
) {
super(message);
this.status = API_ERROR_STATUS[code];
}
}
export const errorBody = (code: ApiErrorCode, message: string) => ({ ok: false as const, error: { code, message } });
+46
View File
@@ -0,0 +1,46 @@
import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
import { apiResponse, HealthStatus } from "@umt/contracts";
export interface HealthDeps {
version: string;
role: "api" | "worker" | "all";
startedAt: number;
/** 返回数据库是否可用;未配置时省略 */
pingDb?: () => Promise<boolean>;
}
const route = createRoute({
method: "get",
path: "/health",
tags: ["system"],
summary: "服务健康状态(免鉴权)",
responses: {
200: {
description: "服务状态",
content: { "application/json": { schema: apiResponse(HealthStatus) } },
},
},
});
export function healthRoutes(deps: HealthDeps) {
const app = new OpenAPIHono();
app.openapi(route, async (c) => {
let db: HealthStatus["db"] = "unknown";
if (deps.pingDb) {
db = await deps.pingDb().then(
(ok) => (ok ? "ok" : "down"),
() => "down" as const,
);
}
const data: HealthStatus = {
status: db === "down" ? "degraded" : "ok",
version: deps.version,
role: deps.role,
uptimeSec: Math.round((Date.now() - deps.startedAt) / 1000),
db,
time: new Date().toISOString(),
};
return c.json({ ok: true, data }, 200);
});
return app;
}
+18
View File
@@ -0,0 +1,18 @@
import { swaggerUI } from "@hono/swagger-ui";
import type { OpenAPIHono } from "@hono/zod-openapi";
/** 挂载 /api/openapi.json 与 /api/docs */
// biome-ignore lint/suspicious/noExplicitAny: 根 app 的 Env 泛型各异
export function mountOpenApi(app: OpenAPIHono<any>, version: string) {
app.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
type: "http",
scheme: "bearer",
description: "API Key",
});
app.doc31("/api/openapi.json", {
openapi: "3.1.0",
info: { title: "UHD Media Tool API", version },
security: [{ bearerAuth: [] }],
});
app.get("/api/docs", swaggerUI({ url: "/api/openapi.json" }));
}
+106
View File
@@ -0,0 +1,106 @@
import { type EventEnvelope, EventType } from "@umt/contracts";
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { ApiException } from "./errors";
type Listener = (e: EventEnvelope) => void;
/**
* 进程内事件总线:序号自增,保留最近 capacity 条供断线补读。
* 多进程部署时需换成 PG LISTEN/NOTIFY 或 outbox 投影(W2)。
*/
export class EventBus {
private seq = 0;
private readonly buffer: EventEnvelope[] = [];
private readonly listeners = new Set<Listener>();
constructor(private readonly capacity = 1000) {}
get latestSeq() {
return this.seq;
}
publish<P>(type: string, payload: P): EventEnvelope<P> {
const e: EventEnvelope<P> = { seq: ++this.seq, type, at: new Date().toISOString(), payload };
this.buffer.push(e);
if (this.buffer.length > this.capacity) this.buffer.shift();
for (const l of this.listeners) l(e);
return e;
}
/** 取 seq > after 的缓冲事件;gap=true 表示请求的起点已被淘汰,客户端需重新拉快照 */
since(after: number): { events: EventEnvelope[]; gap: boolean } {
const oldest = this.buffer[0]?.seq ?? this.seq + 1;
const gap = after > 0 && after < oldest - 1;
return { events: this.buffer.filter((e) => e.seq > after), gap };
}
subscribe(l: Listener): () => void {
this.listeners.add(l);
return () => this.listeners.delete(l);
}
}
export interface SseOptions {
bus: EventBus;
keepaliveMs?: number;
}
/** GET /events?after=<seq>(也接受 Last-Event-ID 头) */
export function sseRoutes({ bus, keepaliveMs = 15_000 }: SseOptions) {
const app = new Hono();
app.get("/events", (c) => {
const raw = c.req.query("after") ?? c.req.header("last-event-id") ?? "0";
const after = Number(raw);
if (!Number.isInteger(after) || after < 0) throw new ApiException("bad_request", "after 必须是非负整数");
return streamSSE(c, async (stream) => {
const queue: EventEnvelope[] = [];
let wake: (() => void) | null = null;
// 先订阅再读缓冲,避免两者之间的事件丢失
const unsubscribe = bus.subscribe((e) => {
queue.push(e);
wake?.();
});
let closed = false;
stream.onAbort(() => {
closed = true;
wake?.();
});
const { events, gap } = bus.since(after);
let last = after;
if (gap) {
await stream.writeSSE({ event: EventType.resync, data: JSON.stringify({ latestSeq: bus.latestSeq }) });
}
const write = async (e: EventEnvelope) => {
if (e.seq <= last) return;
last = e.seq;
await stream.writeSSE({ id: String(e.seq), event: e.type, data: JSON.stringify(e) });
};
for (const e of events) await write(e);
const timer = setInterval(() => {
void stream.write(": keepalive\n\n");
}, keepaliveMs);
try {
while (!closed) {
while (queue.length) {
const e = queue.shift();
if (e) await write(e);
}
if (closed) break;
await new Promise<void>((r) => {
if (queue.length || closed) r();
else wake = r;
});
wake = null;
}
} finally {
clearInterval(timer);
unsubscribe();
}
});
});
return app;
}
+58
View File
@@ -0,0 +1,58 @@
import pino, { type Logger } from "pino";
export const REDACTED = "[REDACTED]";
// 字段名包含这些词即视为敏感(数值型如 token 计数除外)
const SENSITIVE_KEY = /passkey|token|cookie|authorization|password|secret|api[-_]?key/i;
// URL query 中的敏感参数
const SENSITIVE_QUERY = /([?&](?:passkey|token|access_token|api_key|apikey|auth|sign)=)[^&#\s"'<>]*/gi;
/** 脱敏字符串中的 URL query 参数 */
export function redactString(s: string): string {
return s.replace(SENSITIVE_QUERY, `$1${REDACTED}`);
}
/** 递归脱敏:敏感字段整体替换,其余字符串做 URL query 脱敏 */
export function redact(value: unknown, depth = 0): unknown {
if (depth > 8) return value;
if (typeof value === "string") return redactString(value);
if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1));
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
if (SENSITIVE_KEY.test(k) && typeof v !== "number" && v != null) out[k] = REDACTED;
else out[k] = redact(v, depth + 1);
}
return out;
}
return value;
}
export interface LoggerOptions {
level?: string;
pretty?: boolean;
/** 测试用:自定义输出流 */
destination?: pino.DestinationStream;
}
export function createLogger(opts: LoggerOptions = {}): Logger {
const options: pino.LoggerOptions = {
level: opts.level ?? "info",
formatters: {
// 所有结构化字段(含 err 序列化结果)统一过一遍脱敏
log: (obj) => redact(obj) as Record<string, unknown>,
},
hooks: {
// msg 文本也脱敏
logMethod(args, method) {
const cleaned = args.map((a) => (typeof a === "string" ? redactString(a) : a)) as typeof args;
return method.apply(this, cleaned);
},
},
};
if (opts.destination) return pino(options, opts.destination);
if (opts.pretty) {
return pino({ ...options, transport: { target: "pino-pretty", options: { translateTime: "SYS:HH:MM:ss" } } });
}
return pino(options);
}
+66
View File
@@ -0,0 +1,66 @@
import { parseArgs } from "node:util";
import { serve } from "@hono/node-server";
import { createDb } from "@umt/db";
import pkg from "../package.json" with { type: "json" };
import { loadConfig } from "./config";
import { createApp } from "./http/app";
import { EventBus } from "./http/sse";
import { createLogger } from "./logger";
import { startWorker } from "./worker";
const Roles = ["api", "worker", "all"] as const;
type Role = (typeof Roles)[number];
const { values } = parseArgs({ options: { role: { type: "string", default: "all" } }, strict: true });
const role = values.role as Role;
if (!Roles.includes(role)) {
console.error(`--role 必须是 ${Roles.join("|")}`);
process.exit(2);
}
let config: ReturnType<typeof loadConfig>;
try {
config = loadConfig();
} catch (err) {
console.error((err as Error).message);
process.exit(2);
}
const logger = createLogger({
level: config.LOG_LEVEL,
pretty: config.NODE_ENV === "development" && process.stdout.isTTY,
});
const db = createDb({ connectionString: config.DATABASE_URL });
const bus = new EventBus();
const stops: Array<() => Promise<void>> = [];
if (role === "api" || role === "all") {
const app = createApp({ config, logger, bus, db, role, version: pkg.version });
const server = serve({ fetch: app.fetch, port: config.PORT }, (info) => {
logger.info({ port: info.port, role }, `API 已启动 http://localhost:${info.port}/api/v1/health`);
});
stops.push(
() =>
new Promise((resolve) => {
// SSE 长连接不会自行结束,强制关闭
(server as unknown as { closeAllConnections?: () => void }).closeAllConnections?.();
server.close(() => resolve());
}),
);
}
if (role === "worker" || role === "all") {
const worker = startWorker({ logger, heartbeatMs: config.WORKER_HEARTBEAT_MS, bus });
stops.push(worker.stop);
}
let stopping = false;
async function shutdown(signal: string) {
if (stopping) return;
stopping = true;
logger.info({ signal }, "正在退出");
await Promise.allSettled(stops.map((s) => s()));
await db.destroy();
process.exit(0);
}
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
+30
View File
@@ -0,0 +1,30 @@
import { EventType } from "@umt/contracts";
import type { Logger } from "pino";
import type { EventBus } from "../http/sse";
export interface WorkerDeps {
logger: Logger;
heartbeatMs: number;
/** role=all 时与 API 共享同一总线,心跳可在 SSE 中看到 */
bus?: EventBus;
}
/** worker 占位:只打印心跳。持久任务引擎在 W1(packages/tasks)接入 */
export function startWorker({ logger, heartbeatMs, bus }: WorkerDeps) {
const log = logger.child({ component: "worker" });
let n = 0;
const beat = () => {
n += 1;
log.debug({ n }, "worker heartbeat");
bus?.publish(EventType.heartbeat, { role: "worker", n });
};
log.info({ heartbeatMs }, "worker started");
beat();
const timer = setInterval(beat, heartbeatMs);
return {
async stop() {
clearInterval(timer);
log.info("worker stopped");
},
};
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { hasScope } from "../src/http/auth";
import { DEV_KEY, json, memoryStore, testApp } from "./helpers";
const store = memoryStore({
"key-read": { id: "k1", scopes: ["read"] },
"key-org": { id: "k2", scopes: ["organize.execute"] },
"key-revoked": { id: "k3", scopes: ["admin"], revoked: true },
});
const whoami = (app: ReturnType<typeof testApp>["app"], key?: string) =>
app.request("/api/v1/auth/whoami", key ? { headers: { Authorization: `Bearer ${key}` } } : {});
describe("API Key 鉴权", () => {
it("缺少 Key → 401", async () => {
const res = await whoami(testApp({ store }).app);
expect(res.status).toBe(401);
expect((await json(res)).error.code).toBe("unauthorized");
});
it("有效 Key 通过,返回 principal", async () => {
const res = await whoami(testApp({ store }).app, "key-read");
expect(res.status).toBe(200);
expect((await json(res)).data.id).toBe("k1");
});
it("未知或已撤销 Key → 401", async () => {
const { app } = testApp({ store });
expect((await whoami(app, "bogus")).status).toBe(401);
expect((await whoami(app, "key-revoked")).status).toBe(401);
});
it("scope 不足 → 403", async () => {
const res = await whoami(testApp({ store }).app, "key-org");
expect(res.status).toBe(403);
expect((await json(res)).error.code).toBe("forbidden");
});
it("开发模式 DEV_ADMIN_KEY 可用,生产模式不可用", async () => {
expect((await whoami(testApp({ store }).app, DEV_KEY)).status).toBe(200);
expect((await whoami(testApp({ store, nodeEnv: "production" }).app, DEV_KEY)).status).toBe(401);
});
it("scope 蕴含关系", () => {
expect(hasScope(["admin"], "organize.execute")).toBe(true);
expect(hasScope(["organize.execute"], "organize.read")).toBe(true);
expect(hasScope(["organize.read"], "organize.execute")).toBe(false);
expect(hasScope(["read"], "organize.read")).toBe(false);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { json, testApp } from "./helpers";
describe("GET /api/v1/health", () => {
it("免鉴权返回 ok 信封", async () => {
const { app } = testApp();
const res = await app.request("/api/v1/health");
expect(res.status).toBe(200);
const body = await json(res);
expect(body.ok).toBe(true);
expect(body.data.status).toBe("ok");
expect(body.data.db).toBe("unknown");
expect(body.data.role).toBe("all");
});
it("未知路由返回 not_found 信封", async () => {
const { app } = testApp();
const res = await app.request("/nope");
expect(res.status).toBe(404);
expect((await json(res)).error.code).toBe("not_found");
});
it("OpenAPI 文档包含 health", async () => {
const { app } = testApp();
const doc = await json(await app.request("/api/openapi.json"));
expect(doc.paths["/api/v1/health"]).toBeDefined();
});
});
+49
View File
@@ -0,0 +1,49 @@
import { Writable } from "node:stream";
import { createApp } from "../src/http/app";
import { type ApiKeyStore, hashApiKey } from "../src/http/auth";
import { EventBus } from "../src/http/sse";
import { createLogger } from "../src/logger";
export const DEV_KEY = "dev-admin-test-key";
/** 收集日志行 */
export function memoryLogger() {
const lines: string[] = [];
const dest = new Writable({
write(chunk, _enc, cb) {
lines.push(chunk.toString());
cb();
},
});
return { logger: createLogger({ level: "info", destination: dest }), lines };
}
/** 内存 API Key 仓库 */
export function memoryStore(keys: Record<string, { id: string; scopes: string[]; revoked?: boolean }>): ApiKeyStore {
const byHash = new Map(Object.entries(keys).map(([k, v]) => [hashApiKey(k), v]));
return {
async findByHash(h) {
const v = byHash.get(h);
return v ? { id: v.id, name: v.id, scopes: v.scopes, revokedAt: v.revoked ? new Date() : null } : null;
},
async touch() {},
};
}
export function testApp(opts: { nodeEnv?: "development" | "production"; store?: ApiKeyStore } = {}) {
const bus = new EventBus(5);
const { logger, lines } = memoryLogger();
const app = createApp({
config: { NODE_ENV: opts.nodeEnv ?? "development", DEV_ADMIN_KEY: DEV_KEY },
logger,
bus,
role: "all",
version: "0.0.0-test",
...(opts.store ? { apiKeyStore: opts.store } : {}),
});
return { app, bus, lines };
}
/** 读取 JSON 响应体(测试里不做类型收窄) */
// biome-ignore lint/suspicious/noExplicitAny: 测试断言用
export const json = (res: Response): Promise<any> => res.json();
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { redact, redactString } from "../src/logger";
import { memoryLogger } from "./helpers";
describe("日志脱敏", () => {
it("URL query 中的 passkey/token 被替换", () => {
expect(redactString("https://pt.example/rss?passkey=abc123&x=1")).toBe(
"https://pt.example/rss?passkey=[REDACTED]&x=1",
);
expect(redactString("/dl?id=1&token=zzz")).toBe("/dl?id=1&token=[REDACTED]");
});
it("敏感字段整体替换,数值计数保留", () => {
expect(redact({ cookie: "a=b", nested: { Authorization: "Bearer x", password: "p" }, inputTokens: 12 })).toEqual({
cookie: "[REDACTED]",
nested: { Authorization: "[REDACTED]", password: "[REDACTED]" },
inputTokens: 12,
});
});
it("logger 输出不含明文", () => {
const { logger, lines } = memoryLogger();
logger.info({ url: "https://x/rss?passkey=SECRET1", token: "SECRET2" }, "fetch https://y/?token=SECRET3");
logger.error({ err: new Error("GET https://x/dl?passkey=SECRET4 failed") }, "boom");
const out = lines.join("");
for (const s of ["SECRET1", "SECRET2", "SECRET3", "SECRET4"]) expect(out).not.toContain(s);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { EventBus } from "../src/http/sse";
import { DEV_KEY, testApp } from "./helpers";
describe("EventBus", () => {
it("序号自增并按 after 补读,淘汰后标记 gap", () => {
const bus = new EventBus(3);
for (let i = 0; i < 5; i++) bus.publish("t", { i });
expect(bus.latestSeq).toBe(5);
expect(bus.since(3).events.map((e) => e.seq)).toEqual([4, 5]);
expect(bus.since(3).gap).toBe(false);
expect(bus.since(1).gap).toBe(true);
});
});
describe("GET /api/v1/events", () => {
it("需要鉴权", async () => {
const { app } = testApp();
expect((await app.request("/api/v1/events")).status).toBe(401);
});
it("补读 after 之后的事件", async () => {
const { app, bus } = testApp();
bus.publish("a", 1);
bus.publish("b", 2);
const ac = new AbortController();
const res = await app.request("/api/v1/events?after=1", {
headers: { Authorization: `Bearer ${DEV_KEY}` },
signal: ac.signal,
});
expect(res.headers.get("content-type")).toContain("text/event-stream");
const reader = res.body!.getReader();
const { value } = await reader.read();
const text = new TextDecoder().decode(value);
expect(text).toContain("id: 2");
expect(text).toContain("event: b");
expect(text).not.toContain("event: a");
ac.abort();
await reader.cancel();
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"]
}
+3
View File
@@ -0,0 +1,3 @@
# @umt/web
React Router 7 SPA 控制台(`ssr:false`),沿用 uhd_web 设计系统(token 见 `app/app.css`,规范见 `docs/design/`)。只通过 `/api/v1` 与 server 通信,请求/响应类型一律来自 `@umt/contracts`,不手写重复 DTO。开发端口 5180,`/api` 代理到 http://localhost:4380。
+13
View File
@@ -0,0 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import type { HealthStatus } from "@umt/contracts";
import { apiData, TTL_SECOND } from "~/lib/api";
/** 服务健康状态,15 秒轮询 */
export function useHealth() {
return useQuery({
queryKey: ["health"],
queryFn: ({ signal }) => apiData<HealthStatus>("/api/v1/health", { signal }),
refetchInterval: 15 * TTL_SECOND,
retry: false,
});
}
+1616
View File
File diff suppressed because it is too large. Load diff
@@ -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} 接入真实数据。`}
/>
</>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { cn } from "~/lib/utils";
/**
* 全站背景上左上角那束淡淡的棱镜漏光(样式见 app.css 的 .prism-leak)。
* 各页面外壳各放一个:用户中心、流媒体浏览页、公共页(落地页除外)、测速、404、退订。
* 放在背景层之后、内容之前;宿主是层叠上下文时用 -z-10 垫在内容下面。
*/
export default function PrismLeak({ className }: { className?: string }) {
return <div aria-hidden className={cn("prism-leak", className)} />;
}
+57
View File
@@ -0,0 +1,57 @@
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { flushSync } from "react-dom";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
export function ThemeToggle({ className }: { className?: string }) {
const [mounted, setMounted] = useState(false);
const { resolvedTheme, setTheme } = useTheme();
useEffect(() => {
setMounted(true);
}, []);
const isDark = mounted && resolvedTheme === "dark";
const nextTheme = isDark ? "light" : "dark";
// 渐进式主题过渡:整页新旧快照交叉淡化(View Transitions 默认行为,
// plus-lighter 混合,中途无灰闪)。.theme-vt 只在本次过渡期间挂在 html
// 上,给 app.css 一个作用域去放缓时长/缓动,不影响其它 view transition
// (如 play-card)。不支持或用户偏好减弱动效时,退回直接切换。
function handleToggle() {
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!document.startViewTransition || prefersReduced) {
setTheme(nextTheme);
return;
}
const root = document.documentElement;
root.classList.add("theme-vt");
// flushSync:startViewTransition 的回调返回后浏览器就拍新快照,
// 必须让 React 同步把主题类落到 DOM,不能等下一轮调度
const vt = document.startViewTransition(() => {
flushSync(() => setTheme(nextTheme));
});
void vt.finished.finally(() => root.classList.remove("theme-vt"));
}
return (
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label={isDark ? "切换到亮色主题" : "切换到暗色主题"}
title={isDark ? "切换到亮色主题" : "切换到暗色主题"}
onClick={handleToggle}
className={cn("relative", className)}
>
{/* 两个图标常驻,靠 dark: 变体做旋转缩放交接 —— 比条件渲染多一层
"转出去/转进来"的连贯感,也天然无水合闪烁 */}
<Sun className="h-4 w-4 rotate-0 scale-100 transition-transform duration-slow ease-out dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-transform duration-slow ease-out dark:rotate-0 dark:scale-100" />
</Button>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
}
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}
function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-scrim/45 backdrop-blur-[2px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm";
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-3xl pg-float p-6 duration-base data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className,
)}
{...props}
/>
);
}
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className,
)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className,
)}
{...props}
/>
);
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action data-slot="alert-dialog-action" className={cn(className)} {...props} />
</Button>
);
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" className={cn(className)} {...props} />
</Button>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { Avatar as AvatarPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "~/lib/utils";
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg";
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
className,
)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image data-slot="avatar-image" className={cn("aspect-square size-full", className)} {...props} />
);
}
function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full ring-2 select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className,
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
className,
)}
{...props}
/>
);
}
function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage };
+40
View File
@@ -0,0 +1,40 @@
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import type * as React from "react";
import { cn } from "~/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "pill-selected",
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/25 dark:focus-visible:ring-destructive/40",
outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp data-slot="badge" data-variant={variant} className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
+92
View File
@@ -0,0 +1,92 @@
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { Slot } from "radix-ui";
import type * as React from "react";
import { cn } from "~/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5", className)} {...props} />;
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "a";
return (
<Comp data-slot="breadcrumb-link" className={cn("hover:text-foreground transition-colors", className)} {...props} />
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
};
+68
View File
@@ -0,0 +1,68 @@
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import type * as React from "react";
import { cn } from "~/lib/utils";
const buttonVariants = cva(
// 全站只有这一套按钮(与动漫站同一思路):形状一律胶囊 / 正圆,颜色与材质只由 variant 决定,
// 调用处只给布局(宽度、高度、间距),不改圆角、底色、边框、投影和字色。
// 不是 <button> 的元素(Link、a)用 buttonVariants() 拿同一套类名
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-full text-sm font-medium whitespace-nowrap transition-[transform,background-color,border-color,color,box-shadow,opacity] duration-base ease-spring outline-none active:scale-[0.96] focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
// 主操作:半透明胶囊 + 沿边框流转的棱镜光谱。样式见 app.css 的 .pill-primary(--mat-pill-primary-*、--prism-*)
default: "pill-primary",
// 次操作:描边胶囊。outline 与 secondary 是同一个样子,保留两个名字只为兼容旧调用
outline: "pill-secondary",
secondary: "pill-secondary",
// 危险操作:同一胶囊,淡红底红字
destructive:
"border border-destructive/35 bg-destructive/10 text-destructive hover:bg-destructive/15 focus-visible:ring-destructive/25 dark:focus-visible:ring-destructive/40",
// 只有文字 / 图标,悬停出淡底
ghost: "text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
link: "text-primary underline-offset-4 hover:underline active:scale-100",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-11 px-6 has-[>svg]:px-5",
icon: "size-9",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+47
View File
@@ -0,0 +1,47 @@
import { zhCN } from "date-fns/locale";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker, type DayPickerProps } from "react-day-picker";
import { cn } from "~/lib/utils";
// 日历(react-day-picker v10)的玻璃族皮肤:全部走语义 token + 玻璃 hover
// 语言(中性灰纱、选中 primary 药丸 + 顶部内高光、今日描边)。键盘导航
// 与 aria 由 DayPicker 自带。默认中文 locale。
export function Calendar({ className, classNames, ...props }: DayPickerProps) {
return (
<DayPicker
locale={zhCN}
showOutsideDays
className={cn("p-3 select-none", className)}
classNames={{
months: "relative flex flex-col",
month: "space-y-2.5",
nav: "absolute inset-x-0 top-0 z-10 flex items-center justify-between",
button_previous:
"inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-foreground/[0.06] hover:text-foreground disabled:pointer-events-none disabled:opacity-30",
button_next:
"inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-foreground/[0.06] hover:text-foreground disabled:pointer-events-none disabled:opacity-30",
month_caption: "flex h-8 items-center justify-center",
caption_label: "text-sm font-semibold text-foreground",
month_grid: "w-full border-collapse",
weekdays: "flex",
weekday: "w-9 pb-1 text-center text-label font-medium text-muted-foreground/80",
week: "mt-1 flex",
day: "p-0",
day_button:
"inline-flex size-9 cursor-pointer items-center justify-center rounded-full text-sm font-medium tabular-nums text-foreground/85 transition-colors hover:bg-foreground/[0.06] hover:text-foreground",
selected: "[&>button]:pill-selected [&>button]:rounded-full",
today: "[&>button]:border [&>button]:border-primary/35",
outside: "[&>button]:text-muted-foreground/45",
disabled: "[&>button]:pointer-events-none [&>button]:opacity-30",
hidden: "invisible",
...classNames,
}}
components={{
Chevron: ({ orientation }) =>
orientation === "left" ? <ChevronLeft className="size-4" /> : <ChevronRight className="size-4" />,
}}
{...props}
/>
);
}
+56
View File
@@ -0,0 +1,56 @@
import type * as React from "react";
import { cn } from "~/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
);
}
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
+135
View File
@@ -0,0 +1,135 @@
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-scrim/45 backdrop-blur-[2px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-3xl pg-float p-6 duration-base outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 z-50 grid size-8 place-items-center rounded-full opacity-70 hover:bg-foreground/[0.06] ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+21
View File
@@ -0,0 +1,21 @@
import type * as React from "react";
import { cn } from "~/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { Label as LabelPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "~/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
+154
View File
@@ -0,0 +1,154 @@
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { cn } from "~/lib/utils";
const PAGE_SIZE_OPTIONS = [10, 15, 20, 50, 100];
type PaginationProps = {
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
onPageSizeChange?: (size: number) => void;
className?: string;
};
export function Pagination({ page, pageSize, total, onPageChange, onPageSizeChange, className }: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
const to = Math.min(page * pageSize, total);
const isFirst = page <= 1;
const isLast = page >= totalPages;
// 生成页码列表(最多显示 7 个按钮)
function buildPages(): (number | "...")[] {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
if (page <= 4) {
return [1, 2, 3, 4, 5, "...", totalPages];
}
if (page >= totalPages - 3) {
return [1, "...", totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
}
return [1, "...", page - 1, page, page + 1, "...", totalPages];
}
const pages = buildPages();
return (
<div className={cn("flex flex-wrap items-center justify-between gap-3", className)}>
{/* 左侧:总数 + 每页显示 */}
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground whitespace-nowrap">
共 <span className="font-medium text-foreground tabular-nums">{total}</span> 条 · 第{" "}
<span className="font-medium text-foreground tabular-nums">{from}</span>
{" 至 "}
<span className="font-medium text-foreground tabular-nums">{to}</span> 条
</span>
{/* 每页条数 */}
{onPageSizeChange && (
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground/60 whitespace-nowrap">每页</span>
<select
value={pageSize}
onChange={(e) => {
onPageSizeChange(Number(e.target.value));
onPageChange(1); // 切换页大小时回到第 1 页
}}
className="h-7 rounded-lg border border-foreground/10 bg-foreground/5 px-2 text-xs text-foreground outline-none transition-colors focus:border-primary/50 cursor-pointer appearance-none pr-6"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 6px center",
}}
>
{PAGE_SIZE_OPTIONS.map((s) => (
<option key={s} value={s} className="bg-card text-foreground">
{s} 条
</option>
))}
</select>
</div>
)}
</div>
{/* 右侧:页码导航 */}
<div className="flex items-center gap-0.5">
{/* 首页 */}
<NavBtn onClick={() => onPageChange(1)} disabled={isFirst} title="第一页">
<ChevronsLeft className="h-3.5 w-3.5" />
</NavBtn>
{/* 上一页 */}
<NavBtn onClick={() => onPageChange(page - 1)} disabled={isFirst} title="上一页">
<ChevronLeft className="h-3.5 w-3.5" />
</NavBtn>
{/* 页码按钮 */}
<div className="flex items-center gap-0.5 mx-1">
{pages.map((p, i) =>
p === "..." ? (
<span
key={`ellipsis-${i}`}
className="flex h-7 w-6 items-center justify-center text-xs text-muted-foreground/40 select-none"
>
···
</span>
) : (
<button
key={p}
type="button"
onClick={() => onPageChange(p as number)}
className={cn(
"flex h-7 min-w-[28px] px-1.5 items-center justify-center rounded-full text-xs font-medium transition-all",
p === page ? "pill-selected" : "text-muted-foreground hover:bg-foreground/8 hover:text-foreground",
)}
>
{p}
</button>
),
)}
</div>
{/* 下一页 */}
<NavBtn onClick={() => onPageChange(page + 1)} disabled={isLast} title="下一页">
<ChevronRight className="h-3.5 w-3.5" />
</NavBtn>
{/* 末页 */}
<NavBtn onClick={() => onPageChange(totalPages)} disabled={isLast} title="最后一页">
<ChevronsRight className="h-3.5 w-3.5" />
</NavBtn>
</div>
</div>
);
}
function NavBtn({
children,
onClick,
disabled,
title,
}: {
children: React.ReactNode;
onClick: () => void;
disabled: boolean;
title?: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground transition-all",
disabled ? "opacity-25 cursor-not-allowed" : "hover:bg-foreground/8 hover:text-foreground",
)}
>
{children}
</button>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { Popover as PopoverPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "~/lib/utils";
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-2xl pg-float p-4 text-popover-foreground outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="popover-header" className={cn("flex flex-col gap-1 text-sm", className)} {...props} />;
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return <div data-slot="popover-title" className={cn("font-medium", className)} {...props} />;
}
function PopoverDescription({ className, ...props }: React.ComponentProps<"p">) {
return <p data-slot="popover-description" className={cn("text-muted-foreground", className)} {...props} />;
}
export { Popover, PopoverAnchor, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger };
+160
View File
@@ -0,0 +1,160 @@
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "~/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-2xl pg-float text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-xl py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-foreground/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span data-slot="select-item-indicator" className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+26
View File
@@ -0,0 +1,26 @@
import { Separator as SeparatorPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "~/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };
+105
View File
@@ -0,0 +1,105 @@
import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "~/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-scrim/45 backdrop-blur-[2px]",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"pg-float data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-layout data-[state=open]:duration-slow",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 grid size-8 place-items-center rounded-full opacity-70 hover:bg-foreground/[0.06] transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5 p-4", className)} {...props} />;
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />;
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger };
+54
View File
@@ -0,0 +1,54 @@
import { Slider as SliderPrimitive } from "radix-ui";
import * as React from "react";
import { cn } from "~/lib/utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"relative grow overflow-hidden rounded-full bg-foreground/10 data-[orientation=horizontal]:h-2 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn("absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full")}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="block size-5 shrink-0 cursor-grab rounded-full border-2 border-primary bg-background shadow-sm shadow-primary/25 transition-all hover:scale-110 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-primary active:cursor-grabbing disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };
+36
View File
@@ -0,0 +1,36 @@
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
pointerEvents: "none",
} as React.CSSProperties
}
toastOptions={{
style: { pointerEvents: "auto" },
}}
{...props}
/>
);
};
export { Toaster };
+24
View File
@@ -0,0 +1,24 @@
import { Switch as SwitchPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "~/lib/utils";
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer inline-flex h-[1.15rem] w-8 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-input shadow-xs transition-colors outline-none data-[state=checked]:bg-primary focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:data-[state=unchecked]:bg-input/80",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block size-4 rounded-full bg-background shadow-sm transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground"
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+2
View File
@@ -0,0 +1,2 @@
/** 构建时由 vite.config.ts 的 appVersion 插件注入 */
declare const __APP_VERSION__: string;
+107
View File
@@ -0,0 +1,107 @@
import type { ApiError, ApiResponse } from "@umt/contracts";
export type { ApiResponse };
// ─── API Key 存储 ───────────────────────────────────────────────
const TOKEN_KEY = "umt-api-key";
export function getToken(): string {
if (typeof localStorage === "undefined") return "";
return localStorage.getItem(TOKEN_KEY) || "";
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearAuth() {
localStorage.removeItem(TOKEN_KEY);
}
// ─── 请求参数 ────────────────────────────────────────────────────
export type FetcherArg<B = unknown> = {
/** 路径变量,如 { id: "..." } 替换 /api/v1/media/:id */
pathParams?: Record<string, string | number>;
params?: Record<string, string | number | boolean | undefined | null>;
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
body?: B;
headers?: Record<string, string>;
signal?: AbortSignal;
};
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
const fail = (code: ApiError["code"], message: string): ApiResponse<never> => ({
ok: false,
error: { code, message },
});
/**
* 核心 fetcher:始终返回 ApiResponse 信封,不抛出网络/解析错误。
* queryFn: () => apiFetch<HealthStatus>("/api/v1/health")
* mutationFn: (body) => apiFetch("/api/v1/xxx", { method: "POST", body })
*/
export async function apiFetch<T = unknown>(path: string, arg: FetcherArg = {}): Promise<ApiResponse<T>> {
const { method = "GET", headers = {}, body, params, pathParams, signal } = arg;
let url = `${API_BASE.replace(/\/+$/, "")}${path}`;
if (pathParams) {
for (const [key, value] of Object.entries(pathParams)) {
url = url.replace(`:${key}`, encodeURIComponent(String(value)));
}
}
if (params) {
const qs = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) qs.append(key, String(value));
}
const s = qs.toString();
if (s) url += (url.includes("?") ? "&" : "?") + s;
}
const reqHeaders: Record<string, string> = { ...headers };
const token = getToken();
if (token) reqHeaders.Authorization = `Bearer ${token}`;
let reqBody: BodyInit | undefined;
if (body instanceof FormData) {
reqBody = body;
} else if (body !== undefined && body !== null) {
reqHeaders["Content-Type"] ??= "application/json";
reqBody = JSON.stringify(body);
}
let res: Response;
try {
res = await fetch(url, { method, headers: reqHeaders, body: reqBody, signal });
} catch (err) {
if ((err as Error).name === "AbortError") throw err;
return fail("upstream_unavailable", "无法连接服务");
}
if (res.status === 401 && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("unauthorized"));
}
try {
return (await res.json()) as ApiResponse<T>;
} catch {
return fail("internal", `响应不是 JSON(HTTP ${res.status})`);
}
}
/** 取出 data,失败时抛出(给 react-query 的 queryFn 用,错误进入 error 状态) */
export async function apiData<T>(path: string, arg?: FetcherArg): Promise<T> {
const r = await apiFetch<T>(path, arg);
if (!r.ok) throw Object.assign(new Error(r.error.message), { code: r.error.code });
return r.data;
}
/** TanStack Query mutationFn 包装 */
export function mutateFetcher<B = unknown, T = unknown>(path: string, base?: Omit<FetcherArg<B>, "body">) {
return (body: B) => apiFetch<T>(path, { method: "POST", ...base, body });
}
export const TTL_SECOND = 1_000;
export const TTL_MINUTE = TTL_SECOND * 60;
export const TTL_HOUR = TTL_MINUTE * 60;
@@ -0,0 +1,68 @@
// 由 scripts/gen-design-tokens.mjs 从 app/app.css 生成,不要手改;改了 app.css 跑 pnpm gen:tokens
export const DESIGN_TOKENS = {
"fontSizes": [
"micro",
"label",
"meta",
"caption",
"body",
"control",
"lead",
"lead-lg",
"title-xs",
"title-sm",
"title",
"title-lg",
"headline-xs",
"headline-sm",
"headline",
"headline-lg",
"display-xs",
"display-sm",
"display",
"display-lg",
"display-xl",
"hero-stream",
"hero-stream-sm",
"hero-stream-md",
"detail-title",
"notfound-code",
"pricing-hero",
"pricing-title",
"auth-title",
"landing-hero",
"landing-platforms-title",
"landing-immersive-title",
"landing-service-hero",
"landing-service-title",
"landing-service-title-lg",
"landing-service-sub"
],
"eases": [
"standard",
"spring",
"spring-soft",
"sheet",
"pop"
],
"radii": [
"sm",
"md",
"lg",
"xl",
"2xl",
"3xl",
"4xl",
"tight",
"drawer",
"slab"
],
"durations": [
"micro",
"base",
"layout",
"slow",
"hero",
"cinematic"
]
} as const;
+57
View File
@@ -0,0 +1,57 @@
import { type ClassValue, clsx } from "clsx";
import { extendTailwindMerge } from "tailwind-merge";
import { DESIGN_TOKENS } from "./design-tokens.generated";
/**
* 自定义工具类的 token(字号 / 圆角 / 缓动 / 时长)要告诉 tailwind-merge 它们属于哪一组:
* 不登记的话 `text-control text-muted-foreground` 会被当成两个颜色类冲突,字号那个被丢掉。
* 登记表由 scripts/gen-design-tokens.mjs 从 app.css 生成,CI 校验它是最新的。
*/
const twMerge = extendTailwindMerge({
extend: {
theme: {
radius: [...DESIGN_TOKENS.radii],
ease: [...DESIGN_TOKENS.eases],
},
classGroups: {
"font-size": [{ text: [...DESIGN_TOKENS.fontSizes] }],
duration: [{ duration: [...DESIGN_TOKENS.durations] }],
},
},
});
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes: number, decimals = 2) {
if (!+bytes) return "0 B";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
}
export function formatDateTime(value: string | null | undefined) {
if (!value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
}
export function formatDate(value: string | null | undefined) {
if (!value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" });
}
+85
View File
@@ -0,0 +1,85 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "next-themes";
import { useState } from "react";
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import { Toaster } from "~/components/ui/sonner";
import type { Route } from "./+types/root";
import "@fontsource-variable/inter-tight";
import "@fontsource-variable/space-grotesk";
import "./app.css";
export const meta: Route.MetaFunction = () => [{ title: "UHD 媒体自动化控制台" }];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<Meta />
<Links />
</head>
<body suppressHydrationWarning>
<ThemeProvider
attribute="class"
defaultTheme="dark"
themes={["light", "dark"]}
enableSystem={false}
storageKey="umt-theme"
>
{children}
<ScrollRestoration />
<Scripts />
<Toaster position="top-center" duration={1500} richColors />
</ThemeProvider>
</body>
</html>
);
}
export default function App() {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, refetchOnWindowFocus: false },
},
}),
);
return (
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
);
}
export function HydrateFallback() {
return null;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "出错了";
let details = "发生了未预期的错误。";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "错误";
details = error.status === 404 ? "页面不存在。" : error.statusText || details;
} else if (import.meta.env.DEV && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="container mx-auto p-4 pt-16">
<h1 className="text-title font-semibold">{message}</h1>
<p className="mt-2 text-muted-foreground">{details}</p>
{stack && (
<pre className="mt-4 w-full overflow-x-auto p-4 text-label">
<code>{stack}</code>
</pre>
)}
</main>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { index, layout, type RouteConfig, route } from "@react-router/dev/routes";
// 显式路由表。新增工作区:在这里加一行 + components/console/nav.ts 加导航项
export default [
layout("routes/console/layout.tsx", [
index("routes/console/home.tsx"),
route("inbox", "routes/console/inbox.tsx"),
route("library", "routes/console/library.tsx"),
route("identity", "routes/console/identity.tsx"),
route("organize", "routes/console/organize.tsx"),
route("subscriptions", "routes/console/subscriptions.tsx"),
route("preferences", "routes/console/preferences.tsx"),
route("sites", "routes/console/sites.tsx"),
route("sources", "routes/console/sources.tsx"),
route("tasks", "routes/console/tasks.tsx"),
route("agent", "routes/console/agent.tsx"),
route("settings", "routes/console/settings.tsx"),
]),
route("*", "routes/not-found.tsx"),
] satisfies RouteConfig;
+31
View File
@@ -0,0 +1,31 @@
import { Bot, Send } from "lucide-react";
import { EmptyState } from "~/components/console/empty-state";
import { PageHeader } from "~/components/console/page-header";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
/** Agent 会话页占位:消息流 + 输入框骨架,W2/W3 接入 agent_run 与 SSE 增量 */
export default function AgentSession() {
return (
<>
<PageHeader
title="Agent 会话"
description="交代目标,Agent 在授权范围内边执行边汇报;只要方案时,它只调查不执行。"
/>
<div className="flex min-h-[60vh] flex-col gap-4">
<EmptyState
icon={Bot}
title="还没有会话"
description="例如:“把某部电影的 1080p 收进来”。会话能力计划在 W2 接入。"
className="flex-1"
/>
<form className="flex items-center gap-2" onSubmit={(e) => e.preventDefault()}>
<Input disabled placeholder="Agent 尚未接入" className="flex-1" />
<Button type="submit" disabled size="icon" aria-label="发送">
<Send className="h-4 w-4" />
</Button>
</form>
</div>
</>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { Activity, AlertTriangle, CircleCheck, HardDrive, ListTodo, Radio, Sparkles } from "lucide-react";
import { useHealth } from "~/api/health";
import { EmptyState } from "~/components/console/empty-state";
import { PageHeader } from "~/components/console/page-header";
import { StatusBadge } from "~/components/console/status-badge";
import { formatDateTime } from "~/lib/utils";
// 首页六区块(REQ §13.4.3),W3 接入正式状态投影
const SECTIONS = [
{ title: "自动化概览", icon: Sparkles, hint: "今日新增、升级与已核验入库" },
{ title: "当前工作", icon: Activity, hint: "正在搜索/下载/整理/核验的目标" },
{ title: "需要我处理", icon: AlertTriangle, hint: "根因级事项与剩余决策" },
{ title: "缓存与保留", icon: HardDrive, hint: "使用率、在途预留与可清理量" },
{ title: "来源健康", icon: Radio, hint: "站点、qB、Emby、模型上游" },
{ title: "最近产出", icon: ListTodo, hint: "新增的集与版本、整理前后路径" },
];
function ServiceStatus() {
const { data, isPending, isError, error } = useHealth();
if (isPending) return <StatusBadge>检查中</StatusBadge>;
if (isError) return <StatusBadge tone="destructive">服务不可达:{error.message}</StatusBadge>;
return (
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={data.status === "ok" ? "success" : "warning"}>
服务{data.status === "ok" ? "正常" : "降级"}
</StatusBadge>
<StatusBadge tone={data.db === "ok" ? "success" : data.db === "down" ? "destructive" : "neutral"}>
数据库 {data.db === "ok" ? "正常" : data.db === "down" ? "不可用" : "未知"}
</StatusBadge>
<span className="text-meta text-muted-foreground">
v{data.version} · {data.role} · {formatDateTime(data.time)}
</span>
</div>
);
}
export default function Home() {
return (
<>
<PageHeader
title="首页"
description="系统是否在推进目标、完成了什么、还有什么缺口、是否需要你。"
actions={<ServiceStatus />}
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{SECTIONS.map((s) => (
<section key={s.title} className="pg-slab rounded-xl p-5">
<div className="mb-3 flex items-center gap-2">
<s.icon className="h-4 w-4 text-muted-foreground" />
<h2 className="text-body font-semibold text-foreground">{s.title}</h2>
</div>
<EmptyState icon={CircleCheck} title="暂无数据" description={s.hint} plain className="py-8" />
</section>
))}
</div>
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
// 占位:标题与说明取自 components/console/nav.ts
export default function IdentityPage() {
return <WorkspacePlaceholder />;
}
+6
View File
@@ -0,0 +1,6 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
// 占位:标题与说明取自 components/console/nav.ts
export default function InboxPage() {
return <WorkspacePlaceholder />;
}
+208
View File
@@ -0,0 +1,208 @@
import { ChevronLeft, ChevronRight, Clapperboard, Menu } from "lucide-react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link, NavLink, Outlet, useLocation } from "react-router";
import { ApiKeyDialog } from "~/components/console/api-key-dialog";
import { findNavItem, NAV_GROUPS, NAV_ITEMS, type NavGroup } from "~/components/console/nav";
import PrismLeak from "~/components/prism-leak";
import { ThemeToggle } from "~/components/theme-toggle";
import { Button } from "~/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "~/components/ui/sheet";
import { cn } from "~/lib/utils";
const PORTAL_NAME = "媒体自动化";
// ─── 展开态导航:选中项由一枚玻璃滑块(seg-thumb)滑到激活链接背后 ───
function ExpandedNav({ groups, onNavigate }: { groups: NavGroup[]; onNavigate?: () => void }) {
const location = useLocation();
const navRef = useRef<HTMLElement>(null);
const [pill, setPill] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const measure = useCallback(() => {
const nav = navRef.current;
if (!nav) return;
const active = nav.querySelector<HTMLElement>('[aria-current="page"]');
if (!active) {
setPill(null);
return;
}
const navRect = nav.getBoundingClientRect();
const rect = active.getBoundingClientRect();
setPill({ x: rect.left - navRect.left, y: rect.top - navRect.top + nav.scrollTop, w: rect.width, h: rect.height });
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: 路由变化时重新量测
useLayoutEffect(() => {
measure();
}, [measure, location.pathname]);
useEffect(() => {
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [measure]);
return (
<nav ref={navRef} className="relative flex-1 space-y-4 overflow-y-auto px-2.5 py-4">
{pill ? (
<span
aria-hidden
className="tab-slider seg-thumb pointer-events-none absolute top-0 left-0 -z-[1] rounded-lg"
style={{ transform: `translate(${pill.x}px, ${pill.y}px)`, width: pill.w, height: pill.h }}
/>
) : null}
{groups.map((group) => (
<div key={group.title}>
<p className="mb-1 px-2 text-micro font-semibold tracking-[0.15em] text-muted-foreground/75 uppercase">
{group.title}
</p>
<div className="space-y-0.5">
{group.items.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === "/"}
onClick={onNavigate}
className={({ isActive }) =>
cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-base",
isActive
? "text-foreground"
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
)
}
>
{({ isActive }) => (
<>
<item.icon className={cn("h-4 w-4 shrink-0", isActive && "text-primary")} />
<span className="flex-1 truncate">{item.label}</span>
</>
)}
</NavLink>
))}
</div>
</div>
))}
</nav>
);
}
// ─── 折叠态:只显示图标 ───
function CollapsedNav() {
return (
<nav className="flex flex-1 flex-col items-center gap-1 overflow-y-auto py-4">
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === "/"}
title={item.label}
className={({ isActive }) =>
cn(
"flex h-10 w-10 items-center justify-center rounded-xl transition-colors duration-micro",
isActive
? "seg-thumb text-primary"
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
)
}
>
<item.icon className="h-[18px] w-[18px]" />
</NavLink>
))}
</nav>
);
}
function Brand({ collapsed = false }: { collapsed?: boolean }) {
return (
<Link
to="/"
className={cn(
"flex items-center transition-opacity hover:opacity-80",
collapsed ? "w-full justify-center" : "gap-2",
)}
>
<span className="grid h-7 w-7 place-items-center rounded-lg pill-selected">
<Clapperboard className="h-4 w-4" />
</span>
{!collapsed && <p className="text-xs font-bold tracking-wide text-foreground">UHD Media Tool</p>}
</Link>
);
}
export default function ConsoleLayout() {
const location = useLocation();
const [collapsed, setCollapsed] = useState(false);
const [sheetOpen, setSheetOpen] = useState(false);
const activeLabel = findNavItem(location.pathname)?.label ?? PORTAL_NAME;
return (
<div className="app-scrollbars relative isolate flex min-h-screen overflow-x-hidden bg-background">
<div className="pointer-events-none fixed inset-0 -z-10 bg-page-glow" />
<PrismLeak className="-z-10" />
{/* 桌面端侧栏 */}
<aside
className={cn(
"pg-chrome pg-chrome-side fixed inset-y-0 left-0 z-30 hidden flex-col border-r transition-all duration-layout select-none xl:flex",
collapsed ? "w-[60px]" : "w-60",
)}
>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="absolute top-3 -right-4 z-40 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border border-[var(--shell-border)] bg-background text-muted-foreground shadow-md transition-all duration-base hover:scale-110 hover:bg-foreground/[0.04] hover:text-foreground"
title={collapsed ? "展开侧边栏" : "收起侧边栏"}
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
<div
className={cn(
"flex h-14 shrink-0 items-center border-b border-[var(--shell-border)]",
collapsed ? "justify-center px-0" : "px-4",
)}
>
<Brand collapsed={collapsed} />
</div>
{collapsed ? <CollapsedNav /> : <ExpandedNav groups={NAV_GROUPS} />}
</aside>
{/* 主内容区 */}
<div
className={cn(
"relative flex min-h-screen min-w-0 flex-1 flex-col transition-all duration-layout",
collapsed ? "xl:ml-[60px]" : "xl:ml-60",
)}
>
<header className="pg-chrome sticky top-0 z-20 flex h-14 items-center border-b px-4 md:px-6">
<div className="flex min-w-0 items-center gap-3">
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 xl:hidden" aria-label="打开导航">
<Menu className="h-4 w-4" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64 p-0 select-none">
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4">
<SheetTitle className="text-sm">
<Brand />
</SheetTitle>
</SheetHeader>
<ExpandedNav groups={NAV_GROUPS} onNavigate={() => setSheetOpen(false)} />
</SheetContent>
</Sheet>
<span className="truncate text-sm font-semibold text-foreground">{activeLabel}</span>
</div>
<div className="ml-auto flex items-center gap-2">
<ApiKeyDialog />
<ThemeToggle />
</div>
</header>
<main className="relative flex-1 overflow-x-hidden px-4 py-6 md:px-6 md:py-8">
<div key={location.pathname} className="mx-auto max-w-6xl motion-safe:animate-blur-in">
<Outlet />
</div>
</main>
</div>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
// 占位:标题与说明取自 components/console/nav.ts
export default function LibraryPage() {
return <WorkspacePlaceholder />;
}
+6
View File
@@ -0,0 +1,6 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
// 占位:标题与说明取自 components/console/nav.ts
export default function OrganizePage() {
return <WorkspacePlaceholder />;
}
@@ -0,0 +1,6 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
// 占位:标题与说明取自 components/console/nav.ts
export default function PreferencesPage() {
return <WorkspacePlaceholder />;
}
+6
View File
@@ -0,0 +1,6 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
// 占位:标题与说明取自 components/console/nav.ts
export default function SettingsPage() {
return <WorkspacePlaceholder />;
}
+6
View File
@@ -0,0 +1,6 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
// 占位:标题与说明取自 components/console/nav.ts
export default function SitesPage() {
return <WorkspacePlaceholder />;
}
+6
View File
@@ -0,0 +1,6 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
// 占位:标题与说明取自 components/console/nav.ts
export default function SourcesPage() {
return <WorkspacePlaceholder />;
}
@@ -0,0 +1,6 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
// 占位:标题与说明取自 components/console/nav.ts
export default function SubscriptionsPage() {
return <WorkspacePlaceholder />;
}
+6
View File
@@ -0,0 +1,6 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
// 占位:标题与说明取自 components/console/nav.ts
export default function TasksPage() {
return <WorkspacePlaceholder />;
}
+19
View File
@@ -0,0 +1,19 @@
import { Link } from "react-router";
import { EmptyState } from "~/components/console/empty-state";
import { buttonVariants } from "~/components/ui/button";
export default function NotFound() {
return (
<div className="grid min-h-screen place-items-center bg-background px-4">
<EmptyState
title="页面不存在"
description="检查地址是否正确,或回到首页。"
action={
<Link to="/" className={buttonVariants({ variant: "outline" })}>
回到首页
</Link>
}
/>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/app.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "~/components",
"utils": "~/lib/utils",
"ui": "~/components/ui",
"lib": "~/lib",
"hooks": "~/hooks"
},
"registries": {}
}
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@umt/web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "react-router dev --host 0.0.0.0 --port 5180",
"build": "react-router build",
"preview": "vite preview --host 0.0.0.0 --port 5180",
"typecheck": "react-router typegen && tsc",
"test": "vitest run",
"gen:tokens": "node scripts/gen-design-tokens.mjs",
"check:tokens": "node scripts/gen-design-tokens.mjs --check && sh scripts/check-design-tokens.sh"
},
"dependencies": {
"@fontsource-variable/inter-tight": "^5.2.7",
"@fontsource-variable/space-grotesk": "^5.2.10",
"@react-router/node": "7.12.0",
"@tanstack/react-query": "^5.90.21",
"@umt/contracts": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"isbot": "^5.1.31",
"lucide-react": "^0.576.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.4",
"react-router": "7.12.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@react-router/dev": "7.12.0",
"@tailwindcss/vite": "^4.1.13",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"shadcn": "^3.8.5",
"tailwindcss": "^4.1.13",
"tw-animate-css": "^1.4.0",
"vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
import type { Config } from "@react-router/dev/config";
export default {
// SPA 模式,静态部署
ssr: false,
} satisfies Config;
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
# 设计 token 守卫:组件里不许直接写原色、白黑透明度、十六进制或 Tailwind 调色板颜色,一律走语义 token。
# 规范见仓库根 docs/design/(design-tokens.md、interface-materials.md、ui-controls.md)。
# 在 apps/web 目录下运行;/dev/* 陈列页不在范围内。
# 确实需要例外(第三方库的颜色参数等)时,在该行或上一行写 `token-allow: 原因`。
set -eu
fail=0
EXCLUDE='app/routes/dev/'
report() {
# $1 范围,$2 说明,$3 正则(PCRE);跳过注释行、token-allow 标记的行和它的下一行
hits=$(grep -rnP "$3" $1 --include='*.tsx' --include='*.ts' | grep -v "$EXCLUDE" | grep -vP ':\d+:\s*(\*|//|/\*|\{/\*)' || true)
hits=$(printf '%s\n' "$hits" | while IFS= read -r hit; do
[ -z "$hit" ] && continue
file=${hit%%:*}; rest=${hit#*:}; line=${rest%%:*}
prev=$((line > 1 ? line - 1 : 1))
sed -n "${prev},${line}p" "$file" | grep -q 'token-allow' || printf '%s\n' "$hit"
done)
if [ -n "$hits" ]; then
echo "✗ $2"; echo "$hits" | head -20; fail=1
fi
}
# 颜色:全站
report app "原色类(bone / ink / gold)只能在 app.css 的 token 定义里出现" '(?<=-)(bone|ink|gold)-\d+\b'
report app "white / black 透明度请改写成 wash / on-media-wash / scrim / canvas(透明度留在调用处)" '(?<=-)(?<!font-)(white|black)(/[0-9.]+|/\[[0-9.]+\])?(?![-\w])'
report app "Tailwind 调色板颜色请改用状态 token(success / warning / destructive)或无彩 token" '(?<=-)(red|emerald|amber|sky|blue|slate|zinc|gray|green|yellow|orange|rose|violet|indigo|purple|teal|cyan|pink|neutral|stone|lime|fuchsia)-\d{2,3}\b'
report app "类名里的十六进制颜色请改用语义 token" '\[#[0-9a-fA-F]{3,8}\]'
# 控制台页面连 JS 里的十六进制也不允许
report app/routes/console "十六进制颜色请改用语义 token" '(?<![\w&])#[0-9a-fA-F]{6}\b(?![\w-])'
# 材质:按钮 / 选中态 / 浮层各只有一套,见 docs/design/ui-controls.md
report app "按钮不做实心:hover 不填实心 primary / foreground,主操作用 <Button> 默认样式(pill-primary)" 'hover:bg-(primary|foreground|brand-strong)(?![\w/-])'
report app "浮层(下拉、菜单、弹窗面板)请用 pg-float,不要手写 shadow-2xl 之类的投影" '(?<![\w-])shadow-2xl(?![\w-])'
# brand-soft 在两种主题里都是接近底色的淡色,只能当底,不能当字色
report app "brand-soft 是底色,不能当文字色;文字用 content-* 或 brand-light" '(?<![\w-])text-brand-soft(?![\w-])'
# 字号 / 动效 / 圆角
report app "字号请用阶梯 token(text-micro … text-display-xl,流式标题按用途命名),不写 text-[Npx]" 'text-\[(clamp\(|[0-9.]+(px|rem)\])'
report app "过渡时长请用 duration-micro / base / layout / slow / hero / cinematic" '(?<![\w-])duration-\d+(?![\w-])'
report app "缓动请用 ease-standard / spring / spring-soft / sheet / pop" 'ease-\[cubic'
report app "圆角:小件用 rounded-tight,不写裸 rounded 或 rounded-[..]" '(?<![\w-])rounded(?![\w-])|rounded-(t-)?\['
if [ "$fail" -ne 0 ]; then
echo "设计 token 检查未通过,见 docs/design/design-tokens.md"; exit 1
fi
echo "设计 token 检查通过"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env node
// 从 apps/web/app/app.css 抽出自定义工具类的 token 名,生成 app/lib/design-tokens.generated.ts,
// 供 cn() 的 tailwind-merge 登记。不登记的话合并器会把 `text-control` 当成文字颜色、
// 和 `text-muted-foreground` 冲突后丢掉字号(2026-09-12 页面级复验抓到的)。
// 手动维护登记表会漂移,所以改成生成:改了 app.css 跑 `pnpm gen:tokens`,CI 校验生成物是最新的。
import { readFileSync, writeFileSync } from "node:fs";
const css = readFileSync(new URL("../app/app.css", import.meta.url), "utf8");
const pick = (ns) => [
...new Set([...css.matchAll(new RegExp(`^\\s*--${ns}-([a-z0-9-]+)\\s*:`, "gm"))].map((m) => m[1])),
];
const tokens = {
fontSizes: pick("text").filter((n) => !/^(xs|sm|base|lg|xl|\dxl)$/.test(n)),
eases: pick("ease"),
radii: pick("radius"),
durations: [...new Set([...css.matchAll(/^@utility duration-([a-z0-9-]+)/gm)].map((m) => m[1]))],
};
const out = `// 由 scripts/gen-design-tokens.mjs 从 app/app.css 生成,不要手改;改了 app.css 跑 pnpm gen:tokens
export const DESIGN_TOKENS = ${JSON.stringify(tokens, null, 2)} as const;
`;
const target = new URL("../app/lib/design-tokens.generated.ts", import.meta.url);
if (process.argv.includes("--check")) {
let current = "";
try {
current = readFileSync(target, "utf8");
} catch {}
if (current !== out) {
console.error("app/lib/design-tokens.generated.ts 已过期,运行 pnpm gen:tokens");
process.exit(1);
}
console.log(
`design token 登记表是最新的(字号 ${tokens.fontSizes.length} / 缓动 ${tokens.eases.length} / 圆角 ${tokens.radii.length} / 时长 ${tokens.durations.length})`,
);
} else {
writeFileSync(target, out);
console.log(
`已生成 app/lib/design-tokens.generated.ts(字号 ${tokens.fontSizes.length} / 缓动 ${tokens.eases.length} / 圆角 ${tokens.radii.length} / 时长 ${tokens.durations.length})`,
);
}
+24
View File
@@ -0,0 +1,24 @@
{
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
"exclude": ["node_modules", "build"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"types": ["node", "vite/client"],
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true
}
}
+65
View File
@@ -0,0 +1,65 @@
import fs from "node:fs";
import path from "node:path";
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig, loadEnv, type Plugin } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
/** 读取 git 短哈希(不依赖 git 命令) */
function readGitShortSha(cwd: string): string | null {
try {
const gitDir = path.join(cwd, ".git");
const head = fs.readFileSync(path.join(gitDir, "HEAD"), "utf8").trim();
let sha = head;
if (head.startsWith("ref: ")) {
const ref = head.slice(5);
const refFile = path.join(gitDir, ref);
if (fs.existsSync(refFile)) {
sha = fs.readFileSync(refFile, "utf8").trim();
} else {
const packed = fs.readFileSync(path.join(gitDir, "packed-refs"), "utf8");
const line = packed.split("\n").find((l) => l.endsWith(` ${ref}`));
if (!line) return null;
sha = line.split(" ")[0] ?? "";
}
}
return /^[0-9a-f]{40}$/.test(sha) ? sha.slice(0, 7) : null;
} catch {
return null;
}
}
/** 构建时注入 __APP_VERSION__ 并输出 version.json;优先 APP_VERSION,其次 git 短哈希,最后时间戳 */
function appVersion(): Plugin {
const repoRoot = path.resolve(process.cwd(), "../..");
const version = process.env.APP_VERSION?.trim() || readGitShortSha(repoRoot) || `build-${Date.now().toString(36)}`;
return {
name: "app-version",
config() {
return { define: { __APP_VERSION__: JSON.stringify(version) } };
},
generateBundle() {
this.emitFile({
type: "asset",
fileName: "version.json",
source: JSON.stringify({ version, builtAt: new Date().toISOString() }),
});
},
};
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const apiTarget = env.VITE_API_TARGET || "http://localhost:4380";
return {
plugins: [tailwindcss(), reactRouter(), tsconfigPaths(), appVersion()],
server: {
port: 5180,
strictPort: true,
proxy: {
"/api": { target: apiTarget, changeOrigin: true },
},
},
};
});
+8
View File
@@ -0,0 +1,8 @@
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
// 单测不加载 react-router / tailwind 插件
export default defineConfig({
plugins: [tsconfigPaths()],
test: { include: ["app/**/*.test.ts"], environment: "node" },
});