commit b22c4401399670110e8597ae75ebedaa97ecd5ba Author: umt Date: Sun Sep 27 17:36:26 2026 +0800 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 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..8c52ff93 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..f7bbd79a --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..9bf301d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +build/ +.react-router/ +coverage/ +*.tsbuildinfo +.env +.env.local +.env.*.local +*.log +.DS_Store +.vite/ diff --git a/README.md b/README.md new file mode 100644 index 00000000..b0bdf7a5 --- /dev/null +++ b/README.md @@ -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))。 diff --git a/apps/server/README.md b/apps/server/README.md new file mode 100644 index 00000000..bebcc579 --- /dev/null +++ b/apps/server/README.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` 单进程运行。业务规则不写在这里,只做编排、鉴权、审计与协议适配。 diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 00000000..742603dd --- /dev/null +++ b/apps/server/package.json @@ -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" + } +} diff --git a/apps/server/scripts/build.mjs b/apps/server/scripts/build.mjs new file mode 100644 index 00000000..6eed8a5b --- /dev/null +++ b/apps/server/scripts/build.mjs @@ -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 }; + }); + }, + }, + ], +}); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts new file mode 100644 index 00000000..18954c7b --- /dev/null +++ b/apps/server/src/config.ts @@ -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; + +/** 从环境变量加载并校验配置;失败时抛出可读错误(不含变量值) */ +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) => c.NODE_ENV !== "production"; diff --git a/apps/server/src/http/app.ts b/apps/server/src/http/app.ts new file mode 100644 index 00000000..026285f4 --- /dev/null +++ b/apps/server/src/http/app.ts @@ -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; + logger: Logger; + bus: EventBus; + version: string; + role: "api" | "worker" | "all"; + startedAt?: number; + db?: Kysely; + /** 测试可注入;默认用 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({ + // 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; diff --git a/apps/server/src/http/auth.ts b/apps/server/src/http/auth.ts new file mode 100644 index 00000000..b34fa1ff --- /dev/null +++ b/apps/server/src/http/auth.ts @@ -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; + touch(id: string): Promise; +} + +export const hashApiKey = (key: string) => createHash("sha256").update(key, "utf8").digest("hex"); + +export function kyselyApiKeyStore(db: Kysely): 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 { + 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 { + 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(); + }; +} diff --git a/apps/server/src/http/errors.ts b/apps/server/src/http/errors.ts new file mode 100644 index 00000000..cbe80890 --- /dev/null +++ b/apps/server/src/http/errors.ts @@ -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 } }); diff --git a/apps/server/src/http/routes/health.ts b/apps/server/src/http/routes/health.ts new file mode 100644 index 00000000..55150353 --- /dev/null +++ b/apps/server/src/http/routes/health.ts @@ -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; +} + +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; +} diff --git a/apps/server/src/http/routes/openapi.ts b/apps/server/src/http/routes/openapi.ts new file mode 100644 index 00000000..778fa869 --- /dev/null +++ b/apps/server/src/http/routes/openapi.ts @@ -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, 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" })); +} diff --git a/apps/server/src/http/sse.ts b/apps/server/src/http/sse.ts new file mode 100644 index 00000000..af7a7d96 --- /dev/null +++ b/apps/server/src/http/sse.ts @@ -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(); + + constructor(private readonly capacity = 1000) {} + + get latestSeq() { + return this.seq; + } + + publish

(type: string, payload: P): EventEnvelope

{ + const e: EventEnvelope

= { 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=(也接受 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((r) => { + if (queue.length || closed) r(); + else wake = r; + }); + wake = null; + } + } finally { + clearInterval(timer); + unsubscribe(); + } + }); + }); + return app; +} diff --git a/apps/server/src/logger.ts b/apps/server/src/logger.ts new file mode 100644 index 00000000..ab861838 --- /dev/null +++ b/apps/server/src/logger.ts @@ -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 = {}; + 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, + }, + 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); +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts new file mode 100644 index 00000000..18c35a24 --- /dev/null +++ b/apps/server/src/main.ts @@ -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; +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> = []; + +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")); diff --git a/apps/server/src/worker/index.ts b/apps/server/src/worker/index.ts new file mode 100644 index 00000000..584d4571 --- /dev/null +++ b/apps/server/src/worker/index.ts @@ -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"); + }, + }; +} diff --git a/apps/server/test/auth.test.ts b/apps/server/test/auth.test.ts new file mode 100644 index 00000000..049a5770 --- /dev/null +++ b/apps/server/test/auth.test.ts @@ -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["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); + }); +}); diff --git a/apps/server/test/health.test.ts b/apps/server/test/health.test.ts new file mode 100644 index 00000000..fa091d73 --- /dev/null +++ b/apps/server/test/health.test.ts @@ -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(); + }); +}); diff --git a/apps/server/test/helpers.ts b/apps/server/test/helpers.ts new file mode 100644 index 00000000..2260b476 --- /dev/null +++ b/apps/server/test/helpers.ts @@ -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): 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 => res.json(); diff --git a/apps/server/test/logger.test.ts b/apps/server/test/logger.test.ts new file mode 100644 index 00000000..6c507a25 --- /dev/null +++ b/apps/server/test/logger.test.ts @@ -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); + }); +}); diff --git a/apps/server/test/sse.test.ts b/apps/server/test/sse.test.ts new file mode 100644 index 00000000..85073e92 --- /dev/null +++ b/apps/server/test/sse.test.ts @@ -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(); + }); +}); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 00000000..7680f997 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "test"] +} diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 00000000..55884c78 --- /dev/null +++ b/apps/web/README.md @@ -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。 diff --git a/apps/web/app/api/health.ts b/apps/web/app/api/health.ts new file mode 100644 index 00000000..9a7fa3f2 --- /dev/null +++ b/apps/web/app/api/health.ts @@ -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("/api/v1/health", { signal }), + refetchInterval: 15 * TTL_SECOND, + retry: false, + }); +} diff --git a/apps/web/app/app.css b/apps/web/app/app.css new file mode 100644 index 00000000..56a1ce3d --- /dev/null +++ b/apps/web/app/app.css @@ -0,0 +1,1616 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +/* ───────────────────────── Tailwind configuration ───────────────────────── */ + +@custom-variant dark (&:is(.dark *), &:is([data-force-dark] *)); + +@theme { + --font-sans: + "Inter Tight Variable", "Inter Tight", "SF Pro Display", "PingFang SC", + "Microsoft YaHei", sans-serif; + --font-display: + "Space Grotesk Variable", "Space Grotesk", "Inter Tight Variable", + "SF Pro Display", sans-serif; + + --color-ink-950: #040507; + --color-ink-900: #080b12; + --color-ink-800: #141925; + --color-ink-700: #202637; + + --color-bone-50: #fbfaf7; + --color-bone-100: #f1eee7; + --color-bone-200: #dcd5c8; + --color-bone-300: #bdb4a5; + --color-bone-400: #918879; + --color-bone-500: #6f675c; + + --color-gold-100: #fff0c4; + --color-gold-200: #f7d98d; + --color-gold-300: #e6b85c; + --color-gold-400: #d49a4a; + --color-gold-500: #af7133; + + --tracking-hero: 0; + + --shadow-card: 0 18px 40px -24px rgba(0, 0, 0, 0.9); + --shadow-card-hover: 0 26px 54px -24px rgba(0, 0, 0, 1); + + --animate-fade-in: fade-in 520ms ease-out both; + --animate-pg-pop: pg-pop 200ms var(--ease-pop) both; + /* 播放器加载提示的滚动字幕:新一行从下方滚入,上一行向上滚出 */ + --animate-ticker-in: ticker-in 360ms var(--ease-standard) both; + --animate-ticker-out: ticker-out 360ms var(--ease-standard) both; + --animate-blur-in: blur-in 560ms var(--ease-pop) backwards; + --animate-hero-fade: hero-fade 740ms ease-out both; + --animate-ken-burns: ken-burns 14s ease-out both; + + /* ─── 字号阶梯(见 docs/features/design-tokens.md)────────────────────── + 只定字号不定行高,行为等同原来的 text-[Npx];自带行高的 text-xs / text-sm 继续用。 + 组件按角色引用,不写像素。正文阶:10 / 11 / 12 / 13 / 14 / 15 / 16 / 17。 */ + --text-micro: 10px; + --text-label: 11px; + --text-meta: 12px; + --text-caption: 13px; + --text-body: 14px; + --text-control: 15px; + --text-lead: 16px; + --text-lead-lg: 17px; + /* 标题阶:18 / 20 / 22 / 24 / 26 / 28 / 30 / 32 / 34 / 42 / 44 / 52 / 56(现状值逐档保留,收敛另议) */ + --text-title-xs: 18px; + --text-title-sm: 20px; + --text-title: 22px; + --text-title-lg: 24px; + --text-headline-xs: 26px; + --text-headline-sm: 28px; + --text-headline: 30px; + --text-headline-lg: 32px; + --text-display-xs: 34px; + --text-display-sm: 42px; + --text-display: 44px; + --text-display-lg: 52px; + --text-display-xl: 56px; + /* 流式标题:每处 clamp 原值保留,按用途命名;要不要收成三档是设计决定 */ + --text-hero-stream: clamp(32px, 9vw, 46px); + --text-hero-stream-sm: clamp(40px, 7.5vw, 72px); + --text-hero-stream-md: clamp(44px, 7.5vw, 88px); + --text-detail-title: clamp(32px, 8vw, 82px); + --text-notfound-code: clamp(7rem, 18vw, 11rem); + --text-pricing-hero: clamp(2rem, 4.4vw, 4rem); + --text-pricing-title: clamp(1.6rem, 2.4vw, 2.2rem); + --text-auth-title: clamp(2rem, 3.4vw, 3.4rem); + --text-landing-hero: clamp(2.1rem, 4.1vw, 3.9rem); + --text-landing-platforms-title: clamp(1.8rem, 3.4vw, 2.8rem); + --text-landing-immersive-title: clamp(2.25rem, 4.8vw, 5.4rem); + --text-landing-service-hero: clamp(2rem, 4.4vw, 4.4rem); + --text-landing-service-title: clamp(1.6rem, 2.4vw, 2.4rem); + --text-landing-service-title-lg: clamp(1.6rem, 2.6vw, 2.6rem); + --text-landing-service-sub: clamp(1.5rem, 2vw, 2rem); + + /* ─── 缓动(见 docs/features/design-tokens.md)。standard 是绝大多数过渡;spring 给 + 滑块 / 按压回弹;sheet 给抽屉;pop 给弹层出现。组件用 ease-standard 等,不写 bezier */ + --ease-standard: cubic-bezier(0.4, 0, 0.2, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --ease-spring-soft: cubic-bezier(0.34, 1.2, 0.64, 1); + --ease-sheet: cubic-bezier(0.32, 0.72, 0, 1); + --ease-pop: cubic-bezier(0.22, 1, 0.36, 1); +} + +/* 过渡时长四档 + 两档特例。Tailwind 没有时长的主题命名空间,用 @utility 定义, + 写法与内置 duration-N 完全一致(同时设 --tw-duration,tw-animate 的 animate-in 也读它)。 */ +:root { + --duration-micro: 150ms; + --duration-base: 200ms; + --duration-layout: 300ms; + --duration-slow: 500ms; + --duration-hero: 700ms; + --duration-cinematic: 1000ms; +} + +/* 横向滚动容器会连带裁掉纵向溢出(overflow-x:auto 时 overflow-y 不能是 visible), + 里面胶囊、分段滑块的阴影与描边会被切成直边。四周向外借 12px 再用内边距还回来, + 布局位置不变,阴影有地方画。放胶囊的横向滚动容器一律加它 */ +@utility scroll-bleed { + margin: -0.75rem; + padding: 0.75rem; +} + +@utility duration-micro { --tw-duration: var(--duration-micro); transition-duration: var(--duration-micro); } +@utility duration-base { --tw-duration: var(--duration-base); transition-duration: var(--duration-base); } +@utility duration-layout { --tw-duration: var(--duration-layout); transition-duration: var(--duration-layout); } +@utility duration-slow { --tw-duration: var(--duration-slow); transition-duration: var(--duration-slow); } +@utility duration-hero { --tw-duration: var(--duration-hero); transition-duration: var(--duration-hero); } +@utility duration-cinematic { --tw-duration: var(--duration-cinematic); transition-duration: var(--duration-cinematic); } + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + /* 角色型圆角:tight 给徽章 / 骨架屏这类小件(原来的裸 rounded),drawer 给手机端底部抽屉顶角, + slab 给门户大卡。控件 md、输入与小按钮 lg、卡片 xl、面板 2xl、抽屉与大卡 3xl、药丸 full, + 那些沿用 Tailwind 名,值随 --radius 一起缩放。 */ + --radius-tight: 0.25rem; + --radius-drawer: 28px; + --radius-slab: 2.5rem; + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + /* ─── 主题 token(见 docs/features/design-tokens.md)─────────────────── + 组件只许用这些语义名,不许直接写 bone / ink / gold 原色、十六进制、rgba 或 + white/black 透明度。值在下方各壳的 token 块里定义,主题包只覆盖值。 */ + /* 文字层级(随日夜翻转) */ + --color-content-strong: var(--content-strong); + --color-content: var(--content); + --color-content-secondary: var(--content-secondary); + --color-content-tertiary: var(--content-tertiary); + --color-content-muted: var(--content-muted); + --color-content-faint: var(--content-faint); + /* 面(随日夜翻转):页面底、普通面、抬起的卡、更高一层 */ + --color-canvas: var(--canvas); + --color-surface: var(--surface); + --color-surface-raised: var(--surface-raised); + --color-surface-high: var(--surface-high); + /* 品牌色族(门户 = 墨水蓝族,流媒体 = 金,动漫版 = 品牌色) */ + --color-brand-soft: var(--brand-soft); + --color-brand-light: var(--brand-light); + --color-brand: var(--brand); + --color-brand-strong: var(--brand-strong); + --color-brand-deep: var(--brand-deep); + /* 透明"洗"层的底色:hover / 按压 / 分隔线等半透明层写成 `bg-wash/[0.07]`,透明度留在 + 调用处,底色随日夜翻转(暗底白洗,亮底黑洗)。 */ + --color-wash: var(--wash); + /* 压在视频 / 海报上的东西:不随日夜翻转,画面永远是暗的 */ + --color-on-media-strong: var(--on-media-strong); + --color-on-media: var(--on-media); + --color-on-media-secondary: var(--on-media-secondary); + --color-on-media-tertiary: var(--on-media-tertiary); + --color-on-media-muted: var(--on-media-muted); + --color-on-media-faint: var(--on-media-faint); + --color-on-media-wash: var(--on-media-wash); + /* 压暗画面的遮罩底色(黑),透明度留在调用处 */ + --color-scrim: var(--scrim); +} + +/* ─────────────────────────── Semantic tokens ─────────────────────────── */ + +:root { + --radius: 0.85rem; + + color-scheme: light; + + /* Core surfaces */ + --background: oklch(0.985 0.002 260); + --foreground: oklch(0.18 0.006 260); + --card: oklch(1 0 0 / 90%); + --card-solid: oklch(1 0 0); + --card-foreground: var(--foreground); + --popover: oklch(1 0 0); + --popover-foreground: var(--foreground); + + /* Actions and states */ + /* 无彩主色(2026-09-26 拍板):日间是墨,夜间是白,和落地页的白色胶囊按钮同一个语言; + 彩色只来自海报、剧照等内容,状态提示用下面的 success / warning / destructive */ + --primary: oklch(0.2 0.006 260); + --primary-foreground: oklch(0.99 0.002 260); + --secondary: oklch(0.95 0.003 260); + --secondary-foreground: oklch(0.26 0.006 260); + --muted: oklch(0.955 0.003 260); + --muted-foreground: oklch(0.47 0.008 260); + --accent: oklch(0.935 0.004 260); + --accent-foreground: oklch(0.2 0.006 260); + --destructive: oklch(0.54 0.2 25); + --destructive-foreground: oklch(0.99 0.005 25); + /* 状态色:只用于状态提示,饱和度压低 */ + --success: oklch(0.52 0.1 155); + --success-foreground: oklch(0.99 0.002 260); + --warning: oklch(0.6 0.12 70); + --warning-foreground: oklch(0.99 0.002 260); + + /* Controls */ + --border: oklch(0.88 0.003 260 / 85%); + --input: oklch(0.93 0.003 260); + --ring: var(--primary); + + /* App shell */ + --sidebar: oklch(0.975 0.002 260); + --sidebar-foreground: var(--foreground); + --sidebar-primary: var(--primary); + --sidebar-primary-foreground: var(--primary-foreground); + --sidebar-accent: oklch(0.93 0.003 260); + --sidebar-accent-foreground: var(--foreground); + --sidebar-border: var(--border); + --sidebar-ring: var(--primary); + + --shell-border: color-mix(in oklch, var(--border) 86%, transparent); + --shell-hover: color-mix(in oklch, var(--accent) 68%, transparent); + --shell-subtle: color-mix(in oklch, var(--muted) 72%, transparent); + + /* 主题 token:浅色门户。文字层级从 foreground 往淡推,面从 background 往上抬 */ + --content-strong: oklch(0.13 0.006 260); + --content: var(--foreground); + --content-secondary: oklch(0.32 0.006 260); + --content-tertiary: var(--muted-foreground); + --content-muted: oklch(0.58 0.006 260); + --content-faint: oklch(0.7 0.005 260); + --canvas: var(--background); + --surface: oklch(0.98 0.002 260); + --surface-raised: var(--card-solid); + --surface-high: oklch(0.955 0.003 260); + --brand-soft: oklch(0.93 0.003 260); + --brand-light: oklch(0.45 0.006 260); + --brand: var(--primary); + --brand-strong: oklch(0.1 0.004 260); + --brand-deep: oklch(0.05 0.002 260); + --wash: #000000; + /* on-media 不分日夜:与流媒体壳的暗色文字层级同值(bone 阶) */ + --on-media-strong: #fbfaf7; + --on-media: #f1eee7; + --on-media-secondary: #dcd5c8; + --on-media-tertiary: #bdb4a5; + --on-media-muted: #918879; + --on-media-faint: #6f675c; + --on-media-wash: #ffffff; + --scrim: #000000; + + /* ─── 材质参数(见 docs/features/interface-materials.md)──────────────────── + 一个材质 = 一份固定配方(底色 / 光泽 / 模糊 / 边 / 内高光 / 内暗线 / 外投影 / 辉光)+ 这里的参数。 + class 只引用参数;浅色 / 深色 / 流媒体壳的差别只改参数,不再各自重写声明。 + 值与参数化前逐字相同。`--pg-surface-alpha` 是唯一给组件用的公开旋钮。 */ + /* surface:贴边控制条 / header */ + --mat-surface-rgb: 12, 13, 17; + --mat-surface-blur: blur(24px) saturate(160%); + --mat-surface-hairline: rgba(255, 255, 255, 0.08); + --mat-surface-highlight: rgba(255, 255, 255, 0.07); + --mat-surface-solid: rgba(12, 13, 17, 0.92); + /* pill:药丸 HUD */ + --mat-pill-bg: rgba(16, 17, 23, 0.68); + --mat-pill-sheen: linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.04) 40%, rgba(255, 255, 255, 0) 70%); + --mat-pill-blur: blur(16px) saturate(160%); + --mat-pill-border: rgba(255, 255, 255, 0.16); + --mat-pill-highlight: rgba(255, 255, 255, 0.18); + --mat-pill-shade: rgba(0, 0, 0, 0.25); + --mat-pill-shadow: 0 2px 6px -2px rgba(0, 0, 0, 0.2), 0 12px 32px -14px rgba(0, 0, 0, 0.42); + --mat-pill-solid: rgba(16, 17, 23, 0.88); + /* slab:门户外层内容卡(浅色值;深色在 .dark 块) */ + --mat-slab-bg: linear-gradient(155deg, rgba(255, 255, 255, 0.74) 0%, rgba(255, 255, 255, 0.58) 40%, rgba(255, 255, 255, 0.52) 100%); + --mat-slab-border: rgba(255, 255, 255, 0.7); + --mat-slab-blur: blur(20px) saturate(160%); + --mat-slab-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85), 0 0 0 1px rgba(20, 22, 28, 0.04), 0 1px 2px rgba(20, 22, 28, 0.05), 0 12px 28px -18px rgba(20, 22, 28, 0.16); + --mat-slab-reflection: linear-gradient(105deg, transparent 0%, rgba(255, 255, 255, 0.14) 36%, rgba(255, 255, 255, 0.3) 44%, rgba(255, 255, 255, 0.07) 51%, transparent 62%); + --mat-spotlight: rgba(255, 255, 255, 0.6); + /* 卡片投影:日间轻,夜间沿用 @theme 里的深投影 */ + --shadow-card: 0 1px 2px rgba(20, 22, 28, 0.06), 0 14px 32px -18px rgba(20, 22, 28, 0.28); + --shadow-card-hover: 0 1px 2px rgba(20, 22, 28, 0.08), 0 20px 40px -20px rgba(20, 22, 28, 0.34); + --mat-leak-opacity: 0.3; + --mat-leak-blend: multiply; + /* chrome:门户侧栏 / 顶栏 */ + --mat-chrome-bg: rgba(255, 255, 255, 0.52); + --mat-chrome-blur: blur(24px) saturate(160%); + --mat-chrome-border: color-mix(in oklch, var(--primary) 11%, transparent); + --mat-chrome-shadow: 0 8px 24px -20px rgba(20, 22, 28, 0.25); + --mat-chrome-side-shadow: 8px 0 24px -20px rgba(20, 22, 28, 0.25); + --mat-chrome-solid: rgba(255, 255, 255, 0.95); + /* float:门户小型悬浮面板 */ + --mat-float-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.9) 0%, rgba(255, 255, 255, 0.82) 100%); + --mat-float-blur: blur(28px) saturate(170%); + --mat-float-border: rgba(255, 255, 255, 0.75); + --mat-float-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 0 0 1px rgba(20, 22, 28, 0.05), 0 2px 6px -2px rgba(20, 22, 28, 0.08), 0 20px 40px -20px rgba(20, 22, 28, 0.2); + --mat-float-solid: rgb(251, 251, 252); + /* 胶囊按钮:主操作是有填充的薄玻璃,次操作只描边。日夜对称——夜间白色薄玻璃配白字, + 日间墨色薄玻璃配深字;不做实心黑 / 实心白 */ + --mat-pill-primary-bg: linear-gradient(180deg, rgba(20, 22, 28, 0.02), rgba(20, 22, 28, 0.05)); + --mat-pill-primary-bg-hover: linear-gradient(180deg, rgba(20, 22, 28, 0.04), rgba(20, 22, 28, 0.08)); + --mat-pill-primary-fg: var(--content-strong); + --mat-pill-primary-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7), 0 1px 2px rgba(20, 22, 28, 0.06); + /* 主按钮的棱镜环(方案 A):亮度、彩度、环与内光的强度 */ + --prism-l: 0.66; + --prism-c: 0.15; + --prism-ring: 0.9; + --prism-glow: 0.16; + /* 选中态(分页当前页、角标、选中的选项):静态半透明 + 稍深描边,不转动、不实心 */ + --mat-pill-selected-bg: rgba(20, 22, 28, 0.07); + --mat-pill-selected-border: rgba(20, 22, 28, 0.28); + --mat-pill-secondary-bg: rgba(20, 22, 28, 0.03); + --mat-pill-secondary-bg-hover: rgba(20, 22, 28, 0.07); + --mat-pill-secondary-border: rgba(20, 22, 28, 0.12); + /* 分段控件的滑块(侧栏当前项、SegmentedTabs):日间是托起的白片,夜间是薄白玻璃 */ + --mat-seg-thumb-bg: rgba(255, 255, 255, 0.8); + --mat-seg-thumb-border: rgba(20, 22, 28, 0.06); + --mat-seg-thumb-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.95), 0 3px 12px -3px rgba(20, 22, 28, 0.18); + /* notice:更新提示胶囊 */ + --mat-notice-fg: var(--foreground); + --mat-notice-bg: rgba(255, 255, 255, 0.78); + --mat-notice-blur: blur(24px) saturate(160%); + --mat-notice-border: rgba(255, 255, 255, 0.85); + --mat-notice-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.95), 0 0 0 1px color-mix(in oklch, var(--primary) 12%, transparent), 0 14px 36px -14px rgba(20, 22, 28, 0.35), 0 3px 10px -4px rgba(20, 22, 28, 0.12); + --mat-notice-sub-fg: var(--muted-foreground); + --mat-notice-icon-fg: var(--primary); + --mat-notice-icon-bg: color-mix(in oklch, var(--primary) 12%, transparent); + --mat-notice-ring: 0 0 0 0 color-mix(in oklch, var(--primary) 45%, transparent); + --mat-notice-cta-fg: var(--primary-foreground); + --mat-notice-cta-bg: var(--primary); + --mat-notice-cta-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 6px 16px -8px color-mix(in oklch, var(--primary) 70%, transparent); + --mat-notice-close-fg: var(--muted-foreground); + --mat-notice-close-hover-fg: var(--foreground); + --mat-notice-close-hover-bg: rgba(0, 0, 0, 0.06); + + /* Scrollbars */ + --scrollbar-thumb: color-mix( + in oklch, + var(--muted-foreground) 26%, + transparent + ); + --scrollbar-thumb-hover: color-mix( + in oklch, + var(--muted-foreground) 42%, + transparent + ); + --scrollbar-thumb-active: color-mix( + in oklch, + var(--muted-foreground) 58%, + transparent + ); +} + +.dark, +:root:has([data-force-dark]) { + color-scheme: dark; + + --background: oklch(0.15 0.004 265); + --foreground: oklch(0.96 0.003 260); + + --card: oklch(0.21 0.004 265 / 88%); + --card-solid: oklch(0.21 0.004 265); + --card-foreground: var(--foreground); + --popover: oklch(0.2 0.004 265); + --popover-foreground: var(--foreground); + + /* 深色档主色是白(见 :root 处注释) */ + --primary: oklch(0.97 0.002 260); + --primary-foreground: oklch(0.16 0.004 265); + + --secondary: oklch(0.27 0.004 265); + --secondary-foreground: var(--foreground); + + --muted: oklch(0.26 0.004 265); + --muted-foreground: oklch(0.7 0.004 260); + + --accent: oklch(0.29 0.004 265); + --accent-foreground: var(--foreground); + + --destructive: oklch(0.7 0.15 25); + --destructive-foreground: oklch(0.16 0.004 265); + --success: oklch(0.78 0.1 158); + --success-foreground: oklch(0.2 0.03 158); + --warning: oklch(0.82 0.1 78); + --warning-foreground: oklch(0.22 0.03 78); + --border: oklch(0.36 0.004 265 / 70%); + --input: oklch(0.3 0.004 265); + --ring: var(--primary); + + --sidebar: oklch(0.17 0.004 265); + --sidebar-foreground: var(--foreground); + --sidebar-primary: var(--primary); + --sidebar-primary-foreground: var(--primary-foreground); + --sidebar-accent: oklch(0.27 0.004 265); + --sidebar-accent-foreground: var(--foreground); + --sidebar-border: var(--border); + --sidebar-ring: var(--primary); + + --shell-border: rgba(255, 255, 255, 0.08); + --shell-hover: rgba(255, 255, 255, 0.08); + --shell-subtle: rgba(255, 255, 255, 0.05); + + /* 主题 token:深色门户 */ + --content-strong: oklch(0.99 0.002 260); + --content: var(--foreground); + --content-secondary: oklch(0.85 0.004 260); + --content-tertiary: var(--muted-foreground); + --content-muted: oklch(0.58 0.004 260); + --content-faint: oklch(0.46 0.004 260); + --canvas: var(--background); + --surface: oklch(0.18 0.004 265); + --surface-raised: var(--card-solid); + --surface-high: oklch(0.26 0.004 265); + --brand-soft: oklch(0.27 0.004 265); + --brand-light: oklch(1 0 0); + --brand: var(--primary); + --brand-strong: oklch(0.88 0.003 260); + --brand-deep: oklch(0.78 0.003 260); + --wash: #ffffff; + + /* 材质参数:深色门户 */ + --mat-slab-bg: linear-gradient(155deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.04) 30%, rgba(255, 255, 255, 0.03) 75%, rgba(255, 255, 255, 0.04) 100%); + --mat-slab-border: rgba(255, 255, 255, 0.1); + --mat-slab-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12), inset 0 -1px 0 rgba(255, 255, 255, 0.03), 0 1px 2px rgba(0, 0, 0, 0.2), 0 12px 28px -18px rgba(0, 0, 0, 0.4); + --mat-slab-reflection: linear-gradient(105deg, transparent 0%, rgba(255, 255, 255, 0.006) 36%, rgba(255, 255, 255, 0.013) 44%, rgba(255, 255, 255, 0.003) 51%, transparent 62%); + --mat-spotlight: rgba(255, 255, 255, 0.07); + --shadow-card: 0 18px 40px -24px rgba(0, 0, 0, 0.9); + --shadow-card-hover: 0 26px 54px -24px rgba(0, 0, 0, 1); + --mat-leak-opacity: 0.3; + --mat-leak-blend: normal; + --mat-chrome-bg: color-mix(in oklch, var(--card-solid) 48%, transparent); + --mat-chrome-border: color-mix(in oklch, var(--primary) 13%, transparent); + --mat-chrome-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); + --mat-chrome-side-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.04); + --mat-chrome-solid: rgba(12, 13, 17, 0.92); + --mat-float-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.035) 32%, rgba(255, 255, 255, 0) 62%), rgba(20, 21, 25, 0.9); + --mat-float-solid: rgb(22, 23, 27); + --mat-float-blur: blur(36px) saturate(180%); + --mat-float-border: rgba(255, 255, 255, 0.14); + /* 胶囊按钮:夜间主操作是白色玻璃 */ + --mat-pill-primary-bg: rgba(255, 255, 255, 0.05); + --mat-pill-primary-bg-hover: rgba(255, 255, 255, 0.09); + --mat-pill-primary-fg: #ffffff; + --mat-pill-primary-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 1px 2px rgba(0, 0, 0, 0.2); + --prism-l: 0.82; + --prism-c: 0.13; + --prism-ring: 0.85; + --prism-glow: 0.22; + --mat-pill-selected-bg: rgba(255, 255, 255, 0.1); + --mat-pill-selected-border: rgba(255, 255, 255, 0.32); + --mat-pill-secondary-bg: rgba(255, 255, 255, 0.04); + --mat-pill-secondary-bg-hover: rgba(255, 255, 255, 0.1); + --mat-pill-secondary-border: rgba(255, 255, 255, 0.15); + --mat-seg-thumb-bg: rgba(255, 255, 255, 0.08); + --mat-seg-thumb-border: rgba(255, 255, 255, 0.15); + --mat-seg-thumb-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 4px 18px -6px rgba(0, 0, 0, 0.4); + --mat-float-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16), inset 0 -1px 0 rgba(255, 255, 255, 0.03), 0 2px 6px -2px rgba(0, 0, 0, 0.22), 0 18px 40px -20px rgba(0, 0, 0, 0.4); + --mat-notice-bg: rgba(255, 255, 255, 0.08); + --mat-notice-border: rgba(255, 255, 255, 0.14); + --mat-notice-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16), inset 0 -1px 0 rgba(0, 0, 0, 0.25), 0 16px 40px -16px rgba(0, 0, 0, 0.55); + --mat-notice-close-hover-bg: rgba(255, 255, 255, 0.08); +} + +/* ───────────────────────────── Base styles ───────────────────────────── */ + +@layer base { + * { + @apply border-border outline-ring/50; + } + + html, + body { + min-height: 100%; + } + + /* Light app shell */ + body { + @apply bg-background text-foreground antialiased; + background-image: linear-gradient(180deg, oklch(1 0 0), oklch(0.97 0.002 260)); + } + + /* Dark app shell, including fixed-dark public and stream routes */ + .dark body, + :root:has([data-force-dark]) body { + background-image: linear-gradient(180deg, oklch(0.16 0.004 265), oklch(0.12 0.004 265)); + } + + /* 流媒体壳:html / body 整块铺成壳的 canvas,不用上面门户的渐变底。 + 桌面上永远看不到 body,但手机上地址栏收起、橡皮筋回弹、内容不够一屏、崩溃页这些 + "壳没铺满"的时刻会把 body 露出来。夜间 html 也一并铺:iOS 回弹露出来的是 html 不是 body。 */ + :root.dark:has([data-stream-shell]), + :root:has([data-stream-shell="play"]) { + background: var(--canvas); + } + + :root.dark:has([data-stream-shell]) body, + :root:has([data-stream-shell="play"]) body, + :root:not(.dark):has([data-stream-shell="page"]) body { + background-image: none; + background-color: var(--canvas); + } + + + /* 触屏点按时不要系统自带的半透明高亮(Android 是一层蓝灰),按压反馈由 + 卡片 / 按钮自己的 active:scale 给。该属性可继承,挂在壳上即可。 */ + [data-stream-shell] { + -webkit-tap-highlight-color: transparent; + } + + /* 流媒体手机端有 3.5rem 的底部 Tab Bar:全局右下角浮层(更新提示)按这个 + 变量再垫高,免得压在 Tab Bar 上。播放页不渲染 Tab Bar,不设。 */ + @media (max-width: 767px) { + :root:has([data-stream-shell="page"]) { + --uhd-bottom-dock: 3.5rem; + } + } + + a { + @apply transition-colors; + } +} + +/* ────────────────────────────── Utilities ────────────────────────────── */ + +@layer utilities { + /* Typography */ + .font-display { + font-family: var(--font-display); + letter-spacing: 0.01em; + } + + /* Scrolling */ + .no-scrollbar { + scrollbar-width: none; + } + + .no-scrollbar::-webkit-scrollbar { + display: none; + } + + :root:has(.app-scrollbars), + :root:has(.app-scrollbars) *:not(.no-scrollbar):not(.scrollbar-none) { + scrollbar-color: var(--scrollbar-thumb) transparent; + scrollbar-width: thin; + } + + :root:has(.app-scrollbars) + *:not(.no-scrollbar):not(.scrollbar-none):hover { + scrollbar-color: var(--scrollbar-thumb-hover) transparent; + } + + :root:has(.app-scrollbars)::-webkit-scrollbar, + :root:has(.app-scrollbars) + *:not(.no-scrollbar):not(.scrollbar-none)::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + :root:has(.app-scrollbars)::-webkit-scrollbar-track, + :root:has(.app-scrollbars) + *:not(.no-scrollbar):not(.scrollbar-none)::-webkit-scrollbar-track { + background: transparent; + } + + :root:has(.app-scrollbars)::-webkit-scrollbar-thumb, + :root:has(.app-scrollbars) + *:not(.no-scrollbar):not(.scrollbar-none)::-webkit-scrollbar-thumb { + min-height: 36px; + border: 1px solid transparent; + border-radius: 999px; + background-color: var(--scrollbar-thumb); + background-clip: padding-box; + } + + :root:has(.app-scrollbars) + *:not(.no-scrollbar):not(.scrollbar-none):hover::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb-hover); + } + + :root:has(.app-scrollbars)::-webkit-scrollbar-thumb:hover, + :root:has(.app-scrollbars) + *:not(.no-scrollbar):not(.scrollbar-none)::-webkit-scrollbar-thumb:hover { + background-color: var(--scrollbar-thumb-active); + } + + /* Player controls */ + .volume-slider { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 4px; + border-radius: 999px; + outline: none; + cursor: pointer; + background: linear-gradient( + to right, + rgba(255, 255, 255, 0.9) 0%, + rgba(255, 255, 255, 0.9) var(--val, 100%), + rgba(255, 255, 255, 0.12) var(--val, 100%), + rgba(255, 255, 255, 0.12) 100% + ); + } + + .volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 13px; + height: 13px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45); + cursor: pointer; + border: 0; + } + + .volume-slider::-moz-range-thumb { + width: 13px; + height: 13px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45); + cursor: pointer; + border: 0; + } + + /* App shell backgrounds */ + /* 门户 shell 背景光斑(user/unsubscribe 共用)。2026-09-26 主色改为无彩后,光斑也去掉色相, + 只留明暗层次:日间顶部一层柔和的白与一抹极淡的灰,夜间顶部一抹微光。 + 玻璃卡背后要有明暗变化才读得出是玻璃,不能是一整块平涂。 */ + /* 门户页背景:无彩的明暗光斑铺满整页;棱镜漏光是单独一层(.prism-leak)。 + 卡片是磨砂面,背景只需给它柔和的明暗与淡彩,不需要纹理 */ + .bg-page-glow { + background: + radial-gradient(ellipse 60% 50% at 12% 4%, rgba(20, 22, 28, 0.09) 0%, transparent 70%), + radial-gradient(ellipse 45% 40% at 88% 18%, rgba(255, 255, 255, 0.9) 0%, transparent 70%), + radial-gradient(ellipse 50% 45% at 70% 62%, rgba(20, 22, 28, 0.07) 0%, transparent 70%), + radial-gradient(ellipse 45% 40% at 20% 88%, rgba(20, 22, 28, 0.06) 0%, transparent 70%), + linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(246, 246, 248, 0.72)); + } + + /* 棱镜漏光(全站,落地页与播放页除外):左上角斜进来一束已分好色的光,大幅虚化、只占 + 一角,与落地页的棱镜叙事呼应;卡片压在上面时透出淡彩。日间用 multiply 叠在浅底上, + 避免发灰;浓度见 --mat-leak-opacity。由 渲染,固定在视口上 */ + .prism-leak { + position: fixed; + inset: -10%; + pointer-events: none; + background: linear-gradient( + 118deg, + transparent 6%, + oklch(0.75 0.15 25) 13%, + oklch(0.8 0.14 65) 17%, + oklch(0.87 0.14 100) 21%, + oklch(0.8 0.14 150) 25%, + oklch(0.78 0.12 205) 29%, + oklch(0.68 0.15 265) 33%, + oklch(0.62 0.16 305) 37%, + transparent 44% + ); + filter: blur(48px); + opacity: var(--mat-leak-opacity); + mix-blend-mode: var(--mat-leak-blend); + -webkit-mask-image: radial-gradient(ellipse 75% 70% at 8% 0%, #000 0%, rgba(0, 0, 0, 0.5) 45%, transparent 78%); + mask-image: radial-gradient(ellipse 75% 70% at 8% 0%, #000 0%, rgba(0, 0, 0, 0.5) 45%, transparent 78%); + } + + .dark .bg-page-glow, + [data-force-dark] .bg-page-glow { + background: + radial-gradient(ellipse 60% 50% at 12% 4%, rgba(255, 255, 255, 0.075) 0%, transparent 70%), + radial-gradient(ellipse 45% 40% at 88% 22%, rgba(255, 255, 255, 0.05) 0%, transparent 70%), + radial-gradient(ellipse 50% 45% at 68% 64%, rgba(255, 255, 255, 0.055) 0%, transparent 70%), + radial-gradient(ellipse 45% 40% at 18% 90%, rgba(255, 255, 255, 0.04) 0%, transparent 70%), + linear-gradient(180deg, rgba(16, 17, 20, 0.76), rgba(17, 18, 21, 0.86)); + } +} + +/* ───────────────────────────── Components ───────────────────────────── */ + +@layer components { + /* Player subtitles */ + [data-subtitles-hidden] .ASS-box { + display: none !important; + } + + .ASS-box { + transition: transform 200ms ease; + } + + /* 抬多少由 Player 按字幕层与控件常驻面的实际重叠量写进 --uhd-sub-lift(负值 px); + 量不到时退回固定 5.5rem(约等于控件常驻面高度)。 */ + [data-controls-visible] .ASS-box { + transform: translate3d(0, var(--uhd-sub-lift, -5.5rem), 0); + } + + .ASS-box .ASS-dialogue [data-text] { + paint-order: stroke fill; + -webkit-text-stroke: 1.5px rgba(0, 0, 0, 0.95); + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6); + } + + /* 字幕字号档位:Player 在根节点上写 --uhd-sub-scale(见 subtitleScale.ts), + 在 ass.js 自己算出的字号上再乘一个倍率。ass.js 的样式表是不分层的 + (#ASS-global-style),这里在 @layer 里按普通优先级会输给它,所以三条都加 + !important —— 分层内的 !important 高于未分层的普通声明。只动字号 / 行高 / + 字距,定位与描边仍由 ass.js 的 --ass-scale 控制。 */ + .ASS-box .ASS-dialogue [data-text] { + font-size: calc(var(--ass-scale) * var(--ass-real-fs) * var(--uhd-sub-scale, 1) * 1px) !important; + line-height: calc(var(--ass-scale) * var(--ass-tag-fs) * var(--uhd-sub-scale, 1) * 1px) !important; + letter-spacing: calc(var(--ass-scale) * var(--ass-tag-fsp) * var(--uhd-sub-scale, 1) * 1px) !important; + } + + /* ─── 玻璃三档 tokens(全站 dark chrome 统一配方)────────────────── + 配方来源是 btn-glass 的语言 + Glass UI 的思路:① 底色不透明度压到 + 0.55~0.70,blur 真正透出后面的内容才有玻璃感(旧 .glass/.glass-panel + 的 0.86+ 基本是实心板,已删除并全量迁移);② inset 顶部高光,玻璃 + 上沿"接光"。边框与投影一并收进 class,组件侧不再各写各的。 + 使用范围:播放器全部浮层 + 媒体区 header/筛选栏/弹层/卡片徽章。 */ + /* 两族分治(2026-08-01 实测校准): + - 贴边 chrome(header/筛选栏/播放器底栏)= pg-surface,融入页面: + 只有 blur + 底色,不加泛光和接光线 —— 贴屏幕边缘的面没有"上方 + 光源",加亮线会把它从页面上切下来,恰好破坏融为一体的感觉。 + - 悬浮物(弹层/对话框)= pg-float,画面中央的药丸 HUD = pg-pill,材质发光: + 四周有留白,光学上浮在页面上方,泛光层 + 双内高光 + 亮边框成立。 + 暗底上"更透"读不出来,能读出来的是这些材质要素;底色反而要实, + 内容才好读。 */ + .pg-surface { + /* 常驻贴边大面:播放器底部控制条(0.6)。站点 header/筛选栏压在 + 高亮海报上,文字对比要求更高,用 [--pg-surface-alpha:0.7] 收紧 */ + background: rgba(var(--mat-surface-rgb), var(--pg-surface-alpha, 0.6)); + backdrop-filter: var(--mat-surface-blur); + -webkit-backdrop-filter: var(--mat-surface-blur); + border-color: var(--mat-surface-hairline); + box-shadow: inset 0 1px 0 var(--mat-surface-highlight); + } + + .pg-pill { + /* 画面中央短暂浮层:seek 进度环、播放/暂停 flash、toast、手势 HUD */ + background: + var(--mat-pill-sheen), + var(--mat-pill-bg); + backdrop-filter: var(--mat-pill-blur); + -webkit-backdrop-filter: var(--mat-pill-blur); + border: 1px solid var(--mat-pill-border); + box-shadow: + inset 0 1px 0 var(--mat-pill-highlight), + inset 0 -1px 0 var(--mat-pill-shade), + var(--mat-pill-shadow); + } + + + + /* ─── 更新提示胶囊(app/components/update-notice.tsx)────────────── + 三处材质各归各:浅色门户 = 白磨砂玻璃 + 蓝调投影(pg-slab 浅色配方的 + 简化版);深色门户 = 白透玻璃(侧栏药丸同款);流媒体壳 = 同深色配方, + 动作钮换金色 —— 那边的强调色是金不是蓝。 */ + .update-pill { + color: var(--mat-notice-fg); + background: var(--mat-notice-bg); + backdrop-filter: var(--mat-notice-blur); + -webkit-backdrop-filter: var(--mat-notice-blur); + border: 1px solid var(--mat-notice-border); + box-shadow: var(--mat-notice-shadow); + } + + .update-pill__sub { + color: var(--mat-notice-sub-fg); + } + + .update-pill__icon { + color: var(--mat-notice-icon-fg); + background: var(--mat-notice-icon-bg); + } + + .update-pill__icon--stale { + color: var(--warning); + background: color-mix(in oklch, var(--warning) 14%, transparent); + } + + /* 一圈慢呼吸的光环,提醒但不催 */ + .update-pill__ring { + box-shadow: var(--mat-notice-ring); + animation: update-pill-ring 2.4s ease-out infinite; + } + + .update-pill__cta { + color: var(--mat-notice-cta-fg); + background: var(--mat-notice-cta-bg); + box-shadow: var(--mat-notice-cta-shadow); + } + + .update-pill__close { + color: var(--mat-notice-close-fg); + } + + .update-pill__close:hover { + color: var(--mat-notice-close-hover-fg); + background: var(--mat-notice-close-hover-bg); + } + + + @media (prefers-reduced-motion: reduce) { + .update-pill__ring { + animation: none; + } + } + + /* 第四档:内容卡片,双主题(门户与 /speed 共用)。薄白/薄亮底 + 亮棱边 + + 顶部内高光 + 斜反光,背景模糊由 --mat-slab-blur 给;/speed 的卡片贴着 + 高频重绘的实时区,在 .spd-bg 里把它关掉,零合成开销优先。 */ + .pg-slab { + position: relative; + /* 建立层叠上下文,把 ::before 的 z-index:-1 关在自己内部 */ + isolation: isolate; + /* 磨砂面,不追求透明:内容卡以好读为先,背景模糊只让漏光与明暗柔和地渗进来。 + 不做折射——背后没有细节可弯折,Chromium 专属的 SVG 折射也不值得 */ + background: var(--mat-slab-bg); + backdrop-filter: var(--mat-slab-blur); + -webkit-backdrop-filter: var(--mat-slab-blur); + /* 立体感靠亮棱边与顶部内高光,不靠投影:投影只有贴边一层 + 很淡的环境一层, + 越透的东西影子越淡,重投影会让玻璃读成实心板 */ + border: 1px solid var(--mat-slab-border); + box-shadow: var(--mat-slab-shadow); + } + + .pg-slab::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + pointer-events: none; + /* 斜掠反光,收窄且不对称 —— 对称高光读成"渐变背景"而不是"反射"。 + 浅色下卡片本身大面积偏白,反光峰值可以略高;作用仍是"察觉有一层 + 膜",能被明确看成光带就过了 */ + background: var(--mat-slab-reflection); + } + + + /* 贴边 chrome 的双主题版(/speed 页头)。浅色:白磨砂 + 蓝调底缘投影; + 深色:等同 pg-surface [--pg-surface-alpha:0.7] 的配方。只设边框颜色, + 宽度由 border-b utility 给。 */ + /* 2026-08-02 统一校准:① 暗色底从硬编码墨黑(rgba 12,13,17,流媒体 + 继承)改为 token 混色 —— 门户暗色是 271° 蓝紫调,chrome 必须同色相 + 否则读成另一种材质;② 边框掺 primary,与卡片"蓝棱线"同语言; + ③ 竖直侧栏用 .pg-chrome-side 把投影转向右侧(向下投影是横栏的光学)。 */ + .pg-chrome { + background: var(--mat-chrome-bg); + backdrop-filter: var(--mat-chrome-blur); + -webkit-backdrop-filter: var(--mat-chrome-blur); + border-color: var(--mat-chrome-border); + box-shadow: var(--mat-chrome-shadow); + } + + .pg-chrome-side { + box-shadow: var(--mat-chrome-side-shadow); + } + + + /* 全站唯一的悬浮层材质(2026-09-26 起):下拉菜单、弹出框、下拉选择、对话框、抽屉, + 门户与流媒体、播放器共用;日夜跟随,播放页恒为夜间。圆角分两档:菜单类 rounded-2xl, + 对话框 rounded-3xl。悬浮物打开时是静态的,可以放心用 backdrop-filter。 */ + .pg-float { + background: var(--mat-float-bg); + backdrop-filter: var(--mat-float-blur); + -webkit-backdrop-filter: var(--mat-float-blur); + border: 1px solid var(--mat-float-border); + box-shadow: var(--mat-float-shadow); + } + + /* 嵌在另一层磨砂里的菜单(页头、筛选栏、卡片内的下拉):外层磨砂是 backdrop 根, + 里面的菜单糊不到页面,半透明底会透出没糊过的内容 —— 这类一律用近实底(动漫站同一做法)。 + 挂在 body 上的对话框、shadcn 弹出框不需要 */ + .pg-float-solid { + --mat-float-bg: var(--mat-float-solid); + } + + + /* /speed 页面背景:玻璃要"有东西可透"才成立,这几团光斑就是卡片背后的内容。 + 纯静态渐变、零重绘成本。2026-09-26 主色改为无彩后光斑也去掉色相,只留明暗: + 日间是几团极淡的墨,夜间是几团微光。强度照原来校准过的档位,玻璃仍读得出来。 */ + .spd-bg { + /* 测速页的卡片贴着高频重绘的实时区,不开背景模糊 */ + --mat-slab-blur: none; + background-color: var(--background); + background-image: + radial-gradient( + 120% 80% at 12% -8%, + rgba(20, 22, 28, 0.08) 0%, + transparent 55% + ), + radial-gradient( + 90% 65% at 96% -6%, + rgba(20, 22, 28, 0.06) 0%, + transparent 52% + ), + radial-gradient( + 56% 44% at 38% 52%, + rgba(20, 22, 28, 0.04) 0%, + transparent 62% + ), + radial-gradient( + 80% 60% at 50% 112%, + rgba(20, 22, 28, 0.05) 0%, + transparent 60% + ); + } + + .dark .spd-bg, + [data-force-dark] .spd-bg { + background-image: + radial-gradient( + 120% 80% at 15% -10%, + rgba(255, 255, 255, 0.06) 0%, + transparent 55% + ), + radial-gradient( + 100% 70% at 95% 0%, + rgba(255, 255, 255, 0.04) 0%, + transparent 50% + ); + } + + + /* blur 不可用(老内核/省电模式)时透明底会让文字压在画面上没法读, + 退回近实心 */ + @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { + .pg-surface { + background: var(--mat-surface-solid); + } + + .pg-pill { + background: var(--mat-pill-solid); + } + + .pg-chrome { + background: var(--mat-chrome-solid); + } + + .pg-float { + background: var(--mat-float-solid); + } + + + .dark .pg-float, + [data-force-dark] .pg-float { + background: rgba(20, 21, 25, 0.96); + } + } + + /* 吸顶筛选条与顶栏之间的接缝:一条无彩细线,两端淡出,随日夜取 --wash */ + .chrome-seam-top { + position: relative; + } + + .chrome-seam-top::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 1px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in oklch, var(--wash) 8%, transparent) 12%, + color-mix(in oklch, var(--wash) 12%, transparent) 50%, + color-mix(in oklch, var(--wash) 8%, transparent) 88%, + transparent 100% + ); + pointer-events: none; + } + + /* Stream library layout */ + /* 手机三列:两列时单张海报 190px,信息密度只有主流客户端的一半;三列 + 约 120px,一屏能看 9 张,标题字号在 MovieCard 里随之收到 15px */ + .lib-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + column-gap: 0.5rem; + row-gap: 1.25rem; + } + + @media (min-width: 480px) { + .lib-grid { + column-gap: 0.75rem; + row-gap: 2rem; + } + } + + @media (min-width: 768px) { + .lib-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 1.25rem; + row-gap: 2.5rem; + } + } + + @media (min-width: 1024px) { + .lib-grid { + /* 定宽 220 + 左对齐会在平板/宽屏右侧留大片空白(横屏 iPad 只排 4 列、 + 右边空 ~200px)。改成弹性列:每列 200px 起、用 1fr 拉伸铺满整行, + 列数随宽度自适应,不再有右侧留白。 */ + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + } + } + + /* 玻璃族的动效成员:沿元素边框匀速流动的一段高亮弧。用法:宿主已有 + 圆角与边框,挂 .pg-beam 即可;弧色取 var(--primary) 自动双主题。 + 克制档:1.5px 细、弧长约 15% 周长、5.5s 一圈;动画的是注册过的 + 自定义属性(角度),仅小面积重绘。reduced-motion 时静止为常亮弧。 */ + .pg-beam { + position: relative; + } + + .pg-beam::after { + content: ""; + position: absolute; + inset: -2px; + padding: 1.5px; + border-radius: inherit; + pointer-events: none; + /* 弧的亮度与巡边周期可在使用处经 CSS 变量收弱(常驻元素建议弱档, + 一次性 CTA 用默认档) */ + background: conic-gradient( + from var(--pg-beam-angle), + transparent 0%, + transparent 74%, + color-mix(in oklch, var(--primary) var(--pg-beam-strength, 88%), transparent) + 87%, + transparent 100% + ); + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask-composite: exclude; + animation: pg-beam-spin var(--pg-beam-duration, 5.5s) linear infinite; + } + + @media (prefers-reduced-motion: reduce) { + .pg-beam::after { + animation: none; + } + } + + /* Segmented tabs */ + .tab-slider { + transition: + transform 380ms var(--ease-spring-soft), + width 380ms var(--ease-spring-soft), + top 380ms var(--ease-spring-soft), + height 380ms var(--ease-spring-soft); + will-change: transform, width, top, height; + } + + /* 分段控件滑块的材质,位置与尺寸由组件量测后写进 style */ + .seg-thumb { + background: var(--mat-seg-thumb-bg); + border: 1px solid var(--mat-seg-thumb-border); + box-shadow: var(--mat-seg-thumb-shadow); + backdrop-filter: blur(12px); + } + + .tab-color { + transition: color 320ms var(--ease-standard); + } + + /* Glass buttons */ + /* 门户胶囊按钮(2026-09-26):主操作 pill-primary 是带棱镜环的半透明胶囊,次操作 pill-secondary 是描边胶囊。 + 只管底、边、字色与投影,形状尺寸(rounded-full、内边距、字号)由调用处给。 + 日夜差别全在 --mat-pill-* 参数里;以后要换回实底或调透明度只改参数。 */ + .pill-secondary { + background: var(--mat-pill-secondary-bg); + border: 1px solid var(--mat-pill-secondary-border); + color: var(--content); + -webkit-backdrop-filter: blur(20px) saturate(160%); + backdrop-filter: blur(20px) saturate(160%); + transition: + background-color var(--duration-base) var(--ease-standard), + transform 220ms var(--ease-spring); + } + + .pill-secondary:hover { + background: var(--mat-pill-secondary-bg-hover); + } + + /* 主操作(方案 A,2026-09-26):和其他按钮一样是半透明胶囊,不做实心;靠沿边框缓慢流转的 + 一整圈棱镜光谱突出,内侧再透一层很淡的同色光。门户 .pill-primary 与流媒体 + .btn-glass-primary 共用;减少动态效果时停转,只留彩色边框 */ + .pill-primary, + .btn-glass.btn-glass-primary { + position: relative; + isolation: isolate; + overflow: visible; + background: var(--mat-pill-primary-bg); + border: 1px solid transparent; + color: var(--mat-pill-primary-fg); + box-shadow: var(--mat-pill-primary-shadow); + -webkit-backdrop-filter: blur(20px) saturate(160%); + backdrop-filter: blur(20px) saturate(160%); + transition: + background-color var(--duration-base) var(--ease-standard), + transform 220ms var(--ease-spring); + } + + .pill-primary:hover, + .btn-glass.btn-glass-primary:hover { + background: var(--mat-pill-primary-bg-hover); + } + + .pill-primary::before, + .btn-glass.btn-glass-primary::before, + .prism-ring::before { + content: ""; + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 1.5px; + pointer-events: none; + background: conic-gradient( + from var(--prism-angle), + oklch(var(--prism-l) var(--prism-c) 25), + oklch(var(--prism-l) var(--prism-c) 70), + oklch(calc(var(--prism-l) + 0.06) var(--prism-c) 105), + oklch(var(--prism-l) var(--prism-c) 155), + oklch(var(--prism-l) var(--prism-c) 205), + oklch(calc(var(--prism-l) - 0.04) var(--prism-c) 265), + oklch(calc(var(--prism-l) - 0.04) var(--prism-c) 305), + oklch(var(--prism-l) var(--prism-c) 25) + ); + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask-composite: exclude; + opacity: var(--prism-ring); + transition: opacity var(--duration-base) var(--ease-standard); + animation: prism-spin 6s linear infinite; + } + + .pill-primary::after, + .btn-glass.btn-glass-primary::after, + .prism-ring::after { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + pointer-events: none; + background: conic-gradient( + from var(--prism-angle), + oklch(0.8 0.14 25), + oklch(0.85 0.13 105), + oklch(0.8 0.13 205), + oklch(0.72 0.15 305), + oklch(0.8 0.14 25) + ); + filter: blur(10px); + opacity: var(--prism-glow); + transition: opacity var(--duration-base) var(--ease-standard); + animation: prism-spin 6s linear infinite; + } + + .pill-primary:hover::before, + .btn-glass.btn-glass-primary:hover::before, + .group:hover .prism-ring::before, + .prism-ring:hover::before { + opacity: 1; + } + + .pill-primary:hover::after, + .btn-glass.btn-glass-primary:hover::after, + .group:hover .prism-ring::after, + .prism-ring:hover::after { + opacity: calc(var(--prism-glow) * 1.6); + } + + .pill-primary:disabled::before, + .pill-primary:disabled::after, + .btn-glass.btn-glass-primary:disabled::before, + .btn-glass.btn-glass-primary:disabled::after { + animation: none; + } + + @media (prefers-reduced-motion: reduce) { + .pill-primary::before, + .pill-primary::after, + .btn-glass.btn-glass-primary::before, + .btn-glass.btn-glass-primary::after, + .prism-ring::before, + .prism-ring::after { + animation: none; + } + } + + /* 只要棱镜彩环与内侧淡光、不要胶囊底的宿主(如侧栏"前往流媒体"的播放键):彩环画在 + 宿主外沿,宿主本身不能 overflow:hidden;放在 .group 里时悬停整组也会点亮 */ + .prism-ring { + position: relative; + isolation: isolate; + } + + /* 侧栏"前往流媒体":点下去从播放键往外扩一圈七彩光再跳转(动漫站同一做法,光换成棱镜色) */ + .stream-entry__burst { + opacity: 0; + transform: scale(1); + background: conic-gradient( + oklch(0.8 0.14 25), + oklch(0.85 0.13 105), + oklch(0.8 0.13 205), + oklch(0.72 0.15 305), + oklch(0.8 0.14 25) + ); + } + + .stream-entry--leaving .stream-entry__play { + transform: scale(0.92); + } + + .stream-entry--leaving .stream-entry__burst { + animation: stream-entry-burst 360ms var(--ease-standard) both; + } + + /* 流光图标:图标形状做遮罩,里面填和主按钮同一套的旋转光谱(如账户菜单里"流媒体"的三角)。 + 形状由 --prism-icon 给(SVG data URL),尺寸由调用处给 */ + .prism-icon { + display: block; + flex-shrink: 0; + background: conic-gradient( + from var(--prism-angle), + oklch(var(--prism-l) var(--prism-c) 25), + oklch(var(--prism-l) var(--prism-c) 70), + oklch(calc(var(--prism-l) + 0.06) var(--prism-c) 105), + oklch(var(--prism-l) var(--prism-c) 155), + oklch(var(--prism-l) var(--prism-c) 205), + oklch(calc(var(--prism-l) - 0.04) var(--prism-c) 265), + oklch(calc(var(--prism-l) - 0.04) var(--prism-c) 305), + oklch(var(--prism-l) var(--prism-c) 25) + ); + -webkit-mask: var(--prism-icon) center / contain no-repeat; + mask: var(--prism-icon) center / contain no-repeat; + animation: prism-spin 6s linear infinite; + } + + @media (prefers-reduced-motion: reduce) { + .prism-icon { + animation: none; + } + } + + /* 选中态:分页当前页、角标、选中的选项等"不是按钮但要标出来"的地方。静态、不实心 */ + .pill-selected { + background: var(--mat-pill-selected-bg); + border: 1px solid var(--mat-pill-selected-border); + color: var(--content-strong); + } + + /* 按下回弹,与 .btn-glass 同一手感 */ + .pill-primary:active, + .pill-secondary:active { + transform: scale(0.97); + } + + /* 卡片聚光:鼠标所在处的卡面微微提亮(借鉴 Aceternity Card Spotlight)。 + 宿主挂 .pg-spotlight 并用 rippleMove 写入 --mx/--my;只在能悬停的设备上出现。 + 宿主的 ::after 归它用,亮度见 --mat-spotlight */ + .pg-spotlight { + isolation: isolate; + } + + /* z-index:-1 + isolate:光铺在卡面底色之上、文字之下,不洗淡内容 */ + .pg-spotlight::after { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + pointer-events: none; + background: radial-gradient(320px circle at var(--mx, 50%) var(--my, 50%), var(--mat-spotlight), transparent 70%); + opacity: 0; + transition: opacity var(--duration-layout) var(--ease-standard); + } + + @media (hover: hover) { + .pg-spotlight:hover::after { + opacity: 1; + } + } + + /* 流媒体按钮(2026-09-26 起与门户胶囊同一外观):.btn-glass = 次操作(描边胶囊), + .btn-glass-primary = 主操作(棱镜胶囊),.btn-glass-icon = 圆形图标钮。只保留各自的 + 尺寸与排版,底、边、字色、投影全部引用 --mat-pill-*,改外观只改那组参数 */ + .btn-glass { + @apply relative isolate inline-flex items-center gap-2 rounded-full px-6 py-2.5 text-[15px] font-medium tracking-tight cursor-pointer select-none overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-content-strong/40; + } + + .btn-glass, + .btn-glass-icon { + background: var(--mat-pill-secondary-bg); + border: 1px solid var(--mat-pill-secondary-border); + color: var(--content-strong); + -webkit-backdrop-filter: blur(20px) saturate(160%); + backdrop-filter: blur(20px) saturate(160%); + transition: + background-color var(--duration-base) var(--ease-standard), + transform 220ms var(--ease-spring); + } + + .btn-glass:hover, + .btn-glass-icon:hover { + background: var(--mat-pill-secondary-bg-hover); + } + + .btn-glass:active, + .btn-glass-icon:active { + transform: scale(0.97); + } + + .btn-glass-icon { + @apply relative isolate grid place-items-center rounded-full w-11 h-11 cursor-pointer overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-content-strong/40; + } + + /* Stream image overlays */ + /* 大图压暗层,取页面底色:夜间压暗、日间提亮(文字随主题深 / 浅) */ + .hero-vignette { + background: + radial-gradient( + circle at 70% 42%, + transparent 0%, + color-mix(in oklch, var(--canvas) 8%, transparent) 36%, + color-mix(in oklch, var(--canvas) 92%, transparent) 100% + ), + linear-gradient( + 90deg, + color-mix(in oklch, var(--canvas) 92%, transparent) 0%, + color-mix(in oklch, var(--canvas) 56%, transparent) 32%, + color-mix(in oklch, var(--canvas) 10%, transparent) 66%, + color-mix(in oklch, var(--canvas) 46%, transparent) 100% + ), + linear-gradient( + 180deg, + color-mix(in oklch, var(--canvas) 18%, transparent) 0%, + transparent 36%, + color-mix(in oklch, var(--canvas) 96%, transparent) 100% + ); + } + + /* 横向滚动行两侧的渐隐,取页面底色(日间原来是一道黑条) */ + .row-fade-left { + background: linear-gradient(90deg, var(--canvas) 0%, transparent 100%); + } + + .row-fade-right { + background: linear-gradient(270deg, var(--canvas) 0%, transparent 100%); + } +} + +/* ───────────────────────────── Animations ────────────────────────────── */ + +/* .pg-beam 的旋转角:注册为 才能被动画插值(普通自定义属性 + 只能离散跳变);@property 不允许出现在 @layer 内,故放此处 */ +@property --pg-beam-angle { + syntax: ""; + initial-value: 0deg; + inherits: false; +} + +/* 主按钮棱镜环的旋转角,同理需注册 */ +@property --prism-angle { + syntax: ""; + initial-value: 0deg; + inherits: false; +} + +@keyframes prism-spin { + to { + --prism-angle: 360deg; + } +} + +@keyframes stream-entry-burst { + from { + opacity: 0.6; + transform: scale(1); + } + + to { + opacity: 0; + transform: scale(2.6); + } +} + +@keyframes pg-beam-spin { + to { + --pg-beam-angle: 360deg; + } +} + +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* 播放器弹出层:比 fade-in 快得多(200ms)并带轻微 scale,从触发按钮方向 + "弹"出来(配 origin-bottom-right)。菜单是高频操作,520ms 的入场太拖 */ +@keyframes ticker-in { + from { + opacity: 0; + transform: translateY(100%); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes ticker-out { + from { + opacity: 1; + transform: none; + } + + to { + opacity: 0; + transform: translateY(-100%); + } +} + +@keyframes pg-pop { + from { + opacity: 0; + transform: translateY(6px) scale(0.97); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* 页面入场:从轻微失焦到清晰,只播一次(借鉴 Arc 的 blur-in;调用处用 + motion-safe: 前缀,减少动态效果时直接显示)。填充模式用 backwards 而不是 both: + 播完不能留下 filter/transform,否则页面里 position:fixed 的元素会改以这层为参照 */ +@keyframes blur-in { + from { + opacity: 0; + filter: blur(6px); + transform: translateY(8px); + } + + to { + opacity: 1; + filter: blur(0); + transform: translateY(0); + } +} + +@keyframes hero-fade { + from { + opacity: 0; + transform: translateY(16px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes ken-burns { + from { + transform: translateY(var(--parallax, 0)) scale(1.02); + } + + to { + transform: translateY(var(--parallax, 0)) scale(1.08); + } +} + +@keyframes shiny-text { + 0%, + 90%, + 100% { + background-position: calc(-100% - var(--shiny-width)) 0; + } + 30%, + 60% { + background-position: calc(100% + var(--shiny-width)) 0; + } +} + +@keyframes fadeUp { + from { + opacity: 0; + transform: translateY(24px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes player-flash { + 0% { + opacity: 0; + transform: scale(0.55); + } + + 12% { + opacity: 1; + transform: scale(1.06); + } + + 18%, + 75% { + opacity: 1; + transform: scale(1); + } + + 100% { + opacity: 0; + transform: scale(1.15); + } +} + +.animate-shiny-text { + animation: shiny-text 8s infinite; +} + +.animate-player-flash { + animation: player-flash var(--player-flash-dur, 1500ms) + var(--ease-standard) forwards; +} + +::view-transition-old(play-card), +::view-transition-new(play-card) { + animation-duration: 360ms; + animation-timing-function: var(--ease-sheet); +} + +/* 主题切换的渐进过渡(theme-toggle.tsx):走 View Transitions 默认的 + 交叉淡化(plus-lighter 混合),这里只把 UA 默认的 250ms 放缓到 420ms + 并换成标准减速曲线,让明暗互换从容一些。scoped 在 .theme-vt 下, + 不影响其它 view transition(play-card 的导航过渡仍走默认时长)。 */ +:root.theme-vt::view-transition-old(root), +:root.theme-vt::view-transition-new(root) { + animation-duration: 420ms; + animation-timing-function: var(--ease-standard); +} + +@keyframes update-pill-ring { + 0% { + box-shadow: 0 0 0 0 color-mix(in oklch, var(--primary) 45%, transparent); + opacity: 1; + } + + 70% { + box-shadow: 0 0 0 10px color-mix(in oklch, var(--primary) 0%, transparent); + opacity: 0; + } + + 100% { + box-shadow: 0 0 0 0 color-mix(in oklch, var(--primary) 0%, transparent); + opacity: 0; + } +} diff --git a/apps/web/app/components/console/api-key-dialog.tsx b/apps/web/app/components/console/api-key-dialog.tsx new file mode 100644 index 00000000..70357ca6 --- /dev/null +++ b/apps/web/app/components/console/api-key-dialog.tsx @@ -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 ( +

{ + setOpen(o); + if (o) setValue(""); + }} + > + + + + + + API Key + 控制台用它访问服务端接口,只保存在本浏览器。 + + setValue(e.target.value)} + /> + + {hasKey ? ( + + ) : null} + + + + + ); +} diff --git a/apps/web/app/components/console/empty-state.tsx b/apps/web/app/components/console/empty-state.tsx new file mode 100644 index 00000000..c196440d --- /dev/null +++ b/apps/web/app/components/console/empty-state.tsx @@ -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 ( +
+ + + +

{title}

+ {description ?

{description}

: null} + {action ?
{action}
: null} +
+ ); +} diff --git a/apps/web/app/components/console/nav.test.ts b/apps/web/app/components/console/nav.test.ts new file mode 100644 index 00000000..d9b33494 --- /dev/null +++ b/apps/web/app/components/console/nav.test.ts @@ -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(); + }); +}); diff --git a/apps/web/app/components/console/nav.ts b/apps/web/app/components/console/nav.ts new file mode 100644 index 00000000..78e61653 --- /dev/null +++ b/apps/web/app/components/console/nav.ts @@ -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] + ); +} diff --git a/apps/web/app/components/console/page-header.tsx b/apps/web/app/components/console/page-header.tsx new file mode 100644 index 00000000..61db1d6e --- /dev/null +++ b/apps/web/app/components/console/page-header.tsx @@ -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 ( +
+
+

{title}

+ {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ ); +} diff --git a/apps/web/app/components/console/status-badge.tsx b/apps/web/app/components/console/status-badge.tsx new file mode 100644 index 00000000..9d3b5715 --- /dev/null +++ b/apps/web/app/components/console/status-badge.tsx @@ -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 = { + 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 ( + + + {children} + + ); +} diff --git a/apps/web/app/components/console/workspace-placeholder.tsx b/apps/web/app/components/console/workspace-placeholder.tsx new file mode 100644 index 00000000..1a76fe03 --- /dev/null +++ b/apps/web/app/components/console/workspace-placeholder.tsx @@ -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 ( + <> + + + + ); +} diff --git a/apps/web/app/components/prism-leak.tsx b/apps/web/app/components/prism-leak.tsx new file mode 100644 index 00000000..6e19cbd7 --- /dev/null +++ b/apps/web/app/components/prism-leak.tsx @@ -0,0 +1,10 @@ +import { cn } from "~/lib/utils"; + +/** + * 全站背景上左上角那束淡淡的棱镜漏光(样式见 app.css 的 .prism-leak)。 + * 各页面外壳各放一个:用户中心、流媒体浏览页、公共页(落地页除外)、测速、404、退订。 + * 放在背景层之后、内容之前;宿主是层叠上下文时用 -z-10 垫在内容下面。 + */ +export default function PrismLeak({ className }: { className?: string }) { + return
; +} diff --git a/apps/web/app/components/theme-toggle.tsx b/apps/web/app/components/theme-toggle.tsx new file mode 100644 index 00000000..7007f654 --- /dev/null +++ b/apps/web/app/components/theme-toggle.tsx @@ -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 ( + + ); +} diff --git a/apps/web/app/components/ui/alert-dialog.tsx b/apps/web/app/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..c33977e7 --- /dev/null +++ b/apps/web/app/components/ui/alert-dialog.tsx @@ -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) { + return ; +} + +function AlertDialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm"; +}) { + return ( + + + + + ); +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/apps/web/app/components/ui/avatar.tsx b/apps/web/app/components/ui/avatar.tsx new file mode 100644 index 00000000..b6c790d1 --- /dev/null +++ b/apps/web/app/components/ui/avatar.tsx @@ -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 & { + size?: "default" | "sm" | "lg"; +}) { + return ( + + ); +} + +function AvatarImage({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + 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 ( +
+ ); +} + +function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) { + return ( +
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 }; diff --git a/apps/web/app/components/ui/badge.tsx b/apps/web/app/components/ui/badge.tsx new file mode 100644 index 00000000..6741bb20 --- /dev/null +++ b/apps/web/app/components/ui/badge.tsx @@ -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 & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/apps/web/app/components/ui/breadcrumb.tsx b/apps/web/app/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..68b1f1ae --- /dev/null +++ b/apps/web/app/components/ui/breadcrumb.tsx @@ -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
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/apps/web/app/components/ui/input.tsx b/apps/web/app/components/ui/input.tsx new file mode 100644 index 00000000..de935f70 --- /dev/null +++ b/apps/web/app/components/ui/input.tsx @@ -0,0 +1,21 @@ +import type * as React from "react"; + +import { cn } from "~/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/apps/web/app/components/ui/label.tsx b/apps/web/app/components/ui/label.tsx new file mode 100644 index 00000000..20d6f055 --- /dev/null +++ b/apps/web/app/components/ui/label.tsx @@ -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) { + return ( + + ); +} + +export { Label }; diff --git a/apps/web/app/components/ui/pagination.tsx b/apps/web/app/components/ui/pagination.tsx new file mode 100644 index 00000000..26f4fb1d --- /dev/null +++ b/apps/web/app/components/ui/pagination.tsx @@ -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 ( +
+ {/* 左侧:总数 + 每页显示 */} +
+ + 共 {total} 条 · 第{" "} + {from} + {" 至 "} + {to} 条 + + + {/* 每页条数 */} + {onPageSizeChange && ( +
+ 每页 + +
+ )} +
+ + {/* 右侧:页码导航 */} +
+ {/* 首页 */} + onPageChange(1)} disabled={isFirst} title="第一页"> + + + + {/* 上一页 */} + onPageChange(page - 1)} disabled={isFirst} title="上一页"> + + + + {/* 页码按钮 */} +
+ {pages.map((p, i) => + p === "..." ? ( + + ··· + + ) : ( + + ), + )} +
+ + {/* 下一页 */} + onPageChange(page + 1)} disabled={isLast} title="下一页"> + + + + {/* 末页 */} + onPageChange(totalPages)} disabled={isLast} title="最后一页"> + + +
+
+ ); +} + +function NavBtn({ + children, + onClick, + disabled, + title, +}: { + children: React.ReactNode; + onClick: () => void; + disabled: boolean; + title?: string; +}) { + return ( + + ); +} diff --git a/apps/web/app/components/ui/popover.tsx b/apps/web/app/components/ui/popover.tsx new file mode 100644 index 00000000..f7e0a61d --- /dev/null +++ b/apps/web/app/components/ui/popover.tsx @@ -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) { + return ; +} + +function PopoverTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function PopoverAnchor({ ...props }: React.ComponentProps) { + return ; +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { + return
; +} + +function PopoverDescription({ className, ...props }: React.ComponentProps<"p">) { + return

; +} + +export { Popover, PopoverAnchor, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger }; diff --git a/apps/web/app/components/ui/select.tsx b/apps/web/app/components/ui/select.tsx new file mode 100644 index 00000000..1e62d01d --- /dev/null +++ b/apps/web/app/components/ui/select.tsx @@ -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) { + return ; +} + +function SelectGroup({ ...props }: React.ComponentProps) { + return ; +} + +function SelectValue({ ...props }: React.ComponentProps) { + return ; +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default"; +}) { + return ( + + {children} + + + + + ); +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ className, children, ...props }: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectScrollUpButton({ className, ...props }: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/apps/web/app/components/ui/separator.tsx b/apps/web/app/components/ui/separator.tsx new file mode 100644 index 00000000..c656ba5a --- /dev/null +++ b/apps/web/app/components/ui/separator.tsx @@ -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) { + return ( + + ); +} + +export { Separator }; diff --git a/apps/web/app/components/ui/sheet.tsx b/apps/web/app/components/ui/sheet.tsx new file mode 100644 index 00000000..64fe012d --- /dev/null +++ b/apps/web/app/components/ui/sheet.tsx @@ -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) { + return ; +} + +function SheetTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function SheetClose({ ...props }: React.ComponentProps) { + return ; +} + +function SheetPortal({ ...props }: React.ComponentProps) { + return ; +} + +function SheetOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left"; + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return

; +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function SheetTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger }; diff --git a/apps/web/app/components/ui/slider.tsx b/apps/web/app/components/ui/slider.tsx new file mode 100644 index 00000000..c95a89b5 --- /dev/null +++ b/apps/web/app/components/ui/slider.tsx @@ -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) { + const _values = React.useMemo( + () => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]), + [value, defaultValue, min, max], + ); + + return ( + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + ); +} + +export { Slider }; diff --git a/apps/web/app/components/ui/sonner.tsx b/apps/web/app/components/ui/sonner.tsx new file mode 100644 index 00000000..f489bf63 --- /dev/null +++ b/apps/web/app/components/ui/sonner.tsx @@ -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 ( + , + info: , + warning: , + error: , + loading: , + }} + 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 }; diff --git a/apps/web/app/components/ui/switch.tsx b/apps/web/app/components/ui/switch.tsx new file mode 100644 index 00000000..1ed3b5a8 --- /dev/null +++ b/apps/web/app/components/ui/switch.tsx @@ -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) { + return ( + + + + ); +} + +export { Switch }; diff --git a/apps/web/app/globals.d.ts b/apps/web/app/globals.d.ts new file mode 100644 index 00000000..1f87c4b4 --- /dev/null +++ b/apps/web/app/globals.d.ts @@ -0,0 +1,2 @@ +/** 构建时由 vite.config.ts 的 appVersion 插件注入 */ +declare const __APP_VERSION__: string; diff --git a/apps/web/app/lib/api.ts b/apps/web/app/lib/api.ts new file mode 100644 index 00000000..ee53403d --- /dev/null +++ b/apps/web/app/lib/api.ts @@ -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 = { + /** 路径变量,如 { id: "..." } 替换 /api/v1/media/:id */ + pathParams?: Record; + params?: Record; + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + body?: B; + headers?: Record; + signal?: AbortSignal; +}; + +export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; + +const fail = (code: ApiError["code"], message: string): ApiResponse => ({ + ok: false, + error: { code, message }, +}); + +/** + * 核心 fetcher:始终返回 ApiResponse 信封,不抛出网络/解析错误。 + * queryFn: () => apiFetch("/api/v1/health") + * mutationFn: (body) => apiFetch("/api/v1/xxx", { method: "POST", body }) + */ +export async function apiFetch(path: string, arg: FetcherArg = {}): Promise> { + 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 = { ...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; + } catch { + return fail("internal", `响应不是 JSON(HTTP ${res.status})`); + } +} + +/** 取出 data,失败时抛出(给 react-query 的 queryFn 用,错误进入 error 状态) */ +export async function apiData(path: string, arg?: FetcherArg): Promise { + const r = await apiFetch(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(path: string, base?: Omit, "body">) { + return (body: B) => apiFetch(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; diff --git a/apps/web/app/lib/design-tokens.generated.ts b/apps/web/app/lib/design-tokens.generated.ts new file mode 100644 index 00000000..c0988b12 --- /dev/null +++ b/apps/web/app/lib/design-tokens.generated.ts @@ -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; diff --git a/apps/web/app/lib/utils.ts b/apps/web/app/lib/utils.ts new file mode 100644 index 00000000..8752c5fb --- /dev/null +++ b/apps/web/app/lib/utils.ts @@ -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" }); +} diff --git a/apps/web/app/root.tsx b/apps/web/app/root.tsx new file mode 100644 index 00000000..9af3af9f --- /dev/null +++ b/apps/web/app/root.tsx @@ -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 ( + + + + + + + + + + {children} + + + + + + + ); +} + +export default function App() { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { staleTime: 30_000, refetchOnWindowFocus: false }, + }, + }), + ); + return ( + + + + ); +} + +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 ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ); +} diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts new file mode 100644 index 00000000..e835043d --- /dev/null +++ b/apps/web/app/routes.ts @@ -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; diff --git a/apps/web/app/routes/console/agent.tsx b/apps/web/app/routes/console/agent.tsx new file mode 100644 index 00000000..43a48816 --- /dev/null +++ b/apps/web/app/routes/console/agent.tsx @@ -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 ( + <> + +
+ +
e.preventDefault()}> + + +
+
+ + ); +} diff --git a/apps/web/app/routes/console/home.tsx b/apps/web/app/routes/console/home.tsx new file mode 100644 index 00000000..409f2a4b --- /dev/null +++ b/apps/web/app/routes/console/home.tsx @@ -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 检查中; + if (isError) return 服务不可达:{error.message}; + return ( +
+ + 服务{data.status === "ok" ? "正常" : "降级"} + + + 数据库 {data.db === "ok" ? "正常" : data.db === "down" ? "不可用" : "未知"} + + + v{data.version} · {data.role} · {formatDateTime(data.time)} + +
+ ); +} + +export default function Home() { + return ( + <> + } + /> +
+ {SECTIONS.map((s) => ( +
+
+ +

{s.title}

+
+ +
+ ))} +
+ + ); +} diff --git a/apps/web/app/routes/console/identity.tsx b/apps/web/app/routes/console/identity.tsx new file mode 100644 index 00000000..60e4e0bc --- /dev/null +++ b/apps/web/app/routes/console/identity.tsx @@ -0,0 +1,6 @@ +import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; + +// 占位:标题与说明取自 components/console/nav.ts +export default function IdentityPage() { + return ; +} diff --git a/apps/web/app/routes/console/inbox.tsx b/apps/web/app/routes/console/inbox.tsx new file mode 100644 index 00000000..ef5141fd --- /dev/null +++ b/apps/web/app/routes/console/inbox.tsx @@ -0,0 +1,6 @@ +import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; + +// 占位:标题与说明取自 components/console/nav.ts +export default function InboxPage() { + return ; +} diff --git a/apps/web/app/routes/console/layout.tsx b/apps/web/app/routes/console/layout.tsx new file mode 100644 index 00000000..f47b0d45 --- /dev/null +++ b/apps/web/app/routes/console/layout.tsx @@ -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(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('[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 ( + + ); +} + +// ─── 折叠态:只显示图标 ─── +function CollapsedNav() { + return ( + + ); +} + +function Brand({ collapsed = false }: { collapsed?: boolean }) { + return ( + + + + + {!collapsed &&

UHD Media Tool

} + + ); +} + +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 ( +
+
+ + + {/* 桌面端侧栏 */} + + + {/* 主内容区 */} +
+
+
+ + + + + + + + + + + setSheetOpen(false)} /> + + + {activeLabel} +
+
+ + +
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/apps/web/app/routes/console/library.tsx b/apps/web/app/routes/console/library.tsx new file mode 100644 index 00000000..0e1cdad9 --- /dev/null +++ b/apps/web/app/routes/console/library.tsx @@ -0,0 +1,6 @@ +import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; + +// 占位:标题与说明取自 components/console/nav.ts +export default function LibraryPage() { + return ; +} diff --git a/apps/web/app/routes/console/organize.tsx b/apps/web/app/routes/console/organize.tsx new file mode 100644 index 00000000..551099e2 --- /dev/null +++ b/apps/web/app/routes/console/organize.tsx @@ -0,0 +1,6 @@ +import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; + +// 占位:标题与说明取自 components/console/nav.ts +export default function OrganizePage() { + return ; +} diff --git a/apps/web/app/routes/console/preferences.tsx b/apps/web/app/routes/console/preferences.tsx new file mode 100644 index 00000000..b96339f6 --- /dev/null +++ b/apps/web/app/routes/console/preferences.tsx @@ -0,0 +1,6 @@ +import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; + +// 占位:标题与说明取自 components/console/nav.ts +export default function PreferencesPage() { + return ; +} diff --git a/apps/web/app/routes/console/settings.tsx b/apps/web/app/routes/console/settings.tsx new file mode 100644 index 00000000..93150ed5 --- /dev/null +++ b/apps/web/app/routes/console/settings.tsx @@ -0,0 +1,6 @@ +import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; + +// 占位:标题与说明取自 components/console/nav.ts +export default function SettingsPage() { + return ; +} diff --git a/apps/web/app/routes/console/sites.tsx b/apps/web/app/routes/console/sites.tsx new file mode 100644 index 00000000..d3ddd00e --- /dev/null +++ b/apps/web/app/routes/console/sites.tsx @@ -0,0 +1,6 @@ +import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; + +// 占位:标题与说明取自 components/console/nav.ts +export default function SitesPage() { + return ; +} diff --git a/apps/web/app/routes/console/sources.tsx b/apps/web/app/routes/console/sources.tsx new file mode 100644 index 00000000..88cbbf75 --- /dev/null +++ b/apps/web/app/routes/console/sources.tsx @@ -0,0 +1,6 @@ +import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; + +// 占位:标题与说明取自 components/console/nav.ts +export default function SourcesPage() { + return ; +} diff --git a/apps/web/app/routes/console/subscriptions.tsx b/apps/web/app/routes/console/subscriptions.tsx new file mode 100644 index 00000000..0f12daab --- /dev/null +++ b/apps/web/app/routes/console/subscriptions.tsx @@ -0,0 +1,6 @@ +import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; + +// 占位:标题与说明取自 components/console/nav.ts +export default function SubscriptionsPage() { + return ; +} diff --git a/apps/web/app/routes/console/tasks.tsx b/apps/web/app/routes/console/tasks.tsx new file mode 100644 index 00000000..41d09cda --- /dev/null +++ b/apps/web/app/routes/console/tasks.tsx @@ -0,0 +1,6 @@ +import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; + +// 占位:标题与说明取自 components/console/nav.ts +export default function TasksPage() { + return ; +} diff --git a/apps/web/app/routes/not-found.tsx b/apps/web/app/routes/not-found.tsx new file mode 100644 index 00000000..48af9ea9 --- /dev/null +++ b/apps/web/app/routes/not-found.tsx @@ -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 ( +
+ + 回到首页 + + } + /> +
+ ); +} diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 00000000..adece9fb --- /dev/null +++ b/apps/web/components.json @@ -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": {} +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 00000000..d61b7616 --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/react-router.config.ts b/apps/web/react-router.config.ts new file mode 100644 index 00000000..51cf9985 --- /dev/null +++ b/apps/web/react-router.config.ts @@ -0,0 +1,6 @@ +import type { Config } from "@react-router/dev/config"; + +export default { + // SPA 模式,静态部署 + ssr: false, +} satisfies Config; diff --git a/apps/web/scripts/check-design-tokens.sh b/apps/web/scripts/check-design-tokens.sh new file mode 100755 index 00000000..69f85cb5 --- /dev/null +++ b/apps/web/scripts/check-design-tokens.sh @@ -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(透明度留在调用处)" '(?<=-)(? 默认样式(pill-primary)" 'hover:bg-(primary|foreground|brand-strong)(?![\w/-])' +report app "浮层(下拉、菜单、弹窗面板)请用 pg-float,不要手写 shadow-2xl 之类的投影" '(? [ + ...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})`, + ); +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 00000000..826fdbb5 --- /dev/null +++ b/apps/web/tsconfig.json @@ -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 + } +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 00000000..84302408 --- /dev/null +++ b/apps/web/vite.config.ts @@ -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 }, + }, + }, + }; +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 00000000..4fe9313c --- /dev/null +++ b/apps/web/vitest.config.ts @@ -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" }, +}); diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..d468456b --- /dev/null +++ b/biome.json @@ -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" } } } + } + ] +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..77021c15 --- /dev/null +++ b/docker-compose.dev.yml @@ -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: diff --git a/docs/adr/ADR-001-runtime-node-not-deno.md b/docs/adr/ADR-001-runtime-node-not-deno.md new file mode 100644 index 00000000..4d26a023 --- /dev/null +++ b/docs/adr/ADR-001-runtime-node-not-deno.md @@ -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。 diff --git a/docs/design/README.md b/docs/design/README.md new file mode 100644 index 00000000..a07a4dc3 --- /dev/null +++ b/docs/design/README.md @@ -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. **材质按层级只有三种**,不在玻璃上叠玻璃:已在卡片里的空态用 ``。 +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 个工作区的导航表,标题与说明同时供占位页使用。 diff --git a/docs/design/design-tokens.md b/docs/design/design-tokens.md new file mode 100644 index 00000000..3c3782f4 --- /dev/null +++ b/docs/design/design-tokens.md @@ -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`)在迁移时删除,它们原本不产生任何样式。 diff --git a/docs/design/interface-materials.md b/docs/design/interface-materials.md new file mode 100644 index 00000000..ca310af8 --- /dev/null +++ b/docs/design/interface-materials.md @@ -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)。 diff --git a/docs/design/ui-controls.md b/docs/design/ui-controls.md new file mode 100644 index 00000000..cffe448a --- /dev/null +++ b/docs/design/ui-controls.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 先看它。 + +## 按钮只有一套 + +全站按钮(门户、公共页、测速、流媒体、播放器浮层)是同一套外观,与动漫站同一思路:形状一律胶囊或正圆,颜色与材质只由角色决定。 + +| 角色 | 门户写法 | 流媒体写法 | 外观 | +|---|---|---|---| +| 主操作 | `