chore: scaffold monorepo and migrate uhd_web design system (W0-A)
- pnpm workspace: apps/{web,server}, packages/{contracts,db,domain,adapters,tasks,agent,testkit}
- server: Hono + zod-openapi, API Key auth with scopes, redacting pino logger, SSE event bus with replay, worker heartbeat
- db: Kysely + pg, file migrator, 0001_init (settings/api_key/audit_event/outbox_event)
- web: React Router 7 SPA console shell with 12 workspace placeholders, uhd_web tokens/ui/guard scripts
- docs: ADR-001 (Node over Deno), dev runbook, design system rules
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
commit
b22c440139
138 files changed
+14455
No files matched your search
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,9 @@
|
||||
# 复制为 .env 后按需修改(apps/server 与 packages/db 都会读取仓库根的 .env)
|
||||
PORT=4380
|
||||
DATABASE_URL=postgres://umt:umt@localhost:55433/umt
|
||||
# 凭证加密主密钥(32 字节以上随机串);与数据库备份分开保管
|
||||
SECRET_MASTER_KEY=dev-only-change-me-dev-only-change-me
|
||||
LOG_LEVEL=info
|
||||
NODE_ENV=development
|
||||
# 开发模式下可直接用此 Key 以 admin scope 访问 API;生产环境忽略
|
||||
DEV_ADMIN_KEY=umt-dev-admin-key
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.react-router/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.log
|
||||
.DS_Store
|
||||
.vite/
|
||||
@@ -0,0 +1,37 @@
|
||||
# UHD Media Tool
|
||||
|
||||
内部使用、Agent 自动化优先的媒体整理入库平台。需求基线见 [media_automation_requirements_architecture_v0.7.md](media_automation_requirements_architecture_v0.7.md),v0.1 工程任务书见 [docs/plan/00-first-version-plan.md](docs/plan/00-first-version-plan.md)。
|
||||
|
||||
## 快速启动
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
pnpm db:up # postgres:16-alpine,端口 55433
|
||||
pnpm install
|
||||
pnpm db:migrate
|
||||
pnpm dev # API http://localhost:4380 控制台 http://localhost:5180
|
||||
```
|
||||
|
||||
提交前:`pnpm run ci`(typecheck + lint + test + check:tokens + build)。注意不要用 `pnpm ci`,它在 pnpm 11 是内置的 clean-install。
|
||||
|
||||
更多见 [docs/runbook/dev.md](docs/runbook/dev.md)。
|
||||
|
||||
## 仓库结构
|
||||
|
||||
| 路径 | 职责 |
|
||||
|---|---|
|
||||
| `apps/web` | React Router 7 SPA 控制台,沿用 uhd_web 设计系统 |
|
||||
| `apps/server` | Hono API(`/api/v1`)+ SSE + worker 入口 |
|
||||
| `packages/contracts` | zod schema:API/事件/能力的唯一定义 |
|
||||
| `packages/db` | Kysely 客户端、表类型与迁移 |
|
||||
| `packages/domain` | 纯业务逻辑,无 IO |
|
||||
| `packages/adapters` | qB / RSS / 站点 / TMDB / Emby / 文件系统适配 |
|
||||
| `packages/tasks` | 持久任务与 outbox |
|
||||
| `packages/agent` | Pi 接入与能力注册桥接 |
|
||||
| `packages/testkit` | mock 服务与测试夹具 |
|
||||
| `docs/adr` | 架构决策记录 |
|
||||
| `docs/design` | 设计系统规范 |
|
||||
| `docs/runbook` | 运行手册 |
|
||||
| `spikes/` | 独立兼容性验证(不属于 workspace) |
|
||||
|
||||
技术选型:Node.js 24 + pnpm workspace + TypeScript 5.9 strict(偏离 REQ 的 Deno 方向,原因见 [ADR-001](docs/adr/ADR-001-runtime-node-not-deno.md))。
|
||||
@@ -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` 单进程运行。业务规则不写在这里,只做编排、鉴权、审计与协议适配。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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>;
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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 } });
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" }));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"));
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -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。
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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} 接入真实数据。`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/** 构建时由 vite.config.ts 的 appVersion 插件注入 */
|
||||
declare const __APP_VERSION__: string;
|
||||
@@ -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;
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function IdentityPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function InboxPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function LibraryPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function SettingsPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function SitesPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function TasksPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Config } from "@react-router/dev/config";
|
||||
|
||||
export default {
|
||||
// SPA 模式,静态部署
|
||||
ssr: false,
|
||||
} satisfies Config;
|
||||
Executable
+47
@@ -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 检查通过"
|
||||
@@ -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})`,
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
||||
"files": {
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/node_modules",
|
||||
"!**/dist",
|
||||
"!**/build",
|
||||
"!**/.react-router",
|
||||
"!spikes",
|
||||
"!docs",
|
||||
"!apps/web/app/app.css",
|
||||
"!apps/web/app/lib/design-tokens.generated.ts"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 120
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": { "quoteStyle": "double", "semicolons": "always", "trailingCommas": "all" }
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"preset": "recommended",
|
||||
"style": { "noNonNullAssertion": "off" },
|
||||
"suspicious": { "noArrayIndexKey": "off" }
|
||||
}
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["apps/web/app/components/ui/**"],
|
||||
"linter": { "rules": { "a11y": "off", "security": { "noDangerouslySetInnerHtml": "off" } } }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# 开发数据库:pnpm db:up / docker compose -f docker-compose.dev.yml up -d
|
||||
name: umt-dev
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: umt
|
||||
POSTGRES_PASSWORD: umt
|
||||
POSTGRES_DB: umt
|
||||
ports:
|
||||
- "127.0.0.1:55433:5432"
|
||||
volumes:
|
||||
- umt-pg-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U umt -d umt"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
volumes:
|
||||
umt-pg-data:
|
||||
@@ -0,0 +1,29 @@
|
||||
# ADR-001:运行时采用 Node.js 24 LTS + pnpm workspace,而非 Deno
|
||||
|
||||
- 状态:已采纳(2026-09-27)
|
||||
- 关联:REQ §9(技术架构选择)、docs/plan/00-first-version-plan.md §1
|
||||
|
||||
## 背景
|
||||
|
||||
需求文档 v0.7 的技术方向是"TypeScript + 最新稳定版 Deno(部署固定版本)+ PostgreSQL + Pi"。REQ §9 同时允许:在有明确依赖障碍时评估替代方案,并记录代价。
|
||||
|
||||
## 决定
|
||||
|
||||
v0.1 使用 **Node.js 24 LTS + pnpm workspace + TypeScript 5.9 strict**。前端沿用 /opt/uhd_web 的 React Router 7 + Vite 7 + Tailwind v4 + shadcn 技术栈,后端用 Hono 4(`@hono/node-server`)。
|
||||
|
||||
## 理由
|
||||
|
||||
1. **用户指定沿用 uhd_web 技术栈。** 设计系统、组件、token 守卫脚本、构建与 CI 习惯都直接复用,前后端同一套工具链(pnpm、vitest、tsc)。
|
||||
2. **开发机没有安装 Deno。** 引入第二套运行时会增加环境成本,且 uhd_web 相关脚本均基于 Node。
|
||||
3. **候选依赖以 Node 为主要目标。** OpenWorkflow(peer 依赖 postgres.js)与 Pi(`@earendil-works/pi-*`)的文档与测试以 Node 为主,在 Deno 下需要额外兼容性验证;用 Node 可以把 W0 spike 集中在业务语义(恢复/信号/取消/重试)上。
|
||||
4. REQ §9 允许记录代价后替代,产品行为要求不受运行时影响。
|
||||
|
||||
## 代价
|
||||
|
||||
- 失去 Deno 的默认权限沙箱(`--allow-net` 等)。REQ §10.2 要求的"进程权限、容器挂载、网络白名单"须在部署层(容器只读挂载、出站白名单)和业务层(能力注册中心显式白名单)落实,不能依赖运行时。
|
||||
- 失去 Deno 内置的 fmt/lint/test,改用 biome + vitest,需要自行锁版本。
|
||||
- npm 依赖面更大,需锁定 `pnpm-lock.yaml`,并对 install 脚本做白名单(`pnpm-workspace.yaml` 的 `allowBuilds`)。
|
||||
|
||||
## 复议条件
|
||||
|
||||
若后续部署要求强制运行时级权限隔离,或 Pi / OpenWorkflow 转为 Deno 优先,再评估迁移;领域包(`packages/domain`)保持无 IO,迁移成本主要在 adapters 与 server。
|
||||
@@ -0,0 +1,25 @@
|
||||
# 设计系统
|
||||
|
||||
控制台(apps/web)沿用 /opt/uhd_web 的设计系统:token 定义在 [app.css](../../apps/web/app/app.css),shadcn 组件在 `apps/web/app/components/ui/`,控制台共享件在 `apps/web/app/components/console/`。
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [design-tokens.md](design-tokens.md) | 颜色 token 分层、命名与使用规则,字号/圆角/时长/缓动阶梯 |
|
||||
| [interface-materials.md](interface-materials.md) | 材质:`pg-chrome` 贴边、`pg-slab` 内容卡、`pg-float` 悬浮层 |
|
||||
| [ui-controls.md](ui-controls.md) | 按钮、选中态、状态反馈、动效与圆角角色 |
|
||||
|
||||
## 硬规则
|
||||
|
||||
1. **新增语义先加 token 再用。** 需要新的颜色、字号、圆角、时长或缓动时,先在 `app.css` 定义语义 token(日夜两套值都给),跑 `pnpm --filter @umt/web gen:tokens`,再在组件里引用。
|
||||
2. **禁止原色与十六进制。** 组件里不写 `#xxxxxx`、`text-[#..]`、`bg-red-500` 这类 Tailwind 调色板色、`white/10` 之类的黑白透明度,也不写 `text-[13px]`、`duration-300`、`ease-[cubic..]`、裸 `rounded`。状态只用 `success` / `warning` / `destructive`,并且配文字,不只靠颜色。
|
||||
3. **材质按层级只有三种**,不在玻璃上叠玻璃:已在卡片里的空态用 `<EmptyState plain>`。
|
||||
4. 确需例外(第三方库的颜色参数等),在该行或上一行写 `token-allow: 原因`。
|
||||
|
||||
以上规则由 `pnpm check:tokens`(`apps/web/scripts/check-design-tokens.sh` + 生成物校验)在 `pnpm run ci` 中守住。
|
||||
|
||||
## 控制台共享件
|
||||
|
||||
- `PageHeader`:页面标题 + 一句说明 + 右侧操作区。
|
||||
- `EmptyState`:空态/建设中占位;`plain` 用于卡片内部。
|
||||
- `StatusBadge`:`success | warning | destructive | neutral` 四种语气,必须带文字。
|
||||
- `nav.ts`:12 个工作区的导航表,标题与说明同时供占位页使用。
|
||||
@@ -0,0 +1,95 @@
|
||||
# 设计 token
|
||||
|
||||
> 迁入自 /opt/uhd_web `docs/features/`(2026-09-27)。文中流媒体、播放器、落地页、套餐等段落是 uhd_web 的原始上下文,本项目没有这些页面,只作材质与 token 用法参考;以本目录 README 的约定为准。
|
||||
|
||||
样式入口是 [app.css](../../apps/web/app/app.css)。本说明定义颜色类 token 的分层、命名与使用规则,目的是让主站之外的皮肤(如动漫版)只改 token 值就能换皮,组件源码两边保持一致。材质类的说明见[界面材质](interface-materials.md)。
|
||||
|
||||
## 分层
|
||||
|
||||
| 层 | 在哪 | 谁能引用 |
|
||||
|---|---|---|
|
||||
| 原色 | `@theme` 里的 `--color-ink-*`、`--color-bone-*`、`--color-gold-*` 等色阶 | 只有下一层的 token 定义 |
|
||||
| 语义 token | `:root`(浅色门户)、`.dark`(深色门户)、`:root:has([data-stream-shell])`(流媒体壳)三块里的 `--content-*`、`--canvas` 等 | 材质类与组件 |
|
||||
| 材质 | `pg-surface` / `pg-float` / `pg-pill` 等 class 与 `--pg-*` 参数 | 组件 |
|
||||
| 组件 | `app/routes`、`app/components` 里的 class | 不引用原色、不写字面量 |
|
||||
|
||||
主题包只覆盖语义 token 的值(以及材质参数),不改组件选择器。流媒体壳的 token 块必须放在无层的语义 token 区,放进 `@layer base` 会被 `.dark` 的无层规则盖掉。
|
||||
|
||||
## 词汇
|
||||
|
||||
| token | 含义 | 夜间值 |
|
||||
|---|---|---|
|
||||
| `content-strong` / `content` / `content-secondary` / `content-tertiary` / `content-muted` / `content-faint` | 文字层级,随日夜翻转 | 中性白到灰,逐档变淡 |
|
||||
| `canvas` / `surface` / `surface-raised` / `surface-high` | 页面底、普通面、抬起的卡、更高一层 | 中性近黑,逐档抬亮 |
|
||||
| `brand-soft` / `brand-light` / `brand` / `brand-strong` / `brand-deep` | 品牌色族。主站无彩:夜间是白、日间是墨(2026-09-26);动漫版换成它的品牌色 | 白到浅灰 |
|
||||
| `success` / `warning` / `destructive`(及 `-foreground`) | 状态色,低饱和,只用于状态提示;实底上的文字用对应的 `-foreground` | 绿 / 琥珀 / 红 |
|
||||
| `wash` | 半透明"洗"层的底色:hover、按压、分隔线。透明度留在调用处,写成 `bg-wash/[0.07]`、`border-wash/10`;暗底是白,亮底是黑 | 白 |
|
||||
| `on-media-*`、`on-media-wash` | 压在视频 / 海报上的文字与洗层,不随日夜翻转,画面永远是暗的。播放器目录一律用这一族 | 与 content 族同值,白 |
|
||||
| `scrim` | 压暗画面的遮罩底色,透明度留在调用处(`bg-scrim/60`、`from-scrim/75`) | 黑 |
|
||||
|
||||
`bg-wash/[0.07]` 经 Tailwind 生成 `color-mix(in oklab, var(--wash) 7%, transparent)`,与原来的 `rgba(255,255,255,0.07)` 渲染结果一致;去字面量迁移时 143 对旧类到新类在同一元素上原地换类截图,字节级一致(渐变分开两个元素比对会有 Skia 抖动带来的 ±1 噪声,不是颜色差)。
|
||||
|
||||
## 字号
|
||||
|
||||
`@theme` 里的 `--text-*` 只定字号不定行高,行为等同原来的 `text-[Npx]`;自带行高的 `text-xs` / `text-sm` 等 Tailwind 档继续用,两套不混。组件按角色引用,不写像素。
|
||||
|
||||
| 阶 | token | 值 |
|
||||
|---|---|---|
|
||||
| 正文 | `micro` / `label` / `meta` / `caption` / `body` / `control` / `lead` / `lead-lg` | 10 / 11 / 12 / 13 / 14 / 15 / 16 / 17 px |
|
||||
| 标题 | `title-xs` / `title-sm` / `title` / `title-lg` / `headline-xs` / `headline-sm` / `headline` / `headline-lg` / `display-xs` / `display-sm` / `display` / `display-lg` / `display-xl` | 18 / 20 / 22 / 24 / 26 / 28 / 30 / 32 / 34 / 42 / 44 / 52 / 56 px |
|
||||
| 流式 | `hero-stream(-sm/-md)`、`detail-title`、`notfound-code`、`pricing-hero`、`pricing-title`、`auth-title`、`landing-*` | 各处 clamp 原值,按用途命名 |
|
||||
|
||||
2026-09-12 迁移时零散字号并入最近一档(10.5 → 11、13.5 → 14、0.72rem → 11、1.05rem → 17 等,最大偏差 0.6px,约 120 处),经页面级截图复验;标题阶逐档保留现状值,流式标题各处原值保留,收敛是设计决定另议。
|
||||
|
||||
tailwind-merge 不认识的 `text-*` 会被当成文字颜色,`text-control text-muted-foreground` 合并时字号会被丢掉。登记表不再手写:[gen-design-tokens.mjs](../../apps/web/scripts/gen-design-tokens.mjs) 从 app.css 抽出字号、缓动、圆角、时长 token 生成 `app/lib/design-tokens.generated.ts`,`cn()` 读它;改了 app.css 跑 `pnpm gen:tokens`,`pnpm run ci` 校验生成物是最新的。
|
||||
|
||||
## 圆角
|
||||
|
||||
控件 `rounded-md`、输入与小按钮 `rounded-lg`、卡片 `rounded-xl`、面板 `rounded-2xl`、抽屉与大卡 `rounded-3xl`、药丸 `rounded-full`。这些是 Tailwind 名,值由 `--radius` 派生,主题改一个变量整体缩放。角色型的三个:`rounded-tight`(徽章、骨架屏等小件,原来的裸 `rounded`)、`rounded-t-drawer`(手机端底部抽屉顶角)、`rounded-slab`(门户大卡)。不写 `rounded-[..]`。
|
||||
|
||||
## 动效
|
||||
|
||||
时长四档加两档特例,缓动五种,组件只用这些名字:
|
||||
|
||||
| 类 | 值 | 用途 |
|
||||
|---|---|---|
|
||||
| `duration-micro` | 150ms | 按压、hover 这类微反馈 |
|
||||
| `duration-base` | 200ms | 绝大多数颜色 / 透明度过渡 |
|
||||
| `duration-layout` | 300ms | 尺寸、位置变化 |
|
||||
| `duration-slow` / `duration-hero` / `duration-cinematic` | 500 / 700 / 1000ms | 入场、hero、装饰性动画 |
|
||||
| `ease-standard` | cubic-bezier(0.4, 0, 0.2, 1) | 默认 |
|
||||
| `ease-spring` / `ease-spring-soft` | 回弹 | 滑块、按压 |
|
||||
| `ease-sheet` | 抽屉 | 底部抽屉进出 |
|
||||
| `ease-pop` | 弹层出现 | pg-pop |
|
||||
|
||||
`duration-*` 是 `@utility`,写法与内置的 `duration-N` 一致(同时设 `--tw-duration`,tw-animate 的 `animate-in` 也读它)。app.css 里材质与关键帧动画的时长是逐个校准过的值,不套四档;缓动已统一引用 `--ease-*`。
|
||||
|
||||
## 材质参数与播放器白天模式
|
||||
|
||||
所有材质(播放器的 surface / pill,按钮的 pill-primary / pill-secondary / pill-selected,分段滑块 seg-thumb,门户的 slab / chrome / float,更新提示 notice)都是"固定配方 + `--mat-<材质>-<部位>` 参数",class 只引用参数,壳与主题只覆盖参数;结构见[界面材质](interface-materials.md)。
|
||||
|
||||
播放器整体压在画面上,默认只有夜间,但白天模式已经留出位置:主题在播放页作用域(`:root:has([data-stream-shell="play"])`)覆盖两组东西即可,不需要改播放器组件。
|
||||
|
||||
- `on-media-*` 与 `on-media-wash`:控件文字与洗层。做浅色玻璃控件时改成深色文字 + 黑色洗层。
|
||||
- `--mat-surface-*` / `--mat-pill-*`:材质参数。做浅色玻璃或平面 pop 材质时换底色与线色,blur 设 none 就是平面。
|
||||
|
||||
视频面本身永远是黑的,不在主题范围内。
|
||||
|
||||
## 规则
|
||||
|
||||
- 组件里不出现 `bone` / `ink` / `gold` 原色类、`white` / `black` 透明度写法和类名里的十六进制颜色。流媒体与播放器连 JS 里的十六进制也不写;测速图表、二维码库这类只接受颜色值的地方用组件内常量表。
|
||||
- 组件里不出现 Tailwind 调色板颜色(`emerald-*`、`amber-*`、`rose-*`、`blue-*` 等):状态用 `success` / `warning` / `destructive`,装饰性图标与强调用 `foreground` 的透明度。
|
||||
- 组件里不出现 `text-[Npx]` / `text-[Nrem]` / `text-[clamp(...)]`,字号只从阶梯取。
|
||||
- 组件里不出现 `duration-N`、`ease-[...]`、裸 `rounded`、`rounded-[...]`。
|
||||
- 改了 app.css 里的 token 就跑 `pnpm gen:tokens`,让 tailwind-merge 的登记表跟上;CI 会校验。
|
||||
- 播放器(`app/routes/stream/player/`)里压在画面上的东西用 `on-media` 族;流媒体浏览页用 `content` / `wash` 族。分不清就问一句:主题切到浅色时它应不应该变色。
|
||||
- 新的语义需求先加 token 再用,不在组件里临时拼色。
|
||||
- 按钮 hover 不填实心 `primary` / `foreground`;浮层不手写 `shadow-2xl`,用 `pg-float`。
|
||||
- `scripts/check-design-tokens.sh` 在 `pnpm run ci` 里对整个 `app/` 做守卫(落地页组件与 `/dev/ui` 除外)。确实需要例外时(二维码必须白底、支付品牌色),在该行或上一行写注释 `token-allow: 原因`,不另开白名单。
|
||||
|
||||
## 迁移状态
|
||||
|
||||
- 已迁移:颜色、字号、圆角、时长与缓动全部 `app/`(2026-09-27 守卫扩到全站)。
|
||||
- 未迁移:落地页组件(`app/components/landing`)仍固定夜间、有装饰性字面量,落地页日间另做;测速页图表配色是组件内的常量表(已改无彩)。
|
||||
- 材质已全部参数化;`pg-beam`、`tab-slider`(滑块的位移动效,外观是 `seg-thumb`)、`spd-bg` 这类效果不算材质。
|
||||
- 三个本来就不存在于色阶里的类(`text-bone-600`、`text-gold-50`、`to-gold-600`)在迁移时删除,它们原本不产生任何样式。
|
||||
@@ -0,0 +1,104 @@
|
||||
# 界面材质
|
||||
|
||||
> 迁入自 /opt/uhd_web `docs/features/`(2026-09-27)。文中流媒体、播放器、落地页、套餐等段落是 uhd_web 的原始上下文,本项目没有这些页面,只作材质与 token 用法参考;以本目录 README 的约定为准。
|
||||
|
||||
样式入口是 [app.css](../../apps/web/app/app.css),具体色值与透明度只在 CSS 中维护。颜色 token 见[设计 token](design-tokens.md),按钮与控件的用法见[控件使用标准](ui-controls.md),三块壳下的实物陈列在开发态 `/dev/ui`。
|
||||
|
||||
## 设计原则
|
||||
|
||||
全站(用户中心、公共页、测速、流媒体)是同一套审美,改样式先对照这几条:
|
||||
|
||||
1. **主色无彩**。夜间是白、日间是墨。彩色只来自内容(海报、剧照),外加一种很淡的棱镜光。状态色 `success` / `warning` / `destructive` 只表示状态,不做装饰。支付方式保留品牌色。
|
||||
2. **棱镜光只出现在三处**:左上角的漏光 `PrismLeak`、主操作按钮沿边框转动的光谱环、流媒体入口(侧栏的播放键 `prism-ring` 与菜单里的三角 `prism-icon`)。不再往别处加彩虹。
|
||||
3. **材质按层级只有三种**:
|
||||
- 贴边的用 `pg-surface` / `pg-chrome`;
|
||||
- 内容卡用 `pg-slab`,是磨砂面,好读优先;
|
||||
- 悬浮层用 `pg-float`,只有它用较深的投影。
|
||||
|
||||
不做边缘折射,也不在玻璃上再叠玻璃。
|
||||
4. **一个角色一种外观**:
|
||||
- 主操作是半透明胶囊加棱镜环,一屏只放一个;
|
||||
- 次操作是描边胶囊;
|
||||
- 选中态是静态的 `pill-selected`;
|
||||
- 分段控件的滑块是 `seg-thumb`。
|
||||
|
||||
全站没有实心按钮,也没有实心的选中块。
|
||||
5. **日夜两套,画面跟随页面**。大图、横向滚动行两侧的渐隐和压暗层都取页面底色,不硬切成黑色。播放页永远是夜间,落地页暂时固定夜间。
|
||||
6. **同一种信息用同一种结构**:
|
||||
- 套餐卡的零件在公共定价页和订阅服务页共用;
|
||||
- 电影和剧集详情页共用 `DetailExtras`;
|
||||
- 流媒体页面的内容区统一用 `max-w-[1800px]` 加 `px-4 sm:px-6 lg:px-10`,标题、海报行和顶栏对齐。
|
||||
7. **动效只做一次性的**。入场、按压回弹、悬停反馈可以做;持续运动的装饰不做。主按钮的光谱环是唯一的例外,而且在减少动态效果时停转。
|
||||
|
||||
这些规则里能机器检查的,由 [check-design-tokens.sh](../../apps/web/scripts/check-design-tokens.sh) 在 `pnpm run ci` 中守住(见[设计 token](design-tokens.md#规则))。
|
||||
|
||||
## 结构
|
||||
|
||||
一个材质 = 一份固定配方 + 一组参数。配方写在 class 里(哪几层、怎么叠),参数是 `:root` 上的 `--mat-<材质>-<部位>`:
|
||||
|
||||
| 部位 | 含义 |
|
||||
|---|---|
|
||||
| `bg` / `rgb` | 底色(surface 用 rgb 三元组配 `--pg-surface-alpha`,其余是完整颜色或整段 background 值) |
|
||||
| `sheen` / `reflection` / `glow` | 顶部光泽、斜向反光带、鼠标辉光,整段渐变 |
|
||||
| `blur` | backdrop-filter 值 |
|
||||
| `border` / `hairline` | 边 |
|
||||
| `highlight` / `shade` | 上沿内高光、下沿内暗线 |
|
||||
| `shadow` | 外投影(配方里各层形状不同的材质,这一项存整条 box-shadow 列表) |
|
||||
| `solid` | blur 不可用或需要不透明时的实心兜底 |
|
||||
|
||||
材质分三组:
|
||||
|
||||
- 播放器与媒体区:`surface`、`pill`;
|
||||
- 门户:`slab`、`chrome`、`float`,以及按钮用的 `pill-primary` / `pill-secondary` / `pill-selected` 和分段滑块 `seg-thumb`;
|
||||
- 更新提示胶囊:`notice`。
|
||||
|
||||
浅色门户、深色门户与流媒体的差别都只是参数覆盖,不各自重写声明。换皮做平面材质时,把 blur 设成 none、光泽设成透明、投影收掉即可,class 不动。
|
||||
|
||||
| 场景 | 样式 |
|
||||
|---|---|
|
||||
| 贴边的 header、筛选栏、播放器底栏 | `pg-surface`,模糊与底色,不叠加悬浮面板的泛光亮线 |
|
||||
| 悬浮层:下拉菜单、弹出框、下拉选择、对话框、抽屉、错误卡 | `pg-float`,全站唯一。菜单类 `rounded-2xl`,对话框 `rounded-3xl`,调用处不改底色、边框、投影。菜单里再弹出的子菜单、手机底部抽屉加 `pg-float-solid`(不透明),避免透出下面一层 |
|
||||
| 药丸 HUD、徽章等小型悬浮元素 | `pg-pill` |
|
||||
|
||||
新组件先确认它是贴边、内容还是悬浮,再复用对应样式。滚动行卡片本体和高频跟随鼠标的进度气泡不加背景模糊。
|
||||
|
||||
## 门户材质
|
||||
|
||||
`pg-slab` 用于外层内容卡,`pg-chrome` 用于侧栏、顶栏和贴边操作条,`pg-float` 用于悬浮面板;嵌套的子卡保持轻填充,避免多重玻璃。Dialog / Card 原语自带的 utilities 可能覆盖 components 层的材质类,要核对最终的 class 合并和层叠,不要继续叠加透明度补丁。
|
||||
|
||||
内容卡是磨砂面,不追求透明:背景模糊只让底下的明暗和淡彩柔和地渗进来,层次靠亮棱边、顶部内高光和轻投影区分。投影只用贴边一层加很淡的环境一层,日间更轻(`--shadow-card` 按日夜分别给值)。页面底层是 `bg-page-glow` 的无彩明暗光斑,不铺网格。`PrismLeak` 在每个页面外壳各放一个(落地页与播放页除外),浓度与叠加方式见 `--mat-leak-*`。
|
||||
|
||||
主操作 `pill-primary`:
|
||||
|
||||
- 一圈光谱沿边框转动,6 秒一圈,内侧透一层很淡的同色光;
|
||||
- 悬停时变亮,禁用或减少动态效果时停转;
|
||||
- 参数是 `--mat-pill-primary-*` 与 `--prism-*`。
|
||||
|
||||
次操作 `pill-secondary` 与选中态 `pill-selected` 分别由 `--mat-pill-secondary-*`、`--mat-pill-selected-*` 控制。按下回弹 `scale(0.96~0.97)`。
|
||||
|
||||
动效:
|
||||
|
||||
- 页面入场用 `motion-safe:animate-blur-in`(失焦到清晰,填充模式 backwards,播完不留 filter,避免破坏 fixed 定位的子元素)。
|
||||
- 需要鼠标反馈的卡片(套餐卡)挂 `.pg-spotlight`,并用 `rippleMove` 写入 `--mx/--my`。光在卡面底色之上、文字之下,亮度参数是 `--mat-spotlight`,只在能悬停的设备上出现。
|
||||
- `.pg-beam` 巡边光只留给少数常驻小徽标。
|
||||
|
||||
## 流媒体大图
|
||||
|
||||
流媒体首页与详情页的大图通栏显示,跟随日夜:
|
||||
|
||||
- 压暗层(`.hero-vignette` 与各层渐变)取页面底色:夜间压暗配浅色字,日间提亮配深色字。
|
||||
- 整个画面层在下沿渐隐成透明,露出页面的底色与光斑,和下面的内容连成一片。
|
||||
- 渐隐加在不缩放的外层,ken-burns 放大时不会裁出硬边。
|
||||
|
||||
详情页的标题区向上压进渐隐区,海报、规格徽章和按钮在首屏内。
|
||||
|
||||
画面层下面垫一层页面底色,加载占位 `BackdropSkeleton` 用同一个盒子、同样的渐隐。这样图片没到、数据没到的时候,顶栏下面也不会先露出后面的棱镜漏光再被盖掉,切换首页和详情页时顶栏颜色不跳。
|
||||
|
||||
列表页(电影、剧集、收藏、合集、演职人员)不做色带:标题、筛选条和内容坐在同一张页面底上。吸顶筛选条 `StickyFilterBar` 平时透明,滚到顶栏下面吸住之后才出现磨砂底,和顶栏连成一条。
|
||||
|
||||
## 其他
|
||||
|
||||
- 侧栏的选中项由滑块(`seg-thumb`)承担主要形态,导航文字与 hover 保持清晰;分组展开、滚动和路由变化都会影响滑块位置。
|
||||
- 改支付界面的样式时,要覆盖确认、二维码、错误与返回路径。套餐的优惠金额从价格推导,不写死。交易摘要用现有的 RemarkText 展示,不为排版改写后端摘要的含义。
|
||||
|
||||
主动使用 Taste 的触发条件和项目适配见[前端规范](../architecture.md)。
|
||||
@@ -0,0 +1,62 @@
|
||||
# 控件使用标准
|
||||
|
||||
> 迁入自 /opt/uhd_web `docs/features/`(2026-09-27)。文中流媒体、播放器、落地页、套餐等段落是 uhd_web 的原始上下文,本项目没有这些页面,只作材质与 token 用法参考;以本目录 README 的约定为准。
|
||||
|
||||
配合[设计 token](design-tokens.md)与[界面材质](interface-materials.md)。开发态的陈列页 `/dev/ui`(只在开发构建注册,[routes.ts](../../apps/web/app/routes.ts))把三块壳下的 token、材质和控件排在一页,换皮或改 token 先看它。
|
||||
|
||||
## 按钮只有一套
|
||||
|
||||
全站按钮(门户、公共页、测速、流媒体、播放器浮层)是同一套外观,与动漫站同一思路:形状一律胶囊或正圆,颜色与材质只由角色决定。
|
||||
|
||||
| 角色 | 门户写法 | 流媒体写法 | 外观 |
|
||||
|---|---|---|---|
|
||||
| 主操作 | `<Button>` | `btn-glass btn-glass-primary` | 半透明胶囊 + 流转的棱镜光谱边框,不做实心 |
|
||||
| 次操作 | `<Button variant="outline">`(`secondary` 同样子) | `btn-glass` | 描边胶囊 |
|
||||
| 图标钮 | `<Button variant="outline" size="icon*">` | `btn-glass-icon` | 描边正圆 |
|
||||
| 只有文字 / 图标 | `<Button variant="ghost">` | 各自的 ghost 行 | 悬停出淡底 |
|
||||
| 危险操作 | `<Button variant="destructive">` | — | 淡红底红字胶囊 |
|
||||
|
||||
一屏(或一张卡片)只放一个主操作,并列的其余动作退为描边;前置条件没满足时,真正该做的那一步是主操作(例如团队页没有团队套餐时,「创建团队套餐」是主操作,「添加成员」退为描边)。
|
||||
|
||||
两边的底、边、字色、投影都引用 `--mat-pill-*`,改外观只改参数。调用处只给布局(宽度、高度、间距、定位),不改圆角、底色、边框、投影和字色;不是 `<button>` 的元素(`Link`、`a`)用 `buttonVariants()` 拿同一套类名。分段标签、菜单行、列表行不算按钮,按各自控件规范;分页当前页、角标、选中的季 / 集 / 选项、日历选中日用静态的 `pill-selected`,不借主按钮样式;分段控件(侧栏、SegmentedTabs、求片页的分段)的滑块用 `seg-thumb`。放胶囊或分段控件的横向滚动容器加 `scroll-bleed`:`overflow-x-auto` 会连带裁掉纵向溢出,不加的话胶囊的阴影和描边会被切成直边。
|
||||
|
||||
右上角账户模块全站共用 [`account-popover.tsx`](../../apps/web/app/components/account-popover.tsx):用户中心用头像 + 名字的胶囊,流媒体与测速用圆头像;菜单里是另外两块的跳转(当前所在的不列,流媒体与测速需有生效订阅)与退出登录。
|
||||
|
||||
其余控件仍分两处实现:门户原语在 `app/components/ui/`(shadcn 语义 token),流媒体控件在 `app/routes/stream/_sub/`、`app/routes/stream/player/controls.tsx`(浏览页 `content` / `wash`,播放器 `on-media` 族)。需要相同行为时各自实现。
|
||||
|
||||
## 尺寸与圆角
|
||||
|
||||
- 图标钮:桌面 36px,粗指针 40px(`[@media(pointer:coarse)]`);播放键等主钮 44 / 48。
|
||||
- 圆角角色:控件 `rounded-md`、输入与小按钮 `rounded-lg`、卡片 `rounded-xl`、面板 `rounded-2xl`、抽屉与大卡 `rounded-3xl`、药丸 `rounded-full`、徽章与骨架屏 `rounded-tight`。
|
||||
- 字号:控件文字 `text-control`(15),菜单项 `text-body` / `text-caption`,面板小标签 `text-label` 大写 `tracking-[0.18em]`。
|
||||
|
||||
## 状态反馈
|
||||
|
||||
- hover:洗层 `hover:bg-wash/[0.06]`(播放器 `on-media-wash/[0.07]`),文字提一档(secondary → strong)。
|
||||
- 按压:`active:scale-[0.95]`(圆形主钮 0.92),`transition-[transform,background-color,color] duration-micro`。
|
||||
- 选中:`pill-selected`(静态半透明底 + 稍深描边);单选列表项左侧 Check 标记。进度用细线表示,不做实心填充。
|
||||
- 打开中的触发钮:`bg-wash/[0.08]` 保持高亮。
|
||||
- 焦点:门户走 shadcn 的 `focus-visible:ring`,玻璃族 `focus-visible:ring-2 ring-brand/50`。
|
||||
- 禁用:`opacity-50 cursor-not-allowed`,不改颜色。
|
||||
- 危险:移除、删除这类动作平时是中性描边,悬停时换成 `destructive` 的边框与文字。
|
||||
|
||||
## 动效
|
||||
|
||||
`duration-micro` 微反馈、`duration-base` 颜色透明度、`duration-layout` 尺寸位置、`duration-slow` 及以上只给入场与装饰;缓动默认 `ease-standard`,滑块与按压回弹 `ease-spring`,抽屉 `ease-sheet`,弹层 `animate-pg-pop`。尊重 reduced motion。
|
||||
|
||||
## 大写小标签
|
||||
|
||||
`SectionLabel`([section-label.tsx](../../apps/web/app/components/section-label.tsx)),三种口味:`portal`(门户表单字段名与分组,`text-xs font-semibold uppercase text-muted-foreground`)、`page`(浏览页,`text-label uppercase tracking-[0.18em] text-content-muted`)、`media`(播放器弹层,同上换 on-media)。`as` 指定元素(label / span / p)。
|
||||
|
||||
还有几种写法没收进来,值不同、收进来就是像素变化,待设计决定:门户里 `text-xs font-medium uppercase tracking-wider`(团队订单弹窗)、`text-micro … tracking-[0.15em]`(侧栏分组、邀请页)、`tracking-widest` 一处;落地页 hero 的 `tracking-[0.32em] text-white/45` 与分区标题的 `text-meta font-bold tracking-[.22em] text-brand-strong` 是刻意的装饰用法,单列为 hero kicker 与 section kicker。
|
||||
|
||||
## 弹层结构
|
||||
|
||||
触发钮 + 面板,面板由 `usePopover` 管开关(点外面 / Esc 关闭)。面板顶部一行大写小标签说明这是什么,下面是列表;列表项分两种:单选行(左勾选 + 标签 + 右侧提示)与动作行(左图标 + 文字)。四件在 `player/controls.tsx`:`MenuTrigger`、`MenuPanel`、`MenuOption`、`MenuButton`。`MenuPanel` 通吃三处:`placement` 上方(播放器)/ 下方(浏览页 header),`tone` 决定表头口味,`role` 给 listbox,内边距、宽度、z 与偏移经 className 传入,tailwind-merge 让它们覆盖默认值,所以三处的 class 集合与各自收拢前完全一致(用 react-dom/server 渲出来逐项核对过)。账户菜单的头像表头与排序菜单的单选行样式不同,仍各自写在页面里。
|
||||
|
||||
## 收拢原则
|
||||
|
||||
- 同一份 class 串出现在两处以上就抽原语,抽的时候 class 串逐字不改,渲染不变。
|
||||
- 参数只到必要:尺寸档、是否激活、标签、图标;不把整页状态传给控件。
|
||||
- 只有一处用的局部组件留在页面文件里,不为了"以后复用"提前抽。
|
||||
- 两个页面展示同一种信息时共用零件:套餐卡用 [plan-card-parts.tsx](../../apps/web/app/components/plan-card-parts.tsx)(名称价格、规格表、线路提醒、共有权益),电影与剧集详情用 [DetailExtras.tsx](../../apps/web/app/routes/stream/_sub/DetailExtras.tsx)(规格徽章、影片信息、可播放版本、更多推荐)。
|
||||
@@ -0,0 +1,75 @@
|
||||
# 首个可用版本(v0.1)工程规划
|
||||
|
||||
编制:2026-09-27。需求基线:`../../media_automation_requirements_architecture_v0.7.md`(下称 REQ)。本文件是所有开发子任务的统一任务书;与 REQ 冲突时以 REQ 的产品要求为准,技术选型以本文件为准。
|
||||
|
||||
## 1. 技术决策(ADR 摘要,详见 docs/adr/)
|
||||
|
||||
| 项 | 决定 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 运行时 | **Node.js 24 LTS + pnpm workspace**,TypeScript 5.9 strict | 用户指定沿用 /opt/uhd_web 技术栈;开发机无 Deno。REQ §9 允许"有明确依赖障碍时评估替代并记录代价"。ADR-001 记录此偏离。 |
|
||||
| 前端 | React 19 + React Router 7(SPA, `ssr:false`)+ Vite 7 + Tailwind v4 + shadcn(new-york, radix-ui 统一包)+ TanStack Query 5 + zod 4 + sonner + lucide | 直接拷贝 uhd_web 的 `app/app.css` 设计 token、`app/components/ui/*`、`app/lib/api.ts`、`app/lib/utils.ts`、`scripts/gen-design-tokens.mjs`、`scripts/check-design-tokens.sh`、`docs/features/{design-tokens,interface-materials,ui-controls}.md`。**不引入 uhd_web 的播放器/WASM 相关内容。** |
|
||||
| 后端 HTTP | Hono 4(`@hono/node-server`),REST + SSE | OpenAPI 由 zod schema 生成(`@hono/zod-openapi`)。 |
|
||||
| 数据库 | PostgreSQL 16;Kysely + pg;SQL 迁移文件 + Kysely Migrator;`kysely-codegen` 或手写 DB 类型 | 开发用 docker-compose 起 `postgres:16-alpine`,端口 55433。 |
|
||||
| 持久任务引擎 | 优先 `[email protected]`(peer: postgres.js);**须先通过 spike 验证**(重启恢复/等待信号/取消/重试)。未通过则用 `packages/tasks` 内的 PG 租约式任务表(lease + attempts + dead-letter)。 | 结论写 ADR-002。 |
|
||||
| Agent 内核 | `@earendil-works/[email protected]` + `@earendil-works/[email protected]`,锁版本;通过自有 `AgentRuntimePort` 封装,显式工具白名单,不开放 bash/文件系统/任意网络 | 测试用脚本化 fake 模型 provider;真实上游由设置页配置。 |
|
||||
| 校验/测试 | vitest(server/packages 单元 + 集成);web 沿用 token 守卫脚本;biome 做 lint/format | `pnpm ci` = typecheck + lint + test + check:tokens + build |
|
||||
| 端口 | web dev 5180,API 4380,PG 55433 | 本机 3000/5175/5176/55432 已被占用,不要用 |
|
||||
| 参考源码 | MoviePilot: `/opt/moviepilot-plus/upstream/MoviePilot`(GPLv3,只做行为对照与 TS 重写,不复制文件);MovieClaw 若本机不存在则按 REQ 附录链接对照 | 迁移对照记录写 `docs/porting/` |
|
||||
|
||||
## 2. 仓库结构(pnpm monorepo)
|
||||
|
||||
```
|
||||
apps/web React Router 7 SPA 控制台(复用 uhd_web 设计系统)
|
||||
apps/server Node 服务:HTTP API + SSE + worker 入口(单进程可跑,也可 `--role api|worker` 分开)
|
||||
packages/contracts zod schema + 类型:所有 API/事件/能力(capability)输入输出的唯一定义,web 与 server 共用
|
||||
packages/db Kysely 类型、迁移、仓储辅助
|
||||
packages/domain 纯业务逻辑(无 IO):标题解析/身份匹配/Slot 满足度/路径绑定/整理计划/策略评估
|
||||
packages/adapters 外部系统适配:qBittorrent、RSS、站点(观众 NexusPHP / 馒头 API)、TMDB、Emby 兼容、文件系统
|
||||
packages/tasks 持久任务定义与执行器(OpenWorkflow 或自研租约表),outbox
|
||||
packages/agent Pi 接入:AgentRuntimePort、能力注册中心桥接、agent_run 持久化、fake provider
|
||||
packages/testkit mock 服务:mock qB WebUI、mock NexusPHP 站点、mock M-Team API、mock RSS、mock TMDB、临时目录夹具
|
||||
docs/adr 架构决策记录
|
||||
docs/plan 本规划与里程碑验收清单
|
||||
docs/porting MoviePilot/MovieClaw 迁移对照账本(REQ §14.1.2 格式)
|
||||
docs/spikes 兼容性验证记录
|
||||
docs/runbook 启动/备份/恢复/配置说明
|
||||
scripts dev/ci 辅助脚本
|
||||
```
|
||||
|
||||
约定:
|
||||
- 所有共享类型只在 `packages/contracts` 定义;web 不得手写与 server 重复的 DTO。
|
||||
- `packages/domain` 不 import 任何 IO 库,100% 可单元测试。
|
||||
- 每个包有 `src/index.ts` 作为唯一公开入口;跨包只通过入口引用。
|
||||
- 命名:表名 snake_case 与 REQ §12 实体名一致;TS 类型 PascalCase;能力 ID 用 `域.动作`(如 `download.submit`)。
|
||||
- 提交:conventional commits(`feat(server): ...`),每个子任务结束前 `pnpm ci` 必须绿再提交。
|
||||
- 凭证一律 secret reference(`secret_ref` 列 + 加密存储),日志/错误/模型上下文脱敏;含 passkey 的 URL 不得进入日志。
|
||||
|
||||
## 3. v0.1 范围(= REQ M0 + M1 纵切,可在无真实 PT 账号下用 mock 全链路验证)
|
||||
|
||||
### 3.1 必做
|
||||
1. **底座**:monorepo、配置(env + settings 表)、迁移、API Key 鉴权(scope:read/organize.read/organize.execute/admin)、审计日志 `audit_event`、SSE 事件流(带序号可补读)、全局"自动化总开关"。
|
||||
2. **数据模型**(REQ §12 子集,表名照抄):media_entity、content_unit、external_identity、identity_revision、title_alias、media_asset、asset_location、path_binding、library_root、site_account、feed_source、observation、source_release、subscription、policy_revision、acquisition_slot、preference_tag、preference_suppression、acquisition_plan、download_task、asset_operation、decision、approval、automation_grant、agent_run、artifact、audit_event、outbox_event、reconcile_run、server_mapping、api_key、organization_record、organization_file_result、downloader_instance、media_server_instance、settings。
|
||||
3. **来源**:多 RSS 抓取(ETag/Last-Modified、重叠窗口、GUID+摘要去重、退避/熔断、passkey 脱敏)、观众(NexusPHP 列表/详情/下载,参考 MovieClaw audiences.yaml 结构)与馒头(API Key)两类站点适配器、站点快照缓存 + 实时补查 + 站点启停 + 站点级限流。
|
||||
4. **身份与元数据**:发布标题解析(对照 MP MetaInfo 行为:标题/年份/季集/分辨率/来源/编码/发布组/版本)、TMDB 查询与缓存、本地实体优先 + external_identity 映射(provider+type+id 唯一)、title_alias 模糊召回(pg_trgm)、临时 content_unit、待识别池。
|
||||
5. **规则与选片**:subscription(单次/持续追踪)、acquisition_slot(硬条件/软偏好/升级阈值/完成条件,状态机 empty→reserved→downloading→verifying→satisfied/upgrade_pending/attention)、确定性 evaluateAcquisition(不含 Agent 主观选片,只留接口)、每日预算与并发限制、优先级(即时>追更>新作品>升级)。
|
||||
6. **下载**:qB WebUI 适配(多实例、管理标签、提交/接管/查询/暂停恢复)、缓存卷硬阈值与恢复水位(暂停范围仅限纳管任务)、download_task 状态机、无进展只记录不换源。
|
||||
7. **入库**:verifyAndImport → Copy/Move(临时文件+校验+提交;跨卷 Copy→核验→删源;EXDEV 不静默降级)、路径绑定固定(已绑定目录不因译名变化改变)、organization_record + 文件级结果、**公开整理记录 API**(API Key、游标增量)。
|
||||
8. **持久任务**:ingestFeed、resolveObservation、evaluateAcquisition、submitDownload、verifyAndImport、refreshMetadata、reconcileLibrary(资产存在性)、cleanupDownloadCache、resolveDecision;outbox;重启恢复;幂等键;取消传播。
|
||||
9. **Agent**:Pi AgentSession 封装;能力注册中心(ID/版本/schema/权限/风险/幂等/预览/处理器),首批能力:catalog.lookup、release.search、release.inspect、identity.resolve、download.plan、download.submit、organize.plan、organize.execute、task.inspect、task.retry、task.cancel、policy.preview、policy.apply、inventory.query;授权校验在服务端;agent_run 持久化 + SSE 增量;后台事件触发有界 Agent 任务(识别歧义、下载失败诊断);设置页可配上游/模型/思考档位/上下文预算/压缩/子代理开关。
|
||||
10. **控制台页面**(REQ §13):首页六区块(自动化概览/当前工作/需要我处理/缓存与保留/来源健康/最近产出)、媒体库(列表+详情:路径/身份映射/元数据来源/Slot/候选拒绝理由/下载历史)、订阅与收录规则、偏好标签(基础 CRUD+抑制记录)、PT 站点工作台(整理视图:列表/搜索/详情/库存叠加/统一下载入口;原站视图首版只留占位与说明)、来源/Feed/快照、下载与任务(含业务时间线)、整理记录、待处理中心、身份与元数据、系统设置(实例/路径映射/API Key/Pi 上游与预算/自动化开关)、Agent 会话页 + 侧栏。
|
||||
11. **可选 Emby 兼容查询**:多实例,查有无/季集/版本,未知≠缺失。
|
||||
12. **运维**:`scripts/backup.sh`(pg_dump + 映射导出)、恢复后只读对账模式开关、docker-compose(生产:server + web(nginx) + pg)、runbook。
|
||||
13. **测试**:domain 单元测试;adapters 对 mock 服务的集成测试;端到端:mock RSS → 观察 → 识别 → 计划 → mock qB 下载完成 → Copy 入库 → 整理记录 API 可读;Agent e2e:fake 模型按脚本调用能力完成"把 X 电影 1080p 收进来"闭环;崩溃恢复注入(worker 中断后续跑)。
|
||||
|
||||
### 3.2 v0.1 明确不做(留接口/占位)
|
||||
国产综艺分段建模与正片覆盖(M2)、双版本 require_distinct_asset 组合校验(M2,但 Slot 多目标数据结构先建好)、浏览器/camofox 接入与原站视图(M2,BrowserProvider 接口定义先落)、HHClub/CHD 适配(M2)、旧库分范围接管预览(M2)、Agent 主观选片与规则学习回放(M2)、媒体级主动整理 UI(M2,organize.plan 能力可用)、多用户。
|
||||
|
||||
## 4. 里程碑与派工波次
|
||||
|
||||
- **W0 底座**(并行):A 脚手架与设计系统迁入;B OpenWorkflow+Pi 兼容性 spike(结论进 ADR-002);C MoviePilot 迁移对照账本(识别/刮削 + 转移入库)。
|
||||
- **W1 领域与适配**(并行):domain(解析/身份/Slot/路径/整理计划)、adapters(qB/RSS/站点/TMDB/Emby)+ testkit mocks、db schema 全量迁移 + contracts 全量 schema、tasks 引擎接入。
|
||||
- **W2 服务编排**:server API 全量路由 + 任务处理器串起纵切 + Agent 能力注册与 Pi 封装。
|
||||
- **W3 控制台**(按页面分组并行)+ e2e。
|
||||
- **W4 集成验收与修复**:跑 §3.1 第 13 条全部;补 runbook;打 tag v0.1.0。
|
||||
|
||||
每波结束的验收由独立审查子代理执行:`pnpm ci` 绿 + 对照本文件逐条勾选 + 抽查 REQ §2.1 五原则未被破坏。
|
||||
@@ -0,0 +1,49 @@
|
||||
# 开发环境运行手册
|
||||
|
||||
## 前置
|
||||
|
||||
- Node.js ≥ 24(当前开发机 24.15)、pnpm 11(`packageManager` 已锁)、Docker。
|
||||
- 端口:web 5180、API 4380、PG 55433。本机 3000/5175/5176/55432 已被其他项目占用,不要改用。
|
||||
|
||||
## 首次启动
|
||||
|
||||
```sh
|
||||
cp .env.example .env # 按需修改;server 与 db 脚本都读仓库根 .env
|
||||
pnpm db:up # 起 postgres:16-alpine(docker-compose.dev.yml,数据卷 umt-pg-data)
|
||||
pnpm install
|
||||
pnpm db:migrate # 执行 packages/db/migrations 下全部迁移
|
||||
pnpm dev # 同时起 server(4380)与 web(5180)
|
||||
```
|
||||
|
||||
单独起:`pnpm dev:server`(默认 `--role all`,API + worker 同进程)、`pnpm dev:web`。
|
||||
|
||||
## 验证
|
||||
|
||||
```sh
|
||||
curl localhost:4380/api/v1/health
|
||||
# 开发模式可用 .env 的 DEV_ADMIN_KEY 作 admin
|
||||
curl -N -H "Authorization: Bearer umt-dev-admin-key" "localhost:4380/api/v1/events?after=0"
|
||||
```
|
||||
|
||||
- OpenAPI:http://localhost:4380/api/openapi.json,文档页 http://localhost:4380/api/docs
|
||||
- 控制台:http://localhost:5180,右上角钥匙按钮设置 API Key(存 localStorage `umt-api-key`)
|
||||
|
||||
## 常用命令
|
||||
|
||||
| 命令 | 作用 |
|
||||
|---|---|
|
||||
| `pnpm run ci` | typecheck + lint + test + check:tokens + build,提交前必须全绿。注意 `pnpm ci` 在 pnpm 11 是内置的 clean-install 命令,不会执行本脚本 |
|
||||
| `pnpm format` | biome 自动修复格式与 import 排序 |
|
||||
| `pnpm db:reset` | 清空 public schema 并重新迁移(生产环境拒绝执行) |
|
||||
| `pnpm --filter @umt/web gen:tokens` | 改了 app.css 后重新生成 token 登记表 |
|
||||
| `pnpm --filter @umt/server start` | 运行 `pnpm build` 产出的 `apps/server/dist/main.js` |
|
||||
|
||||
## 进程角色
|
||||
|
||||
`node dist/main.js --role api|worker|all`。`all` 下 worker 心跳会发布到进程内事件总线,SSE 可见;分进程部署时事件总线需改为 PG 投影(W2)。
|
||||
|
||||
## 排障
|
||||
|
||||
- `配置无效`:按提示检查 `.env`,`SECRET_MASTER_KEY` 至少 16 字符,`DATABASE_URL` 必须是 postgres://。
|
||||
- health 返回 `db: "down"`:`docker compose -f docker-compose.dev.yml ps` 看容器健康状态。
|
||||
- 日志中的 passkey/token/cookie/authorization/password 字段与 URL query 参数会被替换为 `[REDACTED]`;若发现明文,属于缺陷。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "uhd-media-tool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "[email protected]",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "pnpm --parallel --filter @umt/server --filter @umt/web dev",
|
||||
"dev:web": "pnpm --filter @umt/web dev",
|
||||
"dev:server": "pnpm --filter @umt/server dev",
|
||||
"db:up": "docker compose -f docker-compose.dev.yml up -d --wait",
|
||||
"db:migrate": "pnpm --filter @umt/db migrate",
|
||||
"db:reset": "pnpm --filter @umt/db reset",
|
||||
"typecheck": "pnpm -r --workspace-concurrency=4 typecheck",
|
||||
"lint": "biome check .",
|
||||
"format": "biome check --write .",
|
||||
"test": "pnpm -r test",
|
||||
"check:tokens": "pnpm --filter @umt/web check:tokens",
|
||||
"build": "pnpm -r build",
|
||||
"ci": "pnpm typecheck && pnpm lint && pnpm test && pnpm check:tokens && pnpm build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.14",
|
||||
"@types/node": "^24",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^5.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# @umt/adapters
|
||||
|
||||
外部系统适配层:qBittorrent、RSS、站点(观众 NexusPHP / 馒头 API)、TMDB、Emby 兼容服务器与文件系统。对上只暴露端口接口,凭证经 secret reference 注入,日志一律脱敏。
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@umt/adapters",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// @umt/adapters 公开入口;W1 起在此导出实现
|
||||
export const PACKAGE_NAME = "@umt/adapters";
|
||||
@@ -0,0 +1,6 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { PACKAGE_NAME } from "../src";
|
||||
|
||||
it("包入口可加载", () => {
|
||||
expect(PACKAGE_NAME).toBe("@umt/adapters");
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# @umt/agent
|
||||
|
||||
Pi Agent 接入:AgentRuntimePort 封装、能力注册中心桥接、agent_run 持久化与测试用 fake 模型 provider。只通过显式白名单能力操作业务,不开放 bash/文件系统/任意网络。
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@umt/agent",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// @umt/agent 公开入口;W1 起在此导出实现
|
||||
export const PACKAGE_NAME = "@umt/agent";
|
||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user