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

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

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

No files matched your search

+29
View File
@@ -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。
+25
View File
@@ -0,0 +1,25 @@
# 设计系统
控制台(apps/web)沿用 /opt/uhd_web 的设计系统:token 定义在 [app.css](../../apps/web/app/app.css),shadcn 组件在 `apps/web/app/components/ui/`,控制台共享件在 `apps/web/app/components/console/`。
| 文档 | 内容 |
|---|---|
| [design-tokens.md](design-tokens.md) | 颜色 token 分层、命名与使用规则,字号/圆角/时长/缓动阶梯 |
| [interface-materials.md](interface-materials.md) | 材质:`pg-chrome` 贴边、`pg-slab` 内容卡、`pg-float` 悬浮层 |
| [ui-controls.md](ui-controls.md) | 按钮、选中态、状态反馈、动效与圆角角色 |
## 硬规则
1. **新增语义先加 token 再用。** 需要新的颜色、字号、圆角、时长或缓动时,先在 `app.css` 定义语义 token(日夜两套值都给),跑 `pnpm --filter @umt/web gen:tokens`,再在组件里引用。
2. **禁止原色与十六进制。** 组件里不写 `#xxxxxx`、`text-[#..]`、`bg-red-500` 这类 Tailwind 调色板色、`white/10` 之类的黑白透明度,也不写 `text-[13px]`、`duration-300`、`ease-[cubic..]`、裸 `rounded`。状态只用 `success` / `warning` / `destructive`,并且配文字,不只靠颜色。
3. **材质按层级只有三种**,不在玻璃上叠玻璃:已在卡片里的空态用 `<EmptyState plain>`。
4. 确需例外(第三方库的颜色参数等),在该行或上一行写 `token-allow: 原因`。
以上规则由 `pnpm check:tokens`(`apps/web/scripts/check-design-tokens.sh` + 生成物校验)在 `pnpm run ci` 中守住。
## 控制台共享件
- `PageHeader`:页面标题 + 一句说明 + 右侧操作区。
- `EmptyState`:空态/建设中占位;`plain` 用于卡片内部。
- `StatusBadge`:`success | warning | destructive | neutral` 四种语气,必须带文字。
- `nav.ts`:12 个工作区的导航表,标题与说明同时供占位页使用。
+95
View File
@@ -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`)在迁移时删除,它们原本不产生任何样式。
+104
View File
@@ -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)。
+62
View File
@@ -0,0 +1,62 @@
# 控件使用标准
> 迁入自 /opt/uhd_web `docs/features/`(2026-09-27)。文中流媒体、播放器、落地页、套餐等段落是 uhd_web 的原始上下文,本项目没有这些页面,只作材质与 token 用法参考;以本目录 README 的约定为准。
配合[设计 token](design-tokens.md)与[界面材质](interface-materials.md)。开发态的陈列页 `/dev/ui`(只在开发构建注册,[routes.ts](../../apps/web/app/routes.ts))把三块壳下的 token、材质和控件排在一页,换皮或改 token 先看它。
## 按钮只有一套
全站按钮(门户、公共页、测速、流媒体、播放器浮层)是同一套外观,与动漫站同一思路:形状一律胶囊或正圆,颜色与材质只由角色决定。
| 角色 | 门户写法 | 流媒体写法 | 外观 |
|---|---|---|---|
| 主操作 | `<Button>` | `btn-glass btn-glass-primary` | 半透明胶囊 + 流转的棱镜光谱边框,不做实心 |
| 次操作 | `<Button variant="outline">`(`secondary` 同样子) | `btn-glass` | 描边胶囊 |
| 图标钮 | `<Button variant="outline" size="icon*">` | `btn-glass-icon` | 描边正圆 |
| 只有文字 / 图标 | `<Button variant="ghost">` | 各自的 ghost 行 | 悬停出淡底 |
| 危险操作 | `<Button variant="destructive">` | — | 淡红底红字胶囊 |
一屏(或一张卡片)只放一个主操作,并列的其余动作退为描边;前置条件没满足时,真正该做的那一步是主操作(例如团队页没有团队套餐时,「创建团队套餐」是主操作,「添加成员」退为描边)。
两边的底、边、字色、投影都引用 `--mat-pill-*`,改外观只改参数。调用处只给布局(宽度、高度、间距、定位),不改圆角、底色、边框、投影和字色;不是 `<button>` 的元素(`Link`、`a`)用 `buttonVariants()` 拿同一套类名。分段标签、菜单行、列表行不算按钮,按各自控件规范;分页当前页、角标、选中的季 / 集 / 选项、日历选中日用静态的 `pill-selected`,不借主按钮样式;分段控件(侧栏、SegmentedTabs、求片页的分段)的滑块用 `seg-thumb`。放胶囊或分段控件的横向滚动容器加 `scroll-bleed`:`overflow-x-auto` 会连带裁掉纵向溢出,不加的话胶囊的阴影和描边会被切成直边。
右上角账户模块全站共用 [`account-popover.tsx`](../../apps/web/app/components/account-popover.tsx):用户中心用头像 + 名字的胶囊,流媒体与测速用圆头像;菜单里是另外两块的跳转(当前所在的不列,流媒体与测速需有生效订阅)与退出登录。
其余控件仍分两处实现:门户原语在 `app/components/ui/`(shadcn 语义 token),流媒体控件在 `app/routes/stream/_sub/`、`app/routes/stream/player/controls.tsx`(浏览页 `content` / `wash`,播放器 `on-media` 族)。需要相同行为时各自实现。
## 尺寸与圆角
- 图标钮:桌面 36px,粗指针 40px(`[@media(pointer:coarse)]`);播放键等主钮 44 / 48。
- 圆角角色:控件 `rounded-md`、输入与小按钮 `rounded-lg`、卡片 `rounded-xl`、面板 `rounded-2xl`、抽屉与大卡 `rounded-3xl`、药丸 `rounded-full`、徽章与骨架屏 `rounded-tight`。
- 字号:控件文字 `text-control`(15),菜单项 `text-body` / `text-caption`,面板小标签 `text-label` 大写 `tracking-[0.18em]`。
## 状态反馈
- hover:洗层 `hover:bg-wash/[0.06]`(播放器 `on-media-wash/[0.07]`),文字提一档(secondary → strong)。
- 按压:`active:scale-[0.95]`(圆形主钮 0.92),`transition-[transform,background-color,color] duration-micro`。
- 选中:`pill-selected`(静态半透明底 + 稍深描边);单选列表项左侧 Check 标记。进度用细线表示,不做实心填充。
- 打开中的触发钮:`bg-wash/[0.08]` 保持高亮。
- 焦点:门户走 shadcn 的 `focus-visible:ring`,玻璃族 `focus-visible:ring-2 ring-brand/50`。
- 禁用:`opacity-50 cursor-not-allowed`,不改颜色。
- 危险:移除、删除这类动作平时是中性描边,悬停时换成 `destructive` 的边框与文字。
## 动效
`duration-micro` 微反馈、`duration-base` 颜色透明度、`duration-layout` 尺寸位置、`duration-slow` 及以上只给入场与装饰;缓动默认 `ease-standard`,滑块与按压回弹 `ease-spring`,抽屉 `ease-sheet`,弹层 `animate-pg-pop`。尊重 reduced motion。
## 大写小标签
`SectionLabel`([section-label.tsx](../../apps/web/app/components/section-label.tsx)),三种口味:`portal`(门户表单字段名与分组,`text-xs font-semibold uppercase text-muted-foreground`)、`page`(浏览页,`text-label uppercase tracking-[0.18em] text-content-muted`)、`media`(播放器弹层,同上换 on-media)。`as` 指定元素(label / span / p)。
还有几种写法没收进来,值不同、收进来就是像素变化,待设计决定:门户里 `text-xs font-medium uppercase tracking-wider`(团队订单弹窗)、`text-micro … tracking-[0.15em]`(侧栏分组、邀请页)、`tracking-widest` 一处;落地页 hero 的 `tracking-[0.32em] text-white/45` 与分区标题的 `text-meta font-bold tracking-[.22em] text-brand-strong` 是刻意的装饰用法,单列为 hero kicker 与 section kicker。
## 弹层结构
触发钮 + 面板,面板由 `usePopover` 管开关(点外面 / Esc 关闭)。面板顶部一行大写小标签说明这是什么,下面是列表;列表项分两种:单选行(左勾选 + 标签 + 右侧提示)与动作行(左图标 + 文字)。四件在 `player/controls.tsx`:`MenuTrigger`、`MenuPanel`、`MenuOption`、`MenuButton`。`MenuPanel` 通吃三处:`placement` 上方(播放器)/ 下方(浏览页 header),`tone` 决定表头口味,`role` 给 listbox,内边距、宽度、z 与偏移经 className 传入,tailwind-merge 让它们覆盖默认值,所以三处的 class 集合与各自收拢前完全一致(用 react-dom/server 渲出来逐项核对过)。账户菜单的头像表头与排序菜单的单选行样式不同,仍各自写在页面里。
## 收拢原则
- 同一份 class 串出现在两处以上就抽原语,抽的时候 class 串逐字不改,渲染不变。
- 参数只到必要:尺寸档、是否激活、标签、图标;不把整页状态传给控件。
- 只有一处用的局部组件留在页面文件里,不为了"以后复用"提前抽。
- 两个页面展示同一种信息时共用零件:套餐卡用 [plan-card-parts.tsx](../../apps/web/app/components/plan-card-parts.tsx)(名称价格、规格表、线路提醒、共有权益),电影与剧集详情用 [DetailExtras.tsx](../../apps/web/app/routes/stream/_sub/DetailExtras.tsx)(规格徽章、影片信息、可播放版本、更多推荐)。
+75
View File
@@ -0,0 +1,75 @@
# 首个可用版本(v0.1)工程规划
编制:2026-09-27。需求基线:`../../media_automation_requirements_architecture_v0.7.md`(下称 REQ)。本文件是所有开发子任务的统一任务书;与 REQ 冲突时以 REQ 的产品要求为准,技术选型以本文件为准。
## 1. 技术决策(ADR 摘要,详见 docs/adr/)
| 项 | 决定 | 说明 |
| --- | --- | --- |
| 运行时 | **Node.js 24 LTS + pnpm workspace**,TypeScript 5.9 strict | 用户指定沿用 /opt/uhd_web 技术栈;开发机无 Deno。REQ §9 允许"有明确依赖障碍时评估替代并记录代价"。ADR-001 记录此偏离。 |
| 前端 | React 19 + React Router 7(SPA, `ssr:false`)+ Vite 7 + Tailwind v4 + shadcn(new-york, radix-ui 统一包)+ TanStack Query 5 + zod 4 + sonner + lucide | 直接拷贝 uhd_web 的 `app/app.css` 设计 token、`app/components/ui/*`、`app/lib/api.ts`、`app/lib/utils.ts`、`scripts/gen-design-tokens.mjs`、`scripts/check-design-tokens.sh`、`docs/features/{design-tokens,interface-materials,ui-controls}.md`。**不引入 uhd_web 的播放器/WASM 相关内容。** |
| 后端 HTTP | Hono 4(`@hono/node-server`),REST + SSE | OpenAPI 由 zod schema 生成(`@hono/zod-openapi`)。 |
| 数据库 | PostgreSQL 16;Kysely + pg;SQL 迁移文件 + Kysely Migrator;`kysely-codegen` 或手写 DB 类型 | 开发用 docker-compose 起 `postgres:16-alpine`,端口 55433。 |
| 持久任务引擎 | 优先 `[email protected]`(peer: postgres.js);**须先通过 spike 验证**(重启恢复/等待信号/取消/重试)。未通过则用 `packages/tasks` 内的 PG 租约式任务表(lease + attempts + dead-letter)。 | 结论写 ADR-002。 |
| Agent 内核 | `@earendil-works/[email protected]` + `@earendil-works/[email protected]`,锁版本;通过自有 `AgentRuntimePort` 封装,显式工具白名单,不开放 bash/文件系统/任意网络 | 测试用脚本化 fake 模型 provider;真实上游由设置页配置。 |
| 校验/测试 | vitest(server/packages 单元 + 集成);web 沿用 token 守卫脚本;biome 做 lint/format | `pnpm ci` = typecheck + lint + test + check:tokens + build |
| 端口 | web dev 5180,API 4380,PG 55433 | 本机 3000/5175/5176/55432 已被占用,不要用 |
| 参考源码 | MoviePilot: `/opt/moviepilot-plus/upstream/MoviePilot`(GPLv3,只做行为对照与 TS 重写,不复制文件);MovieClaw 若本机不存在则按 REQ 附录链接对照 | 迁移对照记录写 `docs/porting/` |
## 2. 仓库结构(pnpm monorepo)
```
apps/web React Router 7 SPA 控制台(复用 uhd_web 设计系统)
apps/server Node 服务:HTTP API + SSE + worker 入口(单进程可跑,也可 `--role api|worker` 分开)
packages/contracts zod schema + 类型:所有 API/事件/能力(capability)输入输出的唯一定义,web 与 server 共用
packages/db Kysely 类型、迁移、仓储辅助
packages/domain 纯业务逻辑(无 IO):标题解析/身份匹配/Slot 满足度/路径绑定/整理计划/策略评估
packages/adapters 外部系统适配:qBittorrent、RSS、站点(观众 NexusPHP / 馒头 API)、TMDB、Emby 兼容、文件系统
packages/tasks 持久任务定义与执行器(OpenWorkflow 或自研租约表),outbox
packages/agent Pi 接入:AgentRuntimePort、能力注册中心桥接、agent_run 持久化、fake provider
packages/testkit mock 服务:mock qB WebUI、mock NexusPHP 站点、mock M-Team API、mock RSS、mock TMDB、临时目录夹具
docs/adr 架构决策记录
docs/plan 本规划与里程碑验收清单
docs/porting MoviePilot/MovieClaw 迁移对照账本(REQ §14.1.2 格式)
docs/spikes 兼容性验证记录
docs/runbook 启动/备份/恢复/配置说明
scripts dev/ci 辅助脚本
```
约定:
- 所有共享类型只在 `packages/contracts` 定义;web 不得手写与 server 重复的 DTO。
- `packages/domain` 不 import 任何 IO 库,100% 可单元测试。
- 每个包有 `src/index.ts` 作为唯一公开入口;跨包只通过入口引用。
- 命名:表名 snake_case 与 REQ §12 实体名一致;TS 类型 PascalCase;能力 ID 用 `域.动作`(如 `download.submit`)。
- 提交:conventional commits(`feat(server): ...`),每个子任务结束前 `pnpm ci` 必须绿再提交。
- 凭证一律 secret reference(`secret_ref` 列 + 加密存储),日志/错误/模型上下文脱敏;含 passkey 的 URL 不得进入日志。
## 3. v0.1 范围(= REQ M0 + M1 纵切,可在无真实 PT 账号下用 mock 全链路验证)
### 3.1 必做
1. **底座**:monorepo、配置(env + settings 表)、迁移、API Key 鉴权(scope:read/organize.read/organize.execute/admin)、审计日志 `audit_event`、SSE 事件流(带序号可补读)、全局"自动化总开关"。
2. **数据模型**(REQ §12 子集,表名照抄):media_entity、content_unit、external_identity、identity_revision、title_alias、media_asset、asset_location、path_binding、library_root、site_account、feed_source、observation、source_release、subscription、policy_revision、acquisition_slot、preference_tag、preference_suppression、acquisition_plan、download_task、asset_operation、decision、approval、automation_grant、agent_run、artifact、audit_event、outbox_event、reconcile_run、server_mapping、api_key、organization_record、organization_file_result、downloader_instance、media_server_instance、settings。
3. **来源**:多 RSS 抓取(ETag/Last-Modified、重叠窗口、GUID+摘要去重、退避/熔断、passkey 脱敏)、观众(NexusPHP 列表/详情/下载,参考 MovieClaw audiences.yaml 结构)与馒头(API Key)两类站点适配器、站点快照缓存 + 实时补查 + 站点启停 + 站点级限流。
4. **身份与元数据**:发布标题解析(对照 MP MetaInfo 行为:标题/年份/季集/分辨率/来源/编码/发布组/版本)、TMDB 查询与缓存、本地实体优先 + external_identity 映射(provider+type+id 唯一)、title_alias 模糊召回(pg_trgm)、临时 content_unit、待识别池。
5. **规则与选片**:subscription(单次/持续追踪)、acquisition_slot(硬条件/软偏好/升级阈值/完成条件,状态机 empty→reserved→downloading→verifying→satisfied/upgrade_pending/attention)、确定性 evaluateAcquisition(不含 Agent 主观选片,只留接口)、每日预算与并发限制、优先级(即时>追更>新作品>升级)。
6. **下载**:qB WebUI 适配(多实例、管理标签、提交/接管/查询/暂停恢复)、缓存卷硬阈值与恢复水位(暂停范围仅限纳管任务)、download_task 状态机、无进展只记录不换源。
7. **入库**:verifyAndImport → Copy/Move(临时文件+校验+提交;跨卷 Copy→核验→删源;EXDEV 不静默降级)、路径绑定固定(已绑定目录不因译名变化改变)、organization_record + 文件级结果、**公开整理记录 API**(API Key、游标增量)。
8. **持久任务**:ingestFeed、resolveObservation、evaluateAcquisition、submitDownload、verifyAndImport、refreshMetadata、reconcileLibrary(资产存在性)、cleanupDownloadCache、resolveDecision;outbox;重启恢复;幂等键;取消传播。
9. **Agent**:Pi AgentSession 封装;能力注册中心(ID/版本/schema/权限/风险/幂等/预览/处理器),首批能力:catalog.lookup、release.search、release.inspect、identity.resolve、download.plan、download.submit、organize.plan、organize.execute、task.inspect、task.retry、task.cancel、policy.preview、policy.apply、inventory.query;授权校验在服务端;agent_run 持久化 + SSE 增量;后台事件触发有界 Agent 任务(识别歧义、下载失败诊断);设置页可配上游/模型/思考档位/上下文预算/压缩/子代理开关。
10. **控制台页面**(REQ §13):首页六区块(自动化概览/当前工作/需要我处理/缓存与保留/来源健康/最近产出)、媒体库(列表+详情:路径/身份映射/元数据来源/Slot/候选拒绝理由/下载历史)、订阅与收录规则、偏好标签(基础 CRUD+抑制记录)、PT 站点工作台(整理视图:列表/搜索/详情/库存叠加/统一下载入口;原站视图首版只留占位与说明)、来源/Feed/快照、下载与任务(含业务时间线)、整理记录、待处理中心、身份与元数据、系统设置(实例/路径映射/API Key/Pi 上游与预算/自动化开关)、Agent 会话页 + 侧栏。
11. **可选 Emby 兼容查询**:多实例,查有无/季集/版本,未知≠缺失。
12. **运维**:`scripts/backup.sh`(pg_dump + 映射导出)、恢复后只读对账模式开关、docker-compose(生产:server + web(nginx) + pg)、runbook。
13. **测试**:domain 单元测试;adapters 对 mock 服务的集成测试;端到端:mock RSS → 观察 → 识别 → 计划 → mock qB 下载完成 → Copy 入库 → 整理记录 API 可读;Agent e2e:fake 模型按脚本调用能力完成"把 X 电影 1080p 收进来"闭环;崩溃恢复注入(worker 中断后续跑)。
### 3.2 v0.1 明确不做(留接口/占位)
国产综艺分段建模与正片覆盖(M2)、双版本 require_distinct_asset 组合校验(M2,但 Slot 多目标数据结构先建好)、浏览器/camofox 接入与原站视图(M2,BrowserProvider 接口定义先落)、HHClub/CHD 适配(M2)、旧库分范围接管预览(M2)、Agent 主观选片与规则学习回放(M2)、媒体级主动整理 UI(M2,organize.plan 能力可用)、多用户。
## 4. 里程碑与派工波次
- **W0 底座**(并行):A 脚手架与设计系统迁入;B OpenWorkflow+Pi 兼容性 spike(结论进 ADR-002);C MoviePilot 迁移对照账本(识别/刮削 + 转移入库)。
- **W1 领域与适配**(并行):domain(解析/身份/Slot/路径/整理计划)、adapters(qB/RSS/站点/TMDB/Emby)+ testkit mocks、db schema 全量迁移 + contracts 全量 schema、tasks 引擎接入。
- **W2 服务编排**:server API 全量路由 + 任务处理器串起纵切 + Agent 能力注册与 Pi 封装。
- **W3 控制台**(按页面分组并行)+ e2e。
- **W4 集成验收与修复**:跑 §3.1 第 13 条全部;补 runbook;打 tag v0.1.0。
每波结束的验收由独立审查子代理执行:`pnpm ci` 绿 + 对照本文件逐条勾选 + 抽查 REQ §2.1 五原则未被破坏。
+49
View File
@@ -0,0 +1,49 @@
# 开发环境运行手册
## 前置
- Node.js ≥ 24(当前开发机 24.15)、pnpm 11(`packageManager` 已锁)、Docker。
- 端口:web 5180、API 4380、PG 55433。本机 3000/5175/5176/55432 已被其他项目占用,不要改用。
## 首次启动
```sh
cp .env.example .env # 按需修改;server 与 db 脚本都读仓库根 .env
pnpm db:up # 起 postgres:16-alpine(docker-compose.dev.yml,数据卷 umt-pg-data)
pnpm install
pnpm db:migrate # 执行 packages/db/migrations 下全部迁移
pnpm dev # 同时起 server(4380)与 web(5180)
```
单独起:`pnpm dev:server`(默认 `--role all`,API + worker 同进程)、`pnpm dev:web`。
## 验证
```sh
curl localhost:4380/api/v1/health
# 开发模式可用 .env 的 DEV_ADMIN_KEY 作 admin
curl -N -H "Authorization: Bearer umt-dev-admin-key" "localhost:4380/api/v1/events?after=0"
```
- OpenAPI:http://localhost:4380/api/openapi.json,文档页 http://localhost:4380/api/docs
- 控制台:http://localhost:5180,右上角钥匙按钮设置 API Key(存 localStorage `umt-api-key`)
## 常用命令
| 命令 | 作用 |
|---|---|
| `pnpm run ci` | typecheck + lint + test + check:tokens + build,提交前必须全绿。注意 `pnpm ci` 在 pnpm 11 是内置的 clean-install 命令,不会执行本脚本 |
| `pnpm format` | biome 自动修复格式与 import 排序 |
| `pnpm db:reset` | 清空 public schema 并重新迁移(生产环境拒绝执行) |
| `pnpm --filter @umt/web gen:tokens` | 改了 app.css 后重新生成 token 登记表 |
| `pnpm --filter @umt/server start` | 运行 `pnpm build` 产出的 `apps/server/dist/main.js` |
## 进程角色
`node dist/main.js --role api|worker|all`。`all` 下 worker 心跳会发布到进程内事件总线,SSE 可见;分进程部署时事件总线需改为 PG 投影(W2)。
## 排障
- `配置无效`:按提示检查 `.env`,`SECRET_MASTER_KEY` 至少 16 字符,`DATABASE_URL` 必须是 postgres://。
- health 返回 `db: "down"`:`docker compose -f docker-compose.dev.yml ps` 看容器健康状态。
- 日志中的 passkey/token/cookie/authorization/password 字段与 URL query 参数会被替换为 `[REDACTED]`;若发现明文,属于缺陷。