diff --git a/docs/adr/0008-ui-component-layer-antd-replacement.md b/docs/adr/0008-ui-component-layer-antd-replacement.md new file mode 100644 index 000000000..746749f8c --- /dev/null +++ b/docs/adr/0008-ui-component-layer-antd-replacement.md @@ -0,0 +1,101 @@ +# ADR-0008: 自研 UI 组件层(Celadon UI)——吸收 dbx 组织纪律与 BoardUI 设计体系,分阶段替换 AntD + +状态:已接受。日期:2026-09-05(初版);2026-09-05(修订 1:吸收 BoardUI 设计体系);2026-09-05(修订 2:视觉数值基准改为直接采用 composio-brand-package,见 D3 与「考虑过的方案」末条)。来源:`web/src/components/ui` 混乱整治;参考 dbx(`apps/desktop/src/components/ui`)、BoardUI(`boardui/`)的组织形态与设计纪律,以及 composio-brand-package(`../../../composio-brand-package/`,与 open-ai-canvas 同级的兄弟目录,设计 token 唯一数值权威源);AntD 依赖面证据来自清点脚本复核(数字见「背景」)。 + +## 背景 + +`web/src/components/ui` 混乱、不统一的根因不是缺组件,而是缺一层有纪律的自研组件契约: + +- `components/ui/` 目前几乎全是**一次性装饰件**(`aceternity/` 下的 comet-card、floating-dock、spotlight-surface 等,另有顶层 `animated-theme-toggler`、`announcement-content`、`dia-text-reveal`),深路径引用,无目录规范、无变体契约、无统一导出。 +- 工作台高频交互件(画布工具条按钮、工具激活态、状态/节点徽章、空态、加载态、截断文本提示等)无归属,散落在 `pages/**` 与 `components/canvas/**` 各自私有实现,样式与键盘/无障碍行为漂移。 +- 主题基建已成熟:`web/src/styles/globals.css`(约 892 行 CSS 变量声明)三层 token(Primitive→Semantic→Component),`--color-neutral-*` 色板、`--palette-node-*`、`--palette-status-*`、`--space-*`、`--fs-*` 字号梯度、`--r-*` 圆角梯度;`app-theme.ts` 统一管理 AntD 6 皮肤。缺组件层消费规范与产品级组件沉淀位置。 +- AntD 依赖面:约 180/637 文件导入 AntD、41 个命名导入;Top:Button、App、Input、Modal、Select、Switch、Tooltip、Form、InputNumber、Tag、Segmented、Dropdown、Drawer、Skeleton、Popconfirm;低使用率但对等成本高:Table 5、Upload 3、DatePicker 2、Tree 2(2026-09-05 清点脚本)。 +- 工具链:`cn`(clsx+tailwind-merge)、cva ^0.7、motion ^12.42、lucide-react ^1.25、antd ^6.5.1、tailwindcss ^4.3.3。 + +### 参考吸收(两套参考,结论不同) + +**dbx**(shadcn-vue/reka-nova 风格,Vue SFC):优秀项 = 目录即组件族 + `index.ts` barrel 导出 variants、cva 变体即公共 API、紧凑桌面密度档位、复合模式件下沉、语义状态色与自定义 radius。不足 = 目录半迁移(19 个组件目录旁仍散落 CustomContextMenu/ErrorBanner/LightDropdown 等 8 个顶层扁平遗留件)、token 粒度粗(单角色色值、交互态散在组件内)、无统一排版梯度、图标尺寸由调用方传 class、动效无统一纪律、无 agentic 界面沉淀。 + +**BoardUI**(`components/{base,application,foundations}`,agentic interfaces 设计系统):用 React 19 + Tailwind v4 + `react-aria-components` + remixicon + motion 构建,语义 token 做"状态位 × 前景/文本/背景/边框"矩阵与复合排版工具类(`text-body-medium`、`text-caption-1-semibold`…),kbd/segmented/status-dot 等全部消费专用语义 token;基础组件族目录平铺(`buttons/` 内含 button/icon-button/close-button/button-group),每组件头部注释 Figma 源节点与明暗差异;`application/` 沉淀 agent-chat(composer/history/message)、agent-thinking、notification-center、app-shell、data-table 等 agentic 产品块;动效抽 `button-press-motion` 等工具类并统一尊重 reduced-motion;accent 支持运行时整体换肤,chart/status 各自成组。与影策"AI 影视短剧创作工作台 + canvas-agent"定位高度同构。 + +**composio-brand-package**(设计 token 权威包,**直接采用**):`DESIGN.md`(角色化语义 token 表 + 组件配方 + Do/Don'ts)+ `tokens.json`(W3C DTCG 机器可读)+ `variables.css`/`theme.css`(CSS 变量 + Tailwind v4 @theme 落地)+ `preview.html`。**本项目配色/字体/间距/圆角的数值一律以它为准**(近黑 #0f0f0f 底、电蓝 #0007cd 主色、hairline 分隔、Inter/等宽字体、完整字号与间距梯度),翻译进 globals.css 三层 token;品牌名/Logo/营销文案/abcDiatype 字体(无授权)不采用。 + +**综合结论**:视觉数值取 composio-brand-package(唯一权威源,直接采用);契约形态取 dbx(barrel + cva variants);设计深度取 BoardUI(语义状态矩阵、复合排版、accent/chart/status 分组、RAC 原语、动效纪律、agentic application 层);三套参考都不搬代码。 + +## 决策 + +### D1 战略与执行(维持初版,补充地基阶段) + +战略目标:组件层全自研(Celadon UI),最终不依赖 `antd`。执行:分阶段、按功能对等验收逐批替换(B1–B5,详见实施计划),禁止大爆炸与半替换观感并存。**新增一个"地基"前置项**:进入替换前先按 §D3 补齐 token 与契约(复合排版工具类、语义状态矩阵、accent/chart/status 分组),否则新组件会散落裸值。 + +### D2 分层结构(吸收 BoardUI 顶层分类) + +`components/ui/` 按 `base / product / fx / application` 分层: + +- `base/`:原语 + 基础件(buttons/、badges/、kbd/、segmented-control/…),职责同 dbx/BoardUI 的 base; +- `product/`:跨页面模式件(empty-state/、callout/、loading-state/、section-card/、property-row/…); +- `fx/`:特效件(现 `aceternity/*` 归位命名空间,引用随用随迁,不重写); +- `application/`:产品块(P3 起:agent-chat、notification-center、app-shell…,对齐 BoardUI application)。 + +AntD 皮肤在替换完成前继续由 `app-theme.ts` 统一管理。 + +### D3 token 义务(初版 D3 扩展,吸收 BoardUI 深度;数值以 composio-brand-package 为权威源) + +**数值权威源**:配色/字体/间距/圆角数值一律以 composio-brand-package(`tokens.json` 优先,`DESIGN.md`/`theme.css` 对照)为准,翻译进 globals.css 三层 token 后组件只消费 `var(--*)`/工具类;禁止在页面或组件里直接写规范包裸值。品牌名/Logo/营销文案/abcDiatype 字体(无授权)不采用,字体以 Inter + 开源等宽替代。 + +组件只消费三层 token,禁止裸 `text-[Npx]`/`rounded-[Npx]`/硬编码色;明暗自适应,不读皮肤 ID。在此基础上: + +- **语义状态矩阵**:高频角色补齐 `-default/-hover/-active/-disabled`(含 `-selected`)状态位,消费方直接用状态位,不在组件推算色值。 +- **复合排版工具类**:将 `--fs-*` 字号 + 行高/字重注册为 `text-body-*`、`text-caption-1-*`、`text-title-1-*` 等复合类(Tailwind v4 @theme),并扩展 `cn` 的 tailwind-merge 使其正确合并;组件禁止裸字号堆叠。 +- **分组色系**:`accent`(整体换肤品牌 ramp)、`chart`(`--palette-chart-1..9` + active)、`status`(既有 `--palette-status-*` 上补 halo/dot 成对)、`node`(沿用 `--palette-node-*`)。 +- 新 token 走 globals.css 三层流程并同步 ADR/规范文档。 + +### D4 目录与导出规范(初版 D4 修订为"组件族目录") + +```text +components/ui/base/buttons/ + ├── index.ts # 族级 barrel:重导出组件 + variants 类型 + ├── button.tsx + ├── icon-button.tsx + ├── tool-button.tsx + └── button-group.tsx +``` + +每族一目录、族内按文件拆变体、族级 `index.ts` 汇总导出(同 dbx barrel 形态);无全局 barrel;深路径导入 `@/components/ui//[/]`。cva variants 即公共 API(导出 `XxxVariants` 类型);`className` 经 `cn` 合并;ref 转发;根元素 `data-slot`。持久切换用 `aria-pressed` + `data-active`;`type="primary"` 只表主要命令;保留 `:focus-visible`;尊重 `prefers-reduced-motion`。每组件头注释:用途、token 组、接管/对标对象、明暗差异(对齐 BoardUI 溯源注释)。 + +### D5 原语与无障碍策略(吸收 BoardUI:RAC 为地基) + +- 简单件用原生语义元素 + 自有键盘/ARIA 处理。 +- 复杂控件替换(B3+:Select/Modal/Drawer/Popconfirm/日期/Combobox)以 **`react-aria-components`** 为无障碍地基(同 BoardUI segmented-control/date-picker 做法),包 Celadon 观感与 token;不自研焦点陷阱/键盘导航/ARIA,不引入 Radix 全家桶。B3 启动时正式加入依赖,并先用 1 个样板(SegmentedControl)评审。 + +### D6 图标契约(吸收 BoardUI) + +统一 `lucide-react`(既有),图标以**组件引用**传入(`icon={Move}`),尺寸/颜色由宿主组件以 token 语义档位决定;禁止调用方传 icon className 改尺寸。 + +### D7 动效契约(吸收 BoardUI,项目已有 motion v12) + +按压/工具切换/浮层出入抽统一工具类或小组件(对齐 `button-press-motion` 思路),统一尊重 `prefers-reduced-motion`;渐变悬停等细节抽复用类,不在页面散写。 + +### D8 进库门槛、首批清单与试点(维持初版) + +新组件须满足:① ≥2 处真实重复(PR 附 grep 证据);② 隔离明确协议/交互契约。纯透传壳不进库。P0 六件:IconButton、ToolButton、StatusBadge、EmptyState、Callout、Kbd。P1:NodeTypeBadge、TextTooltip、LoadingState、SectionCard、PropertyRow、IconGroup。试点热区:画布工具条/工具注册表、节点状态徽章、画布空态与提示——替换即删私有实现。 + +## 考虑过的方案 + +- **双层架构(AntD 基座 + 自研轻量契约层,初版推荐)**:作为过渡形态被吸收(替换期 AntD 皮肤继续由 app-theme.ts 统一管理);作为战略目标被否决——最终观感与密度不应长期受第三方主题系统约束。 +- **一步全量替换 AntD**:180 文件一次性重写,不可回滚;否决,改 B1–B5 批次。 +- **仅规范化不动代码**:解决不了页面私有重复与观感不可控;目录规范并入 D4。 +- **引入 Radix 全家桶套皮**:与自研目标不符;否决全家桶,仅对复杂控件采纳 RAC(BoardUI 已验证路径)作无障碍地基。 +- **照搬 BoardUI 或 dbx 代码/清单**:两套均无 AntD 语境且技术栈不同;否决照搬,仅吸收形态与纪律。BoardUI 的 application 产品块映射到影策 AI 工作台场景按需立项(P3),不整层照搬。 +- **沿用既有中性色视觉(初版方向,后被推翻)**:原计划沿用旧中性+状态色 token,不换视觉语言;因配色/字体/间距混乱且无权威数值规范,用户 2026-09-05 决策改为**直接采用 composio-brand-package 视觉**(近黑底 + 电蓝 #0007cd + hairline 分隔 + Inter/等宽),数值全部以该包为准。 + +## 后果 + +- 长线工程:180 文件/41 导入按 B1–B5 推进;新增"地基阶段"先行补齐 token 与排版工具类,组件质量从第一批起就受纪律约束。 +- RAC 作为唯一新增运行时依赖(B3 起);其余沿用既有工具链(cva/cn/motion/lucide),不引入 Radix、recharts 等(图表类延后按需评估,chart token 先行备好)。 +- 自研件与 AntD 皮肤同源观感过渡(同消费三层 token);试点先在画布热区,降低样式漂移风险。 +- 视觉迁移是一次性大转向(近黑底 + 电蓝 accent + Inter):集中在阶段 A 完成 token 层翻译与 app-theme 同步换肤,避免新旧视觉长期并存;light/dark 双主题由同一 Semantic token 推导(规范包为深色单主题,light 角色自行推导)。 +- 依赖下降可量化:每批后重跑清点,数字写进批记录并回写本文档「背景」。 +- Table/DatePicker/Tree/Upload 对等成本最高,B5 强制受控子集评审。 +- 不为旧组件加兼容层、不按皮肤 ID 分叉组件、不建全局 barrel;测试基建暂缓引入,组件逻辑保护按需再评估。 + - 批次与里程碑排期见实施计划 `docs/plans/ui-kit-rollout-plan.mdx`;长期规范与组件 backlog 维护在 `docs/plans/ui-design-system.mdx`(活文档)。 diff --git a/docs/index.md b/docs/index.md index e1d3664ba..8518bc6c2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,6 +32,7 @@ - [ADR-0005:编辑器预设插件架构](adr/0005-editor-preset-plugin-architecture.md):参考 open-vetta「万物皆可插件」,编辑器全部 UI 与能力为预设插件贡献,插件 SDK 演进为 v2(UI 插槽 + 预设分发 + 权限执行校验)。 - [ADR-0006:语音滤镜与项目模板](adr/0006-voice-filters-and-templates.md):补充评估 Concat 剩余两个缺口——语音滤镜走 Web Audio 预览近似 + ffmpeg 音频滤镜图导出,模板走项目快照服务化;均为预设插件且服从单一滤镜图计划。 - [ADR-0007:AI 编辑交互](adr/0007-ai-editing-interaction.md):对话式剪辑作为预设插件——AI 输出受约束命令 JSON(schema 校验 fail-closed),与手势命令同队列同撤销栈;≤3 条直接执行、批量改动 diff 预览待确认。 + - [ADR-0008:自研 UI 组件层与 AntD 替换](adr/0008-ui-component-layer-antd-replacement.md):`components/ui` 重建为自研组件层(Celadon UI),吸收 dbx 契约形态(族目录+barrel)与 BoardUI 设计深度(语义状态矩阵/复合排版/RAC 原语/动效纪律/agentic 产品块),分阶段替换 AntD(依赖面 180 文件/41 导入)最终脱离;试点画布工具条/节点徽章,唯一实施计划见 [plans/ui-kit-rollout-plan.mdx](plans/ui-kit-rollout-plan.mdx),活规范见 [plans/ui-design-system.mdx](plans/ui-design-system.mdx)。 ## 本地协作文档(不随仓库分发) diff --git a/docs/plans/ui-design-system.mdx b/docs/plans/ui-design-system.mdx new file mode 100644 index 000000000..bbeb2601a --- /dev/null +++ b/docs/plans/ui-design-system.mdx @@ -0,0 +1,110 @@ +# Celadon UI 组件规范(自研组件层) + +> 决策记录见 [ADR-0008](../adr/0008-ui-component-layer-antd-replacement.md);唯一实施计划见 [ui-kit-rollout-plan](./ui-kit-rollout-plan.mdx)。本文是长期维护的**组件编写规范与 backlog**。 +> **视觉数值唯一权威源**:`/Users/zhugeyue/Desktop/project/bigdate/github-source-code/composio-brand-package/`(规范包;配色/字体/间距/圆角一律以它为准,消费规则见实施计划 §2.4)。 +> 形态来源:契约与 barrel 取 dbx(`apps/desktop/src/components/ui`);设计深度(语义状态矩阵、复合排版、accent/chart/status 分组、react-aria 原语、动效纪律、application 产品块)取 BoardUI(`components/{base,application,foundations}`)。 + +## 1. 分层与适用范围 + +`web/src/components/ui/` 是自研组件层(Celadon UI),按职责分四层: + +| 层 | 目录 | 内容 | +| --- | --- | --- | +| base | `ui/base/` | 原语 + 基础件:`buttons/`、`badges/`、`kbd/`、`segmented-control/` 等 | +| product | `ui/product/` | 跨页面模式件:`empty-state/`、`callout/`、`loading-state/`、`section-card/`、`property-row/`、`text-tooltip/` | +| fx | `ui/fx/` | 特效件(现 `aceternity/*` 归位至此命名空间,引用随用随迁,不重写) | +| application | `ui/application/` | 产品块(P3 起):agent-chat、notification-center、app-shell 等,对齐 BoardUI application | + +替换完成前 AntD 用法与皮肤仍由 `web/src/lib/app-theme.ts` 统一管理;**同一控件职能不允许两套实现并存超过一个替换周期**。 + +## 2. 目录与导出规范(组件族目录 + 族级 barrel) + +```text +ui/base/buttons/ + ├── index.ts # 族级 barrel:重导出全部组件 + XxxVariants 类型 + ├── button.tsx + ├── icon-button.tsx + ├── tool-button.tsx + └── button-group.tsx +``` + +- 每"族"一目录,族内按文件拆变体;族级 `index.ts` 汇总导出(dbx barrel 形态);**无全局 barrel**。 +- 导入一律深路径 `@/components/ui//`(或 `...//`)。 +- 组件 `PascalCase`、文件 `kebab-case.tsx`、目录 `kebab-case/`。 +- cva 定义 `xxxVariants` 并从 `index.ts` 导出;`className` 经 `cn` 合并;ref 转发;根元素 `data-slot`。 +- 持久切换状态:`aria-pressed` + `data-active`;`type="primary"` 只表"主要命令";保留 `:focus-visible`;尊重 `prefers-reduced-motion`。 + +## 3. Token 义务(含 BoardUI 深度扩展;数值以 composio-brand-package 为权威源) + +- **数值权威源**:新 token 的色值/字号/间距/圆角一律取自规范包(`tokens.json` 优先,`DESIGN.md`/`theme.css` 对照),先翻译进 globals.css 三层 token 再被组件消费;禁止组件/页面直写规范包裸值。字体采用 Inter + 开源等宽(不采用规范包 abcDiatype)。 + +- 只消费三层 token(Primitive→Semantic→Component 的 `var(--*)` 或其 Tailwind 映射);**禁止裸 `text-[Npx]`、裸圆角、硬编码色**。 +- **语义状态矩阵**:高频角色用 `-default/-hover/-active/-disabled`(及 `-selected`)状态位 token,不在组件里推算色值。 +- **复合排版工具类**:字号走 `text-body-*` / `text-caption-1-*` / `text-title-1-*` 等注册类(由 `--fs-*` 映射),禁止裸字号堆叠;`cn` 的 tailwind-merge 已扩展识别这些类。 +- **分组色系**:`accent`(品牌 ramp,默认电蓝 #0007cd + 状态位)、`chart`(`--palette-chart-1..9`)、`status`(`--palette-status-*` 的成对 halo/dot)、`node`(`--palette-node-*`)。 +- 明暗自适应由 CSS 变量完成;不读皮肤 ID、不分叉业务逻辑。新 token 走 globals.css 三层流程并同步 ADR-0008/本文档/实施计划。 + +## 4. 原语 / 图标 / 动效契约 + +- **原语**:简单件用原生语义元素;复杂控件(Select/Modal/Drawer/Popconfirm/日期/Combobox)以 `react-aria-components` 为无障碍地基,包 Celadon 观感。不引入 Radix。 +- **图标**:统一 `lucide-react`,以组件引用传入(`icon={Move}`);尺寸/颜色由宿主组件用语义档位决定,禁止调用方传 icon className 改尺寸。 +- **动效**:按压/切换/浮层出入抽统一工具类或小组件(`press-motion` 思路),统一尊重 `prefers-reduced-motion`。 + +## 5. 组件头注释规范(对齐 BoardUI 溯源注释) + +每组件文件头:用途、消费的 token 组、接管/对标对象(原 AntD 组件或 dbx/BoardUI 组件)、明暗差异要点。 + +## 6. 归属决策表 + +| 场景 | 归属 | +| --- | --- | +| 数据表格、复杂表单、日期、树、上传、大对话框、带搜索下拉 | AntD(B3/B5 替换前保持现状;替换后为 RAC 基底的 Celadon 控件) | +| 纯图标按钮、工具激活态、紧凑分组、尺寸密度 | IconButton / ToolButton / ButtonGroup(base/buttons) | +| 状态点徽章、节点类型徽章 | StatusBadge / NodeTypeBadge(base/badges,对标 AntD Tag/Badge) | +| 空态、加载/进度态、轻量提示横幅 | EmptyState / LoadingState / Callout(product,对标 AntD Empty/Alert) | +| 单行截断悬停全文、快捷键提示 | TextTooltip / Kbd | +| 属性面板行、分区卡片 | PropertyRow / SectionCard | +| 右键菜单、下拉、气泡 | AntD Dropdown/Popover(替换前);B3 后为 RAC 基底控件 | +| 单/多选分段工具切换 | IconGroup / SegmentedControl(base/segmented-control,对标 AntD Segmented) | +| 特效件(aceternity 等) | fx 层命名空间,随用随迁,不重写 | + +## 7. 进库门槛(D8) + +新组件必须满足其一:① ≥2 处真实重复(PR 附 grep 证据);② 隔离明确协议/交互契约(如工具激活态、截断语义)。纯透传壳不进库;P2/P3 组件先立证据再立项。 + +## 8. 组件 backlog + +### P0(阶段 B,已拍板) + +| 组件 | 族目录 | 要点 | 对标 | +| --- | --- | --- | --- | +| IconButton | base/buttons | size 档位、colorRole、loading/dot、图标引用契约 | dbx icon-button | +| ToolButton | base/buttons | `aria-pressed` 工具激活态 | 画布工具注册表 | +| StatusBadge | base/badges | 语义成对 halo/dot + 文本;variant=outline(默认)/filled | BoardUI status-dot + AntD Tag | +| EmptyState | product/empty-state | icon/title/description/action/compact | AntD Empty | +| Callout | product/callout | info/success/warning/error + action + onClose(closable) | dbx ErrorBanner + AntD Alert | +| Kbd | base/kbd | 全语义 token(`--kbd-bg/fg`) | BoardUI kbd | + +### P1(阶段 C) + +NodeTypeBadge(`--palette-node-*`)、TextTooltip、LoadingState、SectionCard、PropertyRow、IconGroup。 + +### P2(B3+ 随替换批次,RAC 基底按需立项) + +Button/Input 全量、Dialog/Modal、Drawer、Popconfirm、Tabs、Combobox/Select、Switch、Tooltip、SegmentedControl 等替代件。 + +### P3(阶段 E,先立证据) + +application 产品块:agent-chat(composer/history/message)、agent-thinking 指示、notification-center、app-shell、data-table——映射影策 AI 工作台场景。 + +### fx + +现有 `aceternity/*` 归位 `ui/fx/` 命名空间(不重写)。 + +## 9. 新组件检查清单 + +- [ ] 落在正确分层;目录 `kebab-case`、族级 `index.ts` 导出 variants 与类型、`data-slot` 根元素、头注释齐备 +- [ ] 样式仅消费既有 token(含状态位/复合排版/分组色系),无裸值;明暗双态冒烟通过 +- [ ] 图标以组件引用传入且由组件定尺寸;动效走统一工具并尊重 reduced-motion +- [ ] 键盘可操作、焦点环可见、持久状态 `aria-pressed` 可感知 +- [ ] 满足进库门槛;PR 描述写明对标对象;替换场景已删私有实现 diff --git a/docs/plans/ui-kit-rollout-plan.mdx b/docs/plans/ui-kit-rollout-plan.mdx new file mode 100644 index 000000000..e7b57f28b --- /dev/null +++ b/docs/plans/ui-kit-rollout-plan.mdx @@ -0,0 +1,223 @@ +# Celadon UI 自研组件层实施计划(唯一方案) + +状态:已接受(2026-09-05)。关联:决策记录 [ADR-0008](../adr/0008-ui-component-layer-antd-replacement.md);组件编写规范 [ui-design-system](./ui-design-system.mdx)。 + +**视觉数值权威源(AI 消费入口)**:`/Users/zhugeyue/Desktop/project/bigdate/github-source-code/composio-brand-package/`(与 open-ai-canvas 同级的兄弟仓库目录,下称「规范包」)。改造本项目配色/字体/间距时,**数值一律以该规范包为唯一权威源**,消费规则见 §2.4,禁止凭记忆或截图自拟色值/字号/间距。 + +本方案是本次 UI 治理的**唯一实施计划**,统管两件事:① 把 `web/src/components/ui` 重建为自研组件层(代号 Celadon UI);② 按功能对等批次替换 AntD,最终脱离 `antd` 依赖。参考对象:**composio-brand-package**(唯一视觉数值权威源,直接采用其配色/字体/间距/圆角体系)+ **dbx**(组织与契约纪律,优秀项保留)+ **BoardUI**(设计深度:语义 token 状态矩阵、复合排版工具、react-aria 无障碍原语、动效纪律、agentic 应用层,弥补 dbx 短板)。 + +## 1. 目标与非目标 + +目标: +- 一套**可继承进项目日常开发**的自研组件层:目录规范、token 义务、变体契约、无障碍与动效纪律齐备,页面只消费 `@/components/ui/*`。 +- AntD 依赖按批次归零;过程中任何时刻不出现"同一控件两套观感并存"。 +- 组件观感以规范包(composio 视觉:近黑底 + 电蓝主色 + Inter/等宽字体)为唯一数值基准,迁移期内与 AntD 皮肤同源过渡(同一套三层 token),无裸值。 + +非目标: +- 不照搬 dbx/BoardUI 参考代码(dbx 是 Vue,BoardUI 是独立设计系统);只吸收“形态与规范”。规范包**只取其数值与视觉体系,不取代码**(其为独立 CSS 变量集,落地时翻译进本项目三层 token)。 +- 本轮(B1 周期)不启动 Table/DatePicker/Tree/Upload 等重型自研;它们进入 B5 专项评审。 +- 不引入 Radix、不重建样式基座、不新增全局 barrel、不为旧组件加兼容层。 + +## 2. 参考吸收对照(决策依据) + +### 2.1 dbx(`apps/desktop/src/components/ui`,shadcn-vue/reka-nova 风格)——优秀项保留 + +| dbx 特征 | 处置 | +| --- | --- | +| 目录即组件族 + `index.ts` barrel 集中导出 variants | ✅ 保留(见 §5 目录规范) | +| cva variants 作为公共 API 契约(props 即变体) | ✅ 保留(本项目已用 cva ^0.7) | +| 紧凑桌面密度档位(h-6…h-9、icon-xs/sm/lg) | ✅ 保留,映射到既有 `--space-*`/`--r-*` token | +| 复合模式件下沉(ErrorBanner/LightTooltip 等思路) | ✅ 保留为"模式件"职责(Callout/TextTooltip 等) | +| 语义状态色扩展 + 自定义 radius 命名 | ✅ 保留,本项目 globals.css 已有等价 `--palette-status-*`/`--r-*` | + +### 2.2 dbx 不足处 —— 用 BoardUI 丰富 + +| dbx 短板 | BoardUI 做法 | 采纳 | +| --- | --- | --- | +| 目录处于半迁移状态(19 个组件目录旁仍散落 8 个顶层扁平 `.vue`/`.ts` 遗留件) | 顶层三分类 `base / application / foundations`,全组件进分类 | ✅ 本方案 §4 采用"base 原语 / product 模式件 / application 产品块"分层 | +| token 粒度粗:单角色色值,交互态(hover/active/disabled)分散在组件里 | 语义 token 状态矩阵:`foreground/text/background/border × default/hover/active/disabled`,明暗只翻 token 不翻 class | ✅ §6.1 语义状态矩阵扩展 | +| 无统一排版梯度,组件内散落 `text-[13px]` 式裸字号 | `typography.css` 把整套 Figma 字号梯度注册为复合工具类 `text-body-medium`、`text-title-1-semibold`…(经 tailwind-merge 扩展识别) | ✅ §6.2 复合排版工具类 | +| 无系统级换肤 | `@theme` 声明 accent ramp,运行时整体 re-tint;chart/status 各自 hue-lock | ✅ §6.3 accent/chart/status 分组 | +| Vue + reka-ui,无 AntD 语境 | React Aria(`react-aria-components`)作表单/浮层原语,键盘/焦点/ARIA 开箱即得 | ✅ §7 原语策略:复杂控件替换以 RAC 为地基 | +| 图标由调用方传任意尺寸 class | Icon 以**组件引用**传入,尺寸/颜色由宿主组件用 token 决定(`@remixicon/react`) | ✅ §8 图标契约(本项目沿用 lucide-react) | +| 动效无统一纪律 | `button-press-motion` 等工具类 + `::before` 渐变悬停交叉淡化 + `prefers-reduced-motion` | ✅ §9 动效纪律(本项目已有 motion v12) | +| 组件注释缺溯源 | 每个组件头注释标注 Figma 源节点、尺寸表、明暗差异 | ✅ §10 注释规范(引用 token 组 + 对标对象) | +| 无"agentic 界面"沉淀 | `application/agent-chat`(composer/history/message)、`agent-thinking`、`notification-center`、`app-shell`、`data-table` 等产品块 | ✅ §12 backlog 的 P3 application 层(对齐影策 AI 工作台) | + +### 2.3 BoardUI 事实基线(2026-09-05 勘察) + +- 分层目录:`components/{base,application,foundations}`;base 下每"族"一目录(`buttons/`、`badges/`、`kbd/`、`segmented-control/`、`date-picker/`…),族内平铺多个文件(`button.tsx`/`icon-button.tsx`/`close-button.tsx`/`button-group.tsx`),无全局 barrel,深路径导入。 +- 每组件:`interface XxxProps`(含 `ref`)+ 具名函数 + `cx` 合并 + 头部注释(Figma 源节点/尺寸/明暗差异)。变体用本地 style map(`sortCx`),不用 cva——**本项目沿用 cva**(既有习惯 + variants 类型导出更贴合 dbx barrel 形态)。 +- 语义 token 命名如 `bg-kbd-background`、`text-kbd-foreground`、`text-caption-1-semibold`、`bg-status-dot-green-halo`;radius 语义名 `radius/md`、`radius/2lg`;暗色仅调 halo 透明度等细节。 +- 依赖:`react-aria-components ^1.17`、`@remixicon/react ^4.9`、`@tanstack/react-table`、`recharts`、`@internationalized/date`、`motion ^12`、`tailwind-merge ^3`。 +- 形态定位:"agentic interfaces"(chat/thinking/agent log/composer/sidebar)——与影策 AI 影视短剧工作台高度同构。 + +### 2.4 规范包(composio-brand-package)——唯一视觉数值权威源(AI 消费规则) + +> 用户决策(2026-09-05):**直接采用 composio 视觉**(近黑底 + 电蓝主色 + 无衬线/等宽字体),数值全部以该包为准。 + +**消费顺序(AI 每次改造配色/字体/间距前必读,由浅入深)**: +1. `preview.html`——浏览器打开,先看整体视觉基调(近黑 #0f0f0f 底、电蓝 #0007cd 主色、hairline 分隔、终端质感); +2. `DESIGN.md`——人类可读规范:语义角色 token 表、排版梯度、组件配方、Do/Don'ts(禁止引入色板外颜色等纪律条款); +3. `tokens.json`——W3C DTCG 机器可读格式,数值唯一权威(颜色/字号/间距/圆角的 $value 一律以这里为准); +4. `variables.css` / `theme.css`——CSS 变量落地 + Tailwind v4 @theme 映射,作为翻译进本项目三层 token 的对照样本。 + +**采用边界**: +- 采用:语义角色命名(ink/body/muted/canvas/surface-*/hairline/on-*/primary/accent-*)、完整状态位(-default/-hover/-active/-disabled)、字号梯度、间距/圆角尺度、深色底 + 电蓝 accent 的整体视觉方向; +- 不采用:其品牌名/Logo/营销文案、abcDiatype 字体(无授权,替换为 Inter + 系统栈;代码用 JetBrains Mono/Fira Code 等开源等宽,本项目已有的 lucide/icon 不变); +- 落地规则:数值翻译进本项目 globals.css 三层 token(Primitive→Semantic→Component),组件继续只消费 `var(--*)`/工具类,**不允许在页面/组件里直接写规范包裸值**; +- 明暗:规范包为深色单主题,本项目 light/dark 双主题照旧——light 由同一 Semantic 角色推导(组件不读皮肤 ID,明暗只翻 token 层)。 + +## 3. 现状基线(证据) + +- `web/src/components/ui/`:仅 `aceternity/`(特效装饰件)+ 3 个顶层单文件(`animated-theme-toggler.tsx`、`announcement-content.tsx`、`dia-text-reveal.tsx`),无 barrel、无变体契约。 +- 主题基建:`web/src/styles/globals.css`(约 892 行 CSS 变量声明)三层架构(Primitive→Semantic→Component),含 `--color-neutral-*` 色板、`--palette-node-*`、`--palette-status-*`、`--space-*`、`--fs-*`(nano→display)、`--r-*`(xs→5xl + 语义圆角)等;`@custom-variant dark`;tw-animate-css + shadcn/tailwind.css。 +- AntD 皮肤由 `web/src/lib/app-theme.ts` 统一管理(light/dark + skins)。 +- 工具链:`cn`(clsx+tailwind-merge)就绪(`web/src/lib/utils.ts`);cva 可用;`motion ^12.42.2`、`lucide-react ^1.25`、`antd ^6.5.1`、`tailwindcss ^4.3.3`。 +- AntD 依赖面(清点脚本复核,数字随 ADR):约 180/637 文件导入 AntD、41 个命名导入,Top 为 Button/App/Input/Modal/Select。 +- 视觉混乱面(本方案治理对象):配色/字体/间距散落于 globals.css 既有 token 之外,组件与页面存在裸 `text-[13px]`、硬编码色、非标间距/圆角——以规范包数值对齐收敛。 + +## 4. 目标架构(分层) + +```text +web/src/ + styles/globals.css # 三层 token(Primitive→Semantic→Component)+ @theme 工具映射(不改基座,只扩展) + lib/app-theme.ts # AntD 皮肤唯一管理点(替换完成前继续使用) + lib/utils.ts # cn;按需扩展 tailwind-merge 识别复合排版工具类 + components/ui/ # Celadon UI + base/ # 原语 + 基础件:buttons/ badges/ kbd/ segmented-control/ … + product/ # 模式件:empty-state/ callout/ loading-state/ section-card/ property-row/ … + fx/ # 特效件(现 aceternity/ 归位命名空间,引用随用随迁) + application/ # 产品块(P3 起):agent-chat/ notification-center/ app-shell/ …(对齐 BoardUI application) +``` + +导入约定:一律深路径 `@/components/ui/{base,product,fx,application}//`(或族级 barrel);不建全局 barrel。 + +## 5. 目录与导出规范(沿用 dbx 形态 + BoardUI 族目录) + +```text +components/ui/base/buttons/ + ├── index.ts # 重新导出族内全部组件 + variants 类型(族级 barrel) + ├── button.tsx # Button(cva variants) + ├── icon-button.tsx # IconButton(本族专用,不另开目录) + ├── tool-button.tsx # ToolButton(aria-pressed 工具态) + └── button-group.tsx # ButtonGroup +``` + +- 组件族一目录,族内按文件拆分相关变体;每族 `index.ts` 汇总导出(同 dbx);无全局 barrel。 +- 变体契约:cva 定义 `xxxVariants` 并从 `index.ts` 导出 `XxxVariants` 类型;`className` 经 `cn` 合并;forwardRef 透传;根元素 `data-slot`。 +- 命名:组件 `PascalCase`、文件 `kebab-case.tsx`、目录 `kebab-case/`。 + +## 6. token 扩展规范(对齐 BoardUI 深度;数值以规范包为权威,在既有三层上扩展) + +### 6.1 语义状态矩阵(Semantic 层补齐) +为高频角色补齐 `-default/-hover/-active/-disabled`(及可选 `-selected`)状态位,命名 `----` 风格(如 `--control-bg-hover`),消费方直接用状态位,不在组件里做色值推算。明暗只改 token 定义,组件不读皮肤 ID。 + +### 6.2 复合排版工具类(Component 层注册 @theme) +把既有 `--fs-*` 字号 + 行高/字重映射为 `text-body-regular|medium|semibold`、`text-caption-1-*`、`text-title-1-*` 等**复合工具类**(Tailwind v4 `@theme` 或工具类层注册),并扩展 `cn` 的 tailwind-merge 配置使其可正确合并。**组件禁止再写裸 `text-[Npx]`/`text-sm font-medium` 组合**(对齐 BoardUI typography.css 纪律)。 + +### 6.3 分组色系(沿用既有 --palette-* 思路,扩展完整 ramp) +- `accent`:品牌 ramp 默认取规范包主色 `#0007cd`(含 -hover/-active/-glow 等状态位,见 tokens.json);不再沿用旧中性主色。 +- `chart`:图表系列 `--palette-chart-1..9` + `-active`(供后续图表块使用)。 +- `status`:在既有 `--palette-status-*` 上按 BoardUI 语义补齐成对 halo/dot 类 token(如 `--status-success-halo`)。 +- `node`:沿用 `--palette-node-*`(画布节点类型),NodeTypeBadge 消费。 + +### 6.4 专用 token +组件专用语义 token(如 `--kbd-bg/--kbd-fg`、`--segmented-track`、`--toolbar-bg`)按需在 Component 层新增并在组件头注释标注引用组。所有新 token 走 globals.css 三层流程并同步 ADR/规范文档。 + +## 7. 原语策略(BoardUI 贡献的关键决策) + +- **简单件**(按钮、徽章、空态、kbd…):原生语义元素(`button`/`kbd`/`span`)+ 自有键盘/ARIA 处理(`aria-pressed`、`:focus-visible`)。 +- **复杂控件替换(B3+:Select/Modal/Drawer/Popconfirm/日期/Combobox)**:以 **`react-aria-components`** 为无障碍地基(对齐 BoardUI segmented-control/date-picker 做法),在其上包 Celadon 观感与 token——不自研焦点陷阱/键盘导航/ARIA,也不引入 Radix 全家桶。`react-aria-components` 在 B3 启动时作为正式依赖加入。 + +## 8. 图标契约 + +- 统一 `lucide-react`(项目既有),**以组件引用传入**(`icon={Move}`),尺寸/颜色由宿主组件用 token 决定;禁止调用方传 icon className 改尺寸(对齐 BoardUI remixicon 引用契约)。`IconButton`/`ToolButton` 提供 `iconSize` 语义档位而非裸像素。 + +## 9. 动效纪律(项目已有 motion v12) + +- 按压动效、工具切换、浮层出入:抽 `press-motion`/入场/出场工具类或小组件(对齐 BoardUI `button-press-motion` 思路),**统一尊重 `prefers-reduced-motion`**。 +- 渐变/悬停细节(如 BoardUI 的 `::before` 交叉淡化)抽成可复用类,不在页面散写。 + +## 10. 组件头注释规范(对齐 BoardUI Figma 溯源注释) + +每组件文件头:用途、消费的 token 组、接管/对标对象(原 AntD 组件或 dbx/BoardUI 组件)、明暗差异要点(如有)。页面私有组件不含此注释义务。 + +## 11. 里程碑与批次(唯一路线) + +### 阶段 A:地基(M0)——并入本方案的 token/契约升级 +1. `globals.css`:**按 §2.4 消费规范包**,把配色/字号/间距/圆角数值翻译进三层 token(近黑底 + 电蓝 accent + hairline 分隔),新增语义状态矩阵、复合排版工具类、chart/status 成对 token(新增项见 §6)。 +2. `utils.ts`:扩展 tailwind-merge 识别复合排版工具类。 +3. `components/ui/README.md` 规范落文字(本文 §5–§10 浓缩)。 +4. 样板组件 **Kbd**(对齐 BoardUI kbd:`bg-kbd-background`/`text-kbd-foreground` 全语义)。 + +验证:`bun run build` 通过;新 token 与现有 AntD 皮肤明暗双态冒烟无回归;抽查页面无裸色值/裸字号残留。 + +### 阶段 B:P0 六组件 + 试点(M1–M2,第一个可评审 PR 取 Kbd/StatusBadge/EmptyState 子集) +| 组件 | 族目录 | 要点 | 对标 | +| --- | --- | --- | --- | +| IconButton | base/buttons | size 档位、colorRole、loading/dot | dbx icon-button + BoardUI 图标契约 | +| ToolButton | base/buttons | `aria-pressed` 工具激活态 | 画布工具注册表 | +| StatusBadge | base/badges | 语义成对 halo/dot + 文本 | BoardUI status-dot + AntD Tag 状态场景 | +| EmptyState | product/empty-state | icon/title/description/action | AntD Empty | +| Callout | product/callout | info/success/warning/error + action | dbx ErrorBanner + AntD Alert | +| Kbd | base/kbd | 全语义 token | BoardUI kbd | + +试点热区(M2):画布工具条/工具注册表、节点状态徽章、画布空态与提示;替换即删除私有实现。 + +### 阶段 C:P1 模式件 + 页面铺开(M3–M4) +NodeTypeBadge、TextTooltip、LoadingState、SectionCard、PropertyRow、IconGroup(接管工具 Segmented)→ 资源/任务/项目库页接线,重复模式清零。 + +### 阶段 D:AntD 替换批次(B1–B5;B1 = 阶段 B/C 自研件顺带承接 Tag/Empty/Alert/Segmented/高密度按钮职能) +| 批次 | 内容 | 门禁(对应 antd 导入 → 0 或专项豁免;无双实现并存超一个周期;明暗无回归) | +| --- | --- | --- | +| B2 | Button/Input/InputNumber/Typography 等高频简单件 | Button/Input 计数归零 | +| B3 | Select/Modal/Drawer/Popconfirm/Switch/Tooltip/Dropdown/Tabs——**以 RAC 为地基** | Select/Modal 计数归零;键盘/焦点/ESC/遮罩逐项过 | +| B4 | Form 字段原语与布局、App(useApp 宿主语义)、ConfigProvider 收敛 | App/Form 归零 | +| B5 | 专项评审:Table/DatePicker/Tree/Upload/ColorPicker(受控子集 vs 对等实现) | antd 总导入归零 | + +每批完成重跑依赖清点,把“依赖下降”数字写进批记录。批内按页面热区拆小 PR。 + +**执行状态(2026-09-05)**: + +- M1(阶段 B):P0 六组件全部入库——Kbd / StatusBadge / EmptyState / IconButton / ToolButton / Callout(含 action、onClose);完整 `bun run build` 通过。 +- M2 试点接线:EmptyState → `ai-art-critique-modal`、`workflow-test-workbench`、admin drawer、workflow 三页等;Kbd → 侧边栏/快捷键弹窗/命令面板;StatusBadge → OSS 设置表单;Callout/IconButton → `ai-art-critique-modal`。 +- B1 周期实际推进:AntD Empty 全量归零(12 处);Alert → Callout 全量归零(18 处,唯一保留 `detail.tsx` 归档 banner 横幅);被替换文件同步剔除对应 antd import。 +- **第二轮收尾(同日)**:StatusBadge 增加 `variant="filled"`(tone 色块纯文本胶囊,对标 AntD filled Tag;outline 保持现状零回归);状态徽章 Tag → StatusBadge 全部清零(累计 22 处:首轮 14 + ArtifactStatus / logical-models×2 / storyboard / prompt-preferences / local-cli×2 / portrait×4);ArtifactStatus 改消费 filled、`workflow.css` 死代码块删除。全库剩余 Tag 均为分类/信息/icon/计数 chips 或定制着色,非状态语义。 +- 小件盘点:Badge 全库零使用;Spin 4 处异形;Segmented 为结构性控件——无新归并项。P1 候选(NodeTypeBadge / LoadingState / SectionCard / PropertyRow / IconGroup)盘点后证据不足或形态异质,均不立项,待真实重复自然积累。 +- UI kit 引用文件 20+;每批 `bun run typecheck` + dev server 编译(HTTP 200)验证通过。git 改动未提交,批次提交按 §ADR 约定进行。 +- **下一闸口**:composio Stage A token 迁移(globals.css 数值翻译 + antd skin 同步),改动会全局位移视觉,需用户目验确认后再动。 + +### 阶段 E:application 产品块(P3,按需立项) +对齐 BoardUI `application/`:agent-chat(composer/history/message)、agent-thinking 指示、notification-center、app-shell、data-table 等,映射影策 AI 工作台场景(画布 AI 助手面板、生成/转写日志、通知中心)。每块立项前先有真实页面需求与 ≥2 处重复证据。 + +## 12. 组件 backlog(汇总) + +- P0(阶段 B):IconButton、ToolButton、StatusBadge、EmptyState、Callout、Kbd。 +- P1(阶段 C):NodeTypeBadge、TextTooltip、LoadingState、SectionCard、PropertyRow、IconGroup。 +- P2(B3+,随替换批次):Button/Input 全量、Dialog/Modal、Drawer、Popconfirm、Tabs、Combobox/Select、Switch、Tooltip、SegmentedControl 等替代件(每个以 RAC 或原生实现按需立项)。 +- P3(阶段 E,先立证据):application 产品块。 +- fx:现有 `aceternity/*` 归位 `components/ui/fx/` 命名空间(引用随用随迁,不重写)。 + +## 13. 验收清单(用户可检查) + +- [ ] `components/ui/{base,product,fx}` 分层就位;每族有 `index.ts`、组件有 `data-slot` 与头注释 +- [ ] 试点热区换用新组件且无私有实现残留;明暗与各 skin 无回归 +- [ ] 新组件无裸 `text-[Npx]`/`rounded-[Npx]`/硬编码色;排版走复合工具类 +- [ ] 键盘 Tab/回车/空格可操作、焦点环可见、持久态 `aria-pressed` 可感知、reduced-motion 生效 +- [ ] `bun run build` 通过;每批依赖清点数字落进批记录(ADR 后果同步更新) +- [ ] token/规范变更已同步 ADR-0008、ui-design-system.mdx 与本方案 §2.4;配色/字体/间距数值可回溯到规范包 tokens.json +- [ ] 页面抽查无裸色值/裸字号/非标间距;light/dark 双态由同一 Semantic token 推导 +- [ ] token/规范变更已同步 ADR-0008 与 ui-design-system.mdx + +## 14. 风险与回退 + +| 风险 | 缓解 | 回退 | +| --- | --- | --- | +| 战线过长观感分裂 | 批次门禁 + app-theme 统一皮肤 | 停靠已验收批次,未接管控件继续 AntD | +| 范围蔓延 | 进库门槛(≥2 处重复或协议隔离) | 停在当前阶段 | +| token 扩展影响现有皮肤 | 只增不改、双主题冒烟 | 撤销新增 token,组件引用不动 | +| 规范包数值与既有观感冲突(视觉转向大) | 阶段 A 集中迁移 + app-theme 同步换肤,一次性过渡 | 停靠已验收批次,先回滚视觉迁移 | +| RAC 引入学习成本 | B3 才引入;先行 1 个样板(SegmentedControl)评审 | 该批暂缓,AntD 继续 | +| Table/DatePicker 自研失控 | B5 强制受控子集评审 | 专项豁免并记录 | +| 图标尺寸失控 | 图标引用契约(§8)评审强制 | 单组件回退 | diff --git a/web/bun.lock b/web/bun.lock index a82af0ffd..d6ff6dd59 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -12,6 +12,8 @@ "@ffmpeg/core": "0.12.10", "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "@lobehub/icons": "5.16.0", "@mediapipe/tasks-vision": "^1.0.1", "@react-three/drei": "^10.7.7", @@ -47,6 +49,7 @@ "motion": "^12.42.2", "nanoid": "^6.0.0", "react": "19.2.7", + "react-aria-components": "^1.21.1", "react-dom": "19.2.7", "react-markdown": "^10.1.0", "react-router": "^8.2.0", @@ -277,6 +280,10 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "https://registry.npmmirror.com/@fontsource-variable/inter/-/inter-5.3.0.tgz", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="], + + "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.3.0", "https://registry.npmmirror.com/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="], + "@giscus/react": ["@giscus/react@3.1.0", "https://registry.npmmirror.com/@giscus/react/-/react-3.1.0.tgz", { "dependencies": { "giscus": "^1.6.0" }, "peerDependencies": { "react": "^16 || ^17 || ^18 || ^19", "react-dom": "^16 || ^17 || ^18 || ^19" } }, "sha512-0TCO2TvL43+oOdyVVGHDItwxD1UMKP2ZYpT6gXmhFOqfAJtZxTzJ9hkn34iAF/b6YzyJ4Um89QIt9z/ajmAEeg=="], "@hono/node-server": ["@hono/node-server@1.19.14", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.14.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], @@ -285,6 +292,12 @@ "@iconify/utils": ["@iconify/utils@3.1.4", "https://registry.npmmirror.com/@iconify/utils/-/utils-3.1.4.tgz", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw=="], + "@internationalized/date": ["@internationalized/date@3.12.4", "https://registry.npmmirror.com/@internationalized/date/-/date-3.12.4.tgz", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ=="], + + "@internationalized/number": ["@internationalized/number@3.6.8", "https://registry.npmmirror.com/@internationalized/number/-/number-3.6.8.tgz", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig=="], + + "@internationalized/string": ["@internationalized/string@3.2.10", "https://registry.npmmirror.com/@internationalized/string/-/string-3.2.10.tgz", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -657,6 +670,8 @@ "@react-three/fiber": ["@react-three/fiber@9.6.1", "https://registry.npmmirror.com/@react-three/fiber/-/fiber-9.6.1.tgz", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg=="], + "@react-types/shared": ["@react-types/shared@3.36.1", "https://registry.npmmirror.com/@react-types/shared/-/shared-3.36.1.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], @@ -725,6 +740,8 @@ "@streamdown/code": ["@streamdown/code@1.1.1", "https://registry.npmmirror.com/@streamdown/code/-/code-1.1.1.tgz", { "dependencies": { "shiki": "^3.19.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-i7HTNuDgZWb+VdrNVOam9gQhIc5MSSDXKWXgbUrn/4vSRaSMM+Rtl10MQj4wLWPNpF7p80waJsAqFP8HZfb0Jg=="], + "@swc/helpers": ["@swc/helpers@0.5.23", "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.23.tgz", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.3.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.3.3.tgz", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], @@ -1137,6 +1154,8 @@ "cli-spinners": ["cli-spinners@2.9.2", "https://registry.npmmirror.com/cli-spinners/-/cli-spinners-2.9.2.tgz", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + "client-only": ["client-only@0.0.1", "https://registry.npmmirror.com/client-only/-/client-only-0.0.1.tgz", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "code-block-writer": ["code-block-writer@13.0.3", "https://registry.npmmirror.com/code-block-writer/-/code-block-writer-13.0.3.tgz", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], @@ -2097,6 +2116,10 @@ "react": ["react@19.2.7", "https://registry.npmmirror.com/react/-/react-19.2.7.tgz", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + "react-aria": ["react-aria@3.52.1", "https://registry.npmmirror.com/react-aria/-/react-aria-3.52.1.tgz", { "dependencies": { "@internationalized/date": "^3.12.4", "@internationalized/number": "^3.6.8", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.36.1", "@swc/helpers": "^0.5.0", "aria-hidden": "^1.2.3", "clsx": "^2.0.0", "react-stately": "3.50.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-fdZZruC9/x/joCg0mhKGs5aHpwrXLCSZ4GOJmhhYyiE0ffyEsk9MLFt9LCOCF9tn8ErTD+UDQP4oDUSlZkmpCg=="], + + "react-aria-components": ["react-aria-components@1.21.1", "https://registry.npmmirror.com/react-aria-components/-/react-aria-components-1.21.1.tgz", { "dependencies": { "@internationalized/date": "^3.12.4", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.36.1", "@swc/helpers": "^0.5.0", "client-only": "^0.0.1", "react-aria": "3.52.1", "react-stately": "3.50.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-J88WflYY0z+EhLX0ce+GjFlQ3ag3ubIgfUTjpKSrBQBu92Xtl4Ub9o118HItUddtbk1Ido0jzyPy94/klZy1hg=="], + "react-avatar-editor": ["react-avatar-editor@15.1.0", "https://registry.npmmirror.com/react-avatar-editor/-/react-avatar-editor-15.1.0.tgz", { "peerDependencies": { "react": "^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Zto7u9l6Wd5LPPtjeFJ+7uwoT4bs01OSgkN2kxD18lWl8IiZ0GY3nWCbKPx4qIU7Au1vENsMJm19rfVWHHayaQ=="], "react-colorful": ["react-colorful@5.8.0", "https://registry.npmmirror.com/react-colorful/-/react-colorful-5.8.0.tgz", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-Wy9OzPfjSN9bF12OB8N7UQvlsZ0I+7wHxpN+bV5BjNQGxOj6IiwkRjevJK9yOBjJWGQvAaf1OXtn8rUeEatAng=="], @@ -2129,6 +2152,8 @@ "react-router": ["react-router@8.2.0", "https://registry.npmmirror.com/react-router/-/react-router-8.2.0.tgz", { "dependencies": { "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-uP1LgGNHyilL1u+ZkecQk74DJOKOb6q4NLNYLRJcD/fjoogftNb5/Rj/07o/QBFsY/5MbAKPm1peTCQuVPv9cQ=="], + "react-stately": ["react-stately@3.50.0", "https://registry.npmmirror.com/react-stately/-/react-stately-3.50.0.tgz", { "dependencies": { "@internationalized/date": "^3.12.4", "@internationalized/number": "^3.6.8", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.36.1", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-TnckvpDQGU0672wEaLZqdzvhuSeXWjgW6vijMWxiKOxc8CosQUfPGISdcZyfHqjX3XMjEtRxj4w9HumvX4h4mw=="], + "react-style-singleton": ["react-style-singleton@2.2.3", "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "react-syntax-highlighter": ["react-syntax-highlighter@16.1.1", "https://registry.npmmirror.com/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", { "dependencies": { "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^5.0.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA=="], diff --git a/web/package.json b/web/package.json index a233137fd..fcb0409b0 100644 --- a/web/package.json +++ b/web/package.json @@ -24,6 +24,8 @@ "@ffmpeg/core": "0.12.10", "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "@lobehub/icons": "5.16.0", "@mediapipe/tasks-vision": "^1.0.1", "@react-three/drei": "^10.7.7", @@ -59,6 +61,7 @@ "motion": "^12.42.2", "nanoid": "^6.0.0", "react": "19.2.7", + "react-aria-components": "^1.21.1", "react-dom": "19.2.7", "react-markdown": "^10.1.0", "react-router": "^8.2.0", diff --git a/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx b/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx index 45f61fcbb..1ff3cbf8e 100644 --- a/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx +++ b/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx @@ -1,4 +1,5 @@ -import { Alert, Button, Empty, Modal, Tag } from "antd"; +import { Button, Modal, Tag } from "antd"; +import { EmptyState } from "@/components/ui/product/empty-state"; import { ArrowLeft, ArrowRight, CheckCircle2, ChevronDown, ChevronRight, Copy, FileText, Image as ImageIcon, LoaderCircle, RefreshCw, Sparkles, Target, X } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -27,6 +28,8 @@ import { modelOptionLabel, useEffectiveConfig } from "@/stores/use-config-store" import { usePluginStore } from "@/stores/use-plugin-store"; import { useThemeStore } from "@/stores/use-theme-store"; import type { CanvasNodeData } from "@/types/canvas"; +import { IconButton } from "@/components/ui/base/buttons"; +import { Callout } from "@/components/ui/product/callout"; type AiArtCritiqueModalProps = { node: CanvasNodeData | null; @@ -296,7 +299,7 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat : "尚未分析"} - : } /> : null} - {runtimeReady && runtimeError ? setRuntimeError("")} /> : null} - {state.mode === "network-search" && runtimeStatus?.browser.available === false ? : null} + {!runtimeReady && enabled ? } loading={installingModels} onClick={() => void installModels()}>{installingModels ? "下载中…" : "下载本地模型"} : }>{runtimeConnecting ? "正在自动建立本机连接,请稍候。" : runtimeStatus ? (installingModels ? "正在下载并校验本地肖像模型,请稍候。" : runtimeError || "请下载本地肖像模型后再开始排查。") : portraitRuntimeErrorMessage(runtimeError) || "正在自动重连本机引擎,请确认 Canvas Agent 已启动。"} : null} + {runtimeReady && runtimeError ? setRuntimeError("")}>{runtimeError} : null} + {state.mode === "network-search" && runtimeStatus?.browser.available === false ? 网络排查需要系统 Chrome、Edge 或 Chromium;也可以先连接手动候选,任务不会静默切换搜索服务。 : null}

当前结果

完整结果会在本机任务完成后生成;节点 metadata 只保留可恢复摘要。

- {state.task ? {state.task.status === "failed" ? "失败" : state.task.status === "cancelled" ? "已停止" : PORTRAIT_TASK_STAGE_LABELS[state.task.stage] || state.task.stage} : null} + {state.task ? : null}
- {state.task?.status === "failed" && state.task.errorMessage ? : null} + {state.task?.status === "failed" && state.task.errorMessage ? {state.task.errorMessage} : null} {state.lastResult ? <>
{result ? :
正在读取本机详细结果,图片和完整模型响应不会写入画布 metadata。
} :

{canStart ? "准备好开始排查" : "先连接所需图片"}

本地模式会在 Canvas Agent 中执行,关闭工作台不会取消任务。

}

历史运行

- {history.length ?
{history.map((item) =>
{item.mode === "direct-compare" ? "直接比对" : "网络排查"}{PORTRAIT_TASK_STAGE_LABELS[item.stage] || item.status}{item.processedCandidates}{item.totalCandidates ? ` / ${item.totalCandidates}` : ""} 项{formatDate(item.updatedAt)} void removeHistory(item.taskId)}>
)}
:
暂无当前节点历史。不会建立跨项目的人脸身份索引。
} + {history.length ?
{history.map((item) =>
{item.mode === "direct-compare" ? "直接比对" : "网络排查"}{item.processedCandidates}{item.totalCandidates ? ` / ${item.totalCandidates}` : ""} 项{formatDate(item.updatedAt)} void removeHistory(item.taskId)}>
)}
:
暂无当前节点历史。不会建立跨项目的人脸身份索引。
}
@@ -350,7 +353,7 @@ function PortraitInputCard({ label, node, ready }: { label: string; node?: Canva
{label}
{node?.title || (ready ? "已连接图片" : "待连接")}
- {ready ? "已连接" : "待连接"} + ; } @@ -417,7 +420,7 @@ function ResultPairCard({ taskId, pair, candidate, onAddCandidate }: { taskId: s
{image ? : }
-
{candidate?.title || pair.comparisonImageId}{PORTRAIT_RISK_LABELS[pair.riskLevel]}{pair.status === "failed" ? "失败/跳过" : pair.status === "partial" ? "部分完成" : "完成"}
+
{candidate?.title || pair.comparisonImageId}{PORTRAIT_RISK_LABELS[pair.riskLevel]}
{candidate?.sourceDomain || pair.source} · {pair.analysisPath === "unable" ? "无法形成可靠路径" : `Path ${pair.analysisPath}`}{pair.overallSimilarity === undefined ? "" : ` · 相似度 ${pair.overallSimilarity.toFixed(4)}`}
{candidate?.sourcePageUrl ? : null}{onAddCandidate && candidate && image ? : null}
@@ -427,7 +430,7 @@ function ResultPairCard({ taskId, pair, candidate, onAddCandidate }: { taskId: s {pair.localPrecheck.reliabilityIssues.length ?
可靠性问题:{pair.localPrecheck.reliabilityIssues.join(";")}
: null} {pair.error ?
处理说明:{pair.error.message}
: null} {vision ? <> -
多模态面部特征分析{vision.status === "success" ? "已完成" : "无法可靠判断"}{vision.manualReviewRecommended ? 建议人工复核 : null}
+
多模态面部特征分析{vision.manualReviewRecommended ? : null}
{PORTRAIT_FEATURES.map(({ key, label }) => { const feature = vision.featureComparison[key]; return ; })}
特征维度相似度分析说明
{label}{PORTRAIT_FEATURE_SIMILARITY_LABELS[feature.similarity]}{feature.note || "-"}
视觉依据:{vision.basis.join(";") || "无"}
:
{pair.basis.join(";") || "暂无结构化依据"}
} diff --git a/web/src/components/canvas/storyboard-assets-cell.tsx b/web/src/components/canvas/storyboard-assets-cell.tsx index 020dc8516..b29feb2f3 100644 --- a/web/src/components/canvas/storyboard-assets-cell.tsx +++ b/web/src/components/canvas/storyboard-assets-cell.tsx @@ -1,5 +1,7 @@ +import { Modal } from "antd"; +import { Tooltip } from "@/components/ui/base/tooltip"; import { useEffect, useMemo, useState } from "react"; -import { Modal, Tooltip } from "antd"; + import { Image as ImageIcon, Music2, Play, UserRound } from "lucide-react"; import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview"; diff --git a/web/src/components/canvas/style-asset-binding-modal.tsx b/web/src/components/canvas/style-asset-binding-modal.tsx index c0df441fe..19a2c16cf 100644 --- a/web/src/components/canvas/style-asset-binding-modal.tsx +++ b/web/src/components/canvas/style-asset-binding-modal.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; -import { Button, Input, InputNumber, Modal, Select, Switch } from "antd"; +import { Button, Input, InputNumber, Modal, Select } from "antd"; +import { Switch } from "@/components/ui/base/switch"; import { AlertTriangle, Box, Check, FileImage, Link2, Plus, Save, SlidersHorizontal, Trash2 } from "lucide-react"; import { nanoid } from "nanoid"; @@ -198,7 +199,7 @@ function AssetEditor({ asset, onChange, onDelete }: { asset: StyleAssetBinding;
参与生成执行计划 - onChange({ enabled })} /> + onChange({ enabled })} />
diff --git a/web/src/components/canvas/toolbars/toolbar-settings-modal.tsx b/web/src/components/canvas/toolbars/toolbar-settings-modal.tsx index 8a0e542ab..bdb85a3e7 100644 --- a/web/src/components/canvas/toolbars/toolbar-settings-modal.tsx +++ b/web/src/components/canvas/toolbars/toolbar-settings-modal.tsx @@ -1,4 +1,5 @@ -import { Modal, Switch } from "antd"; +import { Modal } from "antd"; +import { Switch } from "@/components/ui/base/switch"; import { GripVertical, RotateCcw, X } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { useEffect, useRef, useState } from "react"; @@ -218,7 +219,7 @@ function ToolbarSettingsItem({ item, reducedMotion, theme, dragging, onToggleVis > - onToggleVisible(item.id, checked)} aria-label={`${item.visible ? "隐藏" : "显示"}${item.label}`} /> + onToggleVisible(item.id, checked)} aria-label={`${item.visible ? "隐藏" : "显示"}${item.label}`} />
diff --git a/web/src/components/channel-headers-editor.tsx b/web/src/components/channel-headers-editor.tsx index 810ba0f5f..cfcaa33eb 100644 --- a/web/src/components/channel-headers-editor.tsx +++ b/web/src/components/channel-headers-editor.tsx @@ -1,4 +1,6 @@ -import { Button, Input, Tooltip } from "antd"; +import { Button, Input } from "antd"; +import { Tooltip } from "@/components/ui/base/tooltip"; + import { Plus, Trash2 } from "lucide-react"; import type { ChangeEvent } from "react"; diff --git a/web/src/components/conversation/voice-recording-button.tsx b/web/src/components/conversation/voice-recording-button.tsx index f234d0477..bfb500e11 100644 --- a/web/src/components/conversation/voice-recording-button.tsx +++ b/web/src/components/conversation/voice-recording-button.tsx @@ -1,5 +1,7 @@ +import { Button } from "antd"; +import { Tooltip } from "@/components/ui/base/tooltip"; import { useState } from "react"; -import { Button, Tooltip } from "antd"; + import { Mic } from "lucide-react"; import { VoiceRecordingInline } from "./voice-recording-inline"; diff --git a/web/src/components/conversation/voice-recording-inline.tsx b/web/src/components/conversation/voice-recording-inline.tsx index 9923744f6..aab17f7ff 100644 --- a/web/src/components/conversation/voice-recording-inline.tsx +++ b/web/src/components/conversation/voice-recording-inline.tsx @@ -1,5 +1,7 @@ +import { Button, Spin } from "antd"; +import { Tooltip } from "@/components/ui/base/tooltip"; import { useEffect, useRef, useState } from "react"; -import { Button, Spin, Tooltip } from "antd"; + import { Check, Mic, Square, X } from "lucide-react"; import { AudioWaveform } from "./audio-waveform"; diff --git a/web/src/components/image-settings-panel.tsx b/web/src/components/image-settings-panel.tsx index d48021bd6..1626a4c9e 100644 --- a/web/src/components/image-settings-panel.tsx +++ b/web/src/components/image-settings-panel.tsx @@ -1,5 +1,6 @@ import { type ReactNode, useState } from "react"; -import { ConfigProvider, Switch } from "antd"; +import { ConfigProvider } from "antd"; +import { Switch } from "@/components/ui/base/switch"; import { type CanvasTheme } from "@/lib/canvas-theme"; import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers"; @@ -127,7 +128,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
event.stopPropagation()}> onConfigChange("transparentBackground", checked ? "true" : "false")} /> @@ -151,7 +152,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = 16倍数对齐 event.stopPropagation()}> - + diff --git a/web/src/components/layout/user-oss-settings-form.tsx b/web/src/components/layout/user-oss-settings-form.tsx index 50f381801..8942facd2 100644 --- a/web/src/components/layout/user-oss-settings-form.tsx +++ b/web/src/components/layout/user-oss-settings-form.tsx @@ -1,10 +1,12 @@ -import { App, Button, Form, Input, Select, Switch, Tag } from "antd"; +import { App, Button, Form, Input, Select, Tag } from "antd"; +import { Switch } from "@/components/ui/base/switch"; import { Cloud, ShieldCheck } from "lucide-react"; import { useEffect, useState } from "react"; import { changesRequireOSSRetest, DEFAULT_OSS_PATH_PREFIX, getS3PresetHints, S3_PRESET_OPTIONS, type OSSConnectionTestResult, type OSSProvider, type S3Preset } from "@/lib/oss-settings"; import { getUserOSSSetting, testUserOSSConnection, updateUserOSSSetting, type UserOSSSetting } from "@/services/api/resources"; import { useUserStore } from "@/stores/use-user-store"; +import { StatusBadge } from "@/components/ui/base/badges"; type OSSFormValues = { enabled?: boolean; @@ -121,7 +123,7 @@ export function UserOSSSettingsForm() {

启用后,新上传和新生成的媒体优先写入你的存储桶;停用时回退到平台存储。

- {setting?.enabled ? "已启用" : "未启用"} + }> {setting?.hasAccessKeySecret ? "密钥已加密" : "未保存密钥"} @@ -237,7 +239,7 @@ function connectionInput(values: OSSFormValues) { } function ConnectionTestStatus({ result, stale }: { result: OSSConnectionTestResult | null; stale: boolean }) { - if (stale) return 需重新测试; + if (stale) return ; if (!result) return null; - return {result.ok ? `测试通过${result.testedAt ? ` · ${formatSavedAt(result.testedAt)}` : ""}` : result.message || "测试失败"}; + return ; } diff --git a/web/src/components/layout/workspace-account-menu.tsx b/web/src/components/layout/workspace-account-menu.tsx index 19fc30b1d..c6efeda64 100644 --- a/web/src/components/layout/workspace-account-menu.tsx +++ b/web/src/components/layout/workspace-account-menu.tsx @@ -1,4 +1,5 @@ -import { Popover, Switch } from "antd"; +import { Popover } from "antd"; +import { Switch } from "@/components/ui/base/switch"; import { CircleUserRound, LogIn, Moon, Sun } from "lucide-react"; import { useEffect, useState } from "react"; import { Link } from "react-router"; @@ -52,7 +53,7 @@ export function WorkspaceAccountMenu() {
{theme === "dark" ? : } 深色模式 - setTheme(checked ? "dark" : "light")} aria-label="深色模式" /> + setTheme(checked ? "dark" : "light")} aria-label="深色模式" />
)} diff --git a/web/src/components/layout/workspace-command-palette.tsx b/web/src/components/layout/workspace-command-palette.tsx index 92566b9b6..22b6594e7 100644 --- a/web/src/components/layout/workspace-command-palette.tsx +++ b/web/src/components/layout/workspace-command-palette.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState, type ComponentType } from "react" import { useNavigate } from "react-router"; import { navigationTools } from "@/constant/navigation-tools"; +import { Kbd } from "@/components/ui/base/kbd"; import { cn } from "@/lib/utils"; import { useUserStore } from "@/stores/use-user-store"; @@ -100,12 +101,9 @@ toolEntry("projects", "/projects"), className="min-w-0 flex-1 bg-transparent text-[var(--fs-body)] outline-none placeholder:text-foreground/45" placeholder="搜索页面或操作…" /> - + ⌘K - + diff --git a/web/src/components/layout/workspace-sidebar-nav.tsx b/web/src/components/layout/workspace-sidebar-nav.tsx index fce29cdbf..f3e887115 100644 --- a/web/src/components/layout/workspace-sidebar-nav.tsx +++ b/web/src/components/layout/workspace-sidebar-nav.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSPrope import { Link, useLocation, useNavigate, useSearchParams } from "react-router"; import { BrandLogoFrame } from "@/components/brand/brand-logo"; +import { Kbd } from "@/components/ui/base/kbd"; import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools"; import { useWalletBalance } from "@/hooks/use-wallet-balance"; import { useWorkspaceLogout } from "@/hooks/use-workspace-logout"; @@ -186,9 +187,7 @@ function NavItem({ {item.shortcut ? ( - - {item.shortcut} - + {item.shortcut} ) : null} {item.badge !== undefined ? {item.badge} : null} {hasChildren ? : null} @@ -338,9 +337,7 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp > 快速搜索 - - ⌘K - + ⌘K diff --git a/web/src/components/model-capability-editor.tsx b/web/src/components/model-capability-editor.tsx index d1f2a37c0..b76999c25 100644 --- a/web/src/components/model-capability-editor.tsx +++ b/web/src/components/model-capability-editor.tsx @@ -1,4 +1,6 @@ -import { Input, InputNumber, Segmented, Select, Switch } from "antd"; +import { Input, InputNumber, Select } from "antd"; +import { Switch } from "@/components/ui/base/switch"; +import { SegmentedControl } from "@/components/ui/base/segmented-control"; import type { ReactNode } from "react"; import { defaultImageCapabilityConfig, defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ImageCapabilityConfig, type ModelCapabilityConfig, type TextCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities"; @@ -122,7 +124,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability = - - update({ maxOutputs: maxOutputs || 1 })} /> - update({ maxOutputs: maxOutputs || 1 })} /> - @@ -728,7 +730,7 @@ function ParameterField({ label, description, supported, disabled, onChange }: { ); diff --git a/web/src/components/ui/README.md b/web/src/components/ui/README.md new file mode 100644 index 000000000..240dffd10 --- /dev/null +++ b/web/src/components/ui/README.md @@ -0,0 +1,48 @@ +# Celadon UI 组件层 + +`web/src/components/ui/` 下的自研组件层。规范全文见仓库 `docs/plans/ui-design-system.mdx`(组件编写规范)与 `docs/plans/ui-kit-rollout-plan.mdx`(实施计划)、决策记录 `docs/adr/0008-ui-component-layer-antd-replacement.md`。本文是目录内速查版。 + +## 分层 + +| 层 | 目录 | 内容 | +| --- | --- | --- | +| base | `ui/base/` | 原语 + 基础件:`buttons/`、`badges/`、`kbd/`、`segmented-control/` 等 | +| product | `ui/product/` | 跨页面模式件:`empty-state/`、`callout/`、`loading-state/`、`section-card/`、`property-row/` | +| fx | `ui/fx/` | 特效件(原 `aceternity/*` 归位至此命名空间,引用随用随迁,不重写) | +| application | `ui/application/` | 产品块(P3 起):agent-chat、notification-center、app-shell 等 | + +> 视觉数值唯一权威源:`composio-brand-package`(仓库根同级兄弟目录),消费规则见实施计划 §2.4。 + +## 目录与导出 + +- 每"族"一目录(`base/badges/`),族内按文件拆变体;族级 `index.ts` 汇总导出(组件 + `XxxVariants` 类型);**无全局 barrel**。 +- 导入一律深路径 `@/components/ui//`;组件 `PascalCase`、文件 `kebab-case.tsx`、目录 `kebab-case/`。 +- 变体用 cva(`class-variance-authority`)定义并从 `index.ts` 导出类型;`className` 经 `cn` 合并;ref 转发;根元素 `data-slot`。 + +## 硬性纪律 + +1. 样式只消费三层 token(`var(--*)` 或经 `@theme` 映射的工具类,如 `bg-surface`、`text-caption`、`text-status-success`);**禁止裸 `text-[Npx]`/`rounded-[Npx]`/十六进制色值**。字号/行高用注册排版工具类或 token 引用。 +2. 明暗自适应由 CSS 变量完成,不读皮肤 ID;持久切换 `aria-pressed` + `data-active`,保留 `:focus-visible`,尊重 `prefers-reduced-motion`。 +3. 图标以 lucide 组件引用传入(`icon={Move}`),尺寸/颜色由宿主组件决定,禁止调用方传 icon className 改尺寸。 +4. 组件头注释注明:用途、消费 token 组、对标/接管对象(AntD 组件或参考系统组件)、明暗差异要点。 + +## 进库门槛 + +新组件必须满足其一:① ≥2 处真实重复(PR 附 grep 证据);② 隔离明确协议/交互契约。纯透传壳不进库。 + +## 当前族目录 + +- `base/kbd/` — Kbd(快捷键提示键帽) +- `base/badges/` — StatusBadge(状态点徽章;NodeTypeBadge 规划中) +- `base/buttons/` — IconButton / ToolButton(IconButton 含 ghost/default/outline/solid/danger 变体;ButtonGroup 规划中) +- `base/segmented-control/` — SegmentedControl(分段单选,thumb 滑动,泛型 + block/size) +- `base/checkbox/` — Checkbox + CheckboxGroup(原生 input + 自绘 glyph,checked/indeterminate/bare) +- `base/select/` — Select(RAC 单选壳,value/options/allowClear;多选/搜索 ComboBox 待建) +- `base/tooltip/` — Tooltip(RAC 浮层,AntD 8 向 placement 映射,title 空不渲染) +- `base/switch/` — Switch(button role=switch + data-state,checkedChildren 文本轨、loading、Form.Item 注入) +- `product/empty-state/` — EmptyState(空态占位) +- `product/callout/` — Callout(规划中,P0) +- `base/badges/` — StatusBadge(状态点徽章;NodeTypeBadge 规划中) +- `product/empty-state/` — EmptyState(空态占位) +- `product/callout/` — Callout(规划中,P0) +- `base/buttons/` — IconButton / ToolButton / ButtonGroup(规划中,P0) diff --git a/web/src/components/ui/base/badges/index.ts b/web/src/components/ui/base/badges/index.ts new file mode 100644 index 000000000..4633a594a --- /dev/null +++ b/web/src/components/ui/base/badges/index.ts @@ -0,0 +1 @@ +export { StatusBadge, statusBadgeTone, statusBadgeVariants, type StatusBadgeProps } from "./status-badge"; diff --git a/web/src/components/ui/base/badges/status-badge.tsx b/web/src/components/ui/base/badges/status-badge.tsx new file mode 100644 index 000000000..a1aea9d77 --- /dev/null +++ b/web/src/components/ui/base/badges/status-badge.tsx @@ -0,0 +1,95 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +/** + * StatusBadge — 语义状态徽章。 + * + * 两种形态: + * - outline(默认):圆点 + 文本,圆点承担状态色,底色 bg-secondary/60。 + * - filled:tone 色块纯文本胶囊(AntD Tag variant="filled" 观感),无圆点。 + * + * 用途:展示实体的语义状态(成功/告警/失败/进行中/中性),如任务状态、同步状态、 + * 节点状态;替代 AntD Tag 的状态标签场景与各页面私有实现。 + * token:outline 圆点取 bg-status-{success|warning|error|loading}、中性取前景色; + * 底色 bg-secondary/60、文本 text-muted-foreground;filled 容器取 + * bg-status-{tone}/15 + text-status-{tone}(中性取前景色混合)。 + * 对标:AntD Tag(状态色标签,variant="filled" 对应 filled)、BoardUI status-dot(状态点)。 + * 明暗:全部颜色走 token,明暗双主题自动适配。 + */ +export const statusBadgeTone = cva("shrink-0 rounded-full", { + variants: { + tone: { + neutral: "bg-foreground/50", + success: "bg-status-success", + warning: "bg-status-warning", + error: "bg-status-error", + loading: "bg-status-loading", + }, + }, + defaultVariants: { + tone: "neutral", + }, +}); + +export const statusBadgeVariants = cva("inline-flex items-center gap-1.5 whitespace-nowrap rounded-full", { + variants: { + variant: { + outline: "bg-secondary/60", + filled: "font-medium", + }, + size: { + default: "px-2 py-0.5 text-caption", + sm: "gap-1 px-1.5 py-px text-tiny", + }, + }, + defaultVariants: { + variant: "outline", + size: "default", + }, +}); + +/** filled 容器底色:tone 色 12–15% 淡底(中性为前景色 8%) */ +const filledTintClass: Record = { + neutral: "bg-foreground/8", + success: "bg-status-success/15", + warning: "bg-status-warning/15", + error: "bg-status-error/15", + loading: "bg-status-loading/15", +}; + +/** filled 文本色:tone 原色(中性为前景色 70%) */ +const filledInkClass: Record = { + neutral: "text-foreground/70", + success: "text-status-success", + warning: "text-status-warning", + error: "text-status-error", + loading: "text-status-loading", +}; + +export type StatusBadgeProps = HTMLAttributes & + VariantProps & + VariantProps & { + /** 徽章文本;状态语义由文本承担(无文本时组件退化为纯圆点/色块) */ + label?: ReactNode; + }; + +export function StatusBadge({ tone, variant = "outline", size, label, className, children, ...props }: StatusBadgeProps) { + const resolvedTone = tone ?? "neutral"; + return ( + + {variant === "outline" ? ( + + ) : null} + {label != null ? ( + {label} + ) : null} + {children} + + ); +} diff --git a/web/src/components/ui/base/buttons/icon-button.tsx b/web/src/components/ui/base/buttons/icon-button.tsx new file mode 100644 index 000000000..9c1a101ea --- /dev/null +++ b/web/src/components/ui/base/buttons/icon-button.tsx @@ -0,0 +1,80 @@ +import type { ButtonHTMLAttributes } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { Loader2, type LucideIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** + * IconButton — 纯图标按钮(正方形、尺寸档位、语义变体、loading 态)。 + * + * 用途:面板/工具条里的图标触发按钮(新建、删除、收起、关闭等);替代 AntD + * Button `type="text"/"ghost" icon` 小尺寸场景与各页私有 icon-button。 + * token:底 bg-surface-hover(ghost 悬停)/bg-surface-active(active 按压)、 + * 图标 text-muted-foreground(danger 用 text-status-error)、焦点环 ring-ring; + * hover/active 态全用语义 token,无裸色。 + * 图标契约:`icon` 传 lucide 组件引用,尺寸按 `size` 档位由本组件决定;禁止调用方 + * 传 icon className 改尺寸。必须传 `aria-label`(纯图标无文本)。 + * 对标:dbx ui/button variants(ghost/outline/destructive、icon-xs/sm/lg); + * AntD Button icon-only。 + * 明暗:全部颜色走 token;active 按压底用 bg-surface-active,明暗自动适配。 + */ +export const iconButtonVariants = cva( + [ + "inline-flex shrink-0 items-center justify-center rounded-md", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + "disabled:pointer-events-none disabled:opacity-45", + "motion-safe:active:scale-[0.96]", + ], + { + variants: { + variant: { + // 纯透明 + hover 浮底(最常用:面板/工具条) + ghost: "text-muted-foreground hover:bg-surface-hover hover:text-foreground active:bg-surface-active", + // 常驻浅底 + default: "bg-surface-secondary text-muted-foreground hover:bg-surface-hover hover:text-foreground active:bg-surface-active", + // 描边 + outline: "border border-border bg-transparent text-muted-foreground hover:bg-surface-hover hover:text-foreground", + // 危险(文本色常驻红) + danger: "text-status-error hover:bg-surface-hover active:bg-surface-active", + // 实心(中性黑底白图标,主操作;active 反转成白底黑字 + 内描边,供选中态使用) + solid: "bg-foreground text-background hover:opacity-85 active:opacity-75 data-[active=true]:bg-background data-[active=true]:text-foreground data-[active=true]:ring-1 data-[active=true]:ring-inset data-[active=true]:ring-border", + }, + size: { + xs: "size-6 [&_svg]:size-3.5", + sm: "size-7 [&_svg]:size-4", + default: "size-8 [&_svg]:size-4", + lg: "size-9 [&_svg]:size-[18px]", + }, + }, + defaultVariants: { + variant: "ghost", + size: "default", + }, + }, +); + +export type IconButtonProps = ButtonHTMLAttributes & + VariantProps & { + /** lucide 图标组件引用 */ + icon: LucideIcon; + /** loading:替换为 spinner 并禁用 */ + loading?: boolean; + /** data-active 语义高亮(如开关组内当前项),仅装饰,不改变 aria-pressed */ + active?: boolean; + }; + +export function IconButton({ variant, size, icon: Icon, loading = false, active = false, className, type = "button", disabled, ...props }: IconButtonProps) { + return ( + + ); +} diff --git a/web/src/components/ui/base/buttons/index.ts b/web/src/components/ui/base/buttons/index.ts new file mode 100644 index 000000000..7413ae179 --- /dev/null +++ b/web/src/components/ui/base/buttons/index.ts @@ -0,0 +1,2 @@ +export { IconButton, iconButtonVariants, type IconButtonProps } from "./icon-button"; +export { ToolButton, toolButtonVariants, type ToolButtonProps } from "./tool-button"; diff --git a/web/src/components/ui/base/buttons/tool-button.tsx b/web/src/components/ui/base/buttons/tool-button.tsx new file mode 100644 index 000000000..f27271b8f --- /dev/null +++ b/web/src/components/ui/base/buttons/tool-button.tsx @@ -0,0 +1,114 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { Loader2 } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** + * ToolButton — 工具激活态按钮(持久切换,可展开面板)。 + * + * 用途:工具类按钮——画布工具 Dock、面板工具组:被按下时保持高亮(aria-pressed), + * 或展开浮层面板(expands → aria-expanded)。active 持久态与 hover/按压态区分 + * 明显,配合画布 ToolDefinition(active/expands/disabled 谓词)渲染。 + * token:active 态 bg-surface-active + text-foreground,常态 bg-transparent + + * text-muted-foreground,hover bg-surface-hover;焦点环 ring-ring; + * danger 态激活文字用 text-status-error。间距由 size 档位控制。 + * 图标契约:`icon` 传 ReactNode(内联 JSX 或组件元素),与画布 tool-registry 的 + * ToolDefinition.icon 字段一致(icons 是内联 JSX 如 );尺寸由本组件 + * [&_svg] 规则统一控制。禁传 icon className 改尺寸。 + * 对标:接管画布工具 Dock 中按钮渲染(aria-pressed 语义);AntD Tooltip+Button + * 组合的工具按钮场景。参考 BoardUI tool 切换语义。 + * 明暗:全部颜色走 token;active 用按压底语义 token,明暗自动适配。 + */ +export const toolButtonVariants = cva( + [ + "inline-flex select-none items-center justify-center gap-1.5 rounded-md", + "font-medium transition-colors", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + "disabled:pointer-events-none disabled:opacity-45", + ], + { + variants: { + variant: { + // 工具条/面板内常规(常态透明,激活常驻底) + ghost: "text-muted-foreground hover:bg-surface-hover hover:text-foreground", + // 常驻浅底(面板内分组工具) + default: "bg-surface-secondary text-muted-foreground hover:bg-surface-hover hover:text-foreground", + }, + size: { + xs: "h-6 px-1.5 text-tiny [&_svg]:size-3.5", + sm: "h-7 px-2 text-caption [&_svg]:size-4", + default: "h-8 px-2.5 text-caption [&_svg]:size-4", + lg: "h-9 px-3 text-body [&_svg]:size-[18px]", + }, + tone: { + default: "", + danger: "data-[active=true]:text-status-error", + }, + active: { + true: "", + false: "", + }, + }, + compoundVariants: [ + { + variant: ["ghost", "default"], + active: true, + className: "bg-surface-active text-foreground hover:text-foreground", + }, + ], + defaultVariants: { + variant: "ghost", + size: "default", + tone: "default", + active: false, + }, + }, +); + +export type ToolButtonProps = ButtonHTMLAttributes & + VariantProps & { + /** 图标内容:ReactNode(内联 JSX,与 tool-registry ToolDefinition.icon 对齐) */ + icon?: ReactNode; + /** 持久激活态(映射 aria-pressed=true) */ + active?: boolean; + /** 面板展开型工具:active 走 aria-expanded 语义而非 pressed */ + expands?: boolean; + loading?: boolean; + /** 显式文本标签(提供时按带标签布局渲染) */ + label?: ReactNode; + }; + +export function ToolButton({ + variant, + size, + tone, + icon, + active = false, + expands = false, + loading = false, + label, + className, + type = "button", + disabled, + ...props +}: ToolButtonProps) { + const pressed = !expands && active; + const expanded = expands && active; + return ( + + ); +} diff --git a/web/src/components/ui/base/checkbox/checkbox-group.tsx b/web/src/components/ui/base/checkbox/checkbox-group.tsx new file mode 100644 index 000000000..58a016b85 --- /dev/null +++ b/web/src/components/ui/base/checkbox/checkbox-group.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +import { Checkbox } from "./checkbox"; + +/** + * CheckboxGroup — 复选框组(自研,原生 input,无依赖)。 + * + * - 容器不预设布局:调用方用 className 决定排列(垂直传 `flex flex-col gap-1.5`, + * 网格传 `grid grid-cols-2 gap-2`),与 AntD Checkbox.Group 行为一致。 + * - 受控 value: V[] + onChange(values),泛型覆盖枚举/字符串场景; + * 可直接替换 AntD Checkbox.Group(options/value/onChange/disabled)。 + * - 对标:AntD Checkbox.Group。 + */ + +export interface CheckboxGroupOption { + value: V; + label: ReactNode; + disabled?: boolean; +} + +export interface CheckboxGroupProps { + value?: V[]; + onChange?: (values: V[]) => void; + options: Array>; + /** 组整体禁用 */ + disabled?: boolean; + ariaLabel?: string; + className?: string; +} + +export function CheckboxGroup({ + value = [], + onChange, + options, + disabled = false, + ariaLabel, + className, +}: CheckboxGroupProps) { + const toggle = (optionValue: V, checked: boolean) => { + if (disabled) return; + const next = checked ? [...value, optionValue] : value.filter((item) => item !== optionValue); + onChange?.(next); + }; + + return ( +
+ {options.map((option) => ( + toggle(option.value, event.target.checked)} + > + {option.label} + + ))} +
+ ); +} diff --git a/web/src/components/ui/base/checkbox/checkbox.tsx b/web/src/components/ui/base/checkbox/checkbox.tsx new file mode 100644 index 000000000..71f24b149 --- /dev/null +++ b/web/src/components/ui/base/checkbox/checkbox.tsx @@ -0,0 +1,126 @@ +import { useLayoutEffect, useRef, type InputHTMLAttributes, type ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +/** + * Checkbox — 复选框(自研,原生 input + 自绘 glyph,无依赖)。 + * + * 设计约束(对齐 ui/README 与 ADR-0008): + * - 原生 承载语义与键盘(简单件不走 react-aria-components); + * hidden input + peer 视觉盒::focus-visible ring 落在盒上,aria 状态由原生保证。 + * - 只消费三层 token:box border-border / 选中 bg-foreground + text-background(中性反色), + * hover 加深 border、焦点 ring-ring、禁用 opacity;无裸色。 + * - indeterminate:非受控 DOM 属性,useLayoutEffect 同步到 input.indeterminate + * (样式经 data-indeterminate 落在视觉盒上)。 + * - API 对齐 AntD Checkbox:checked/defaultChecked/disabled/indeterminate/onChange/children, + * 可直接替换;value 传 string 时随原生 input 提交。 + * 对标:AntD Checkbox;boardui base/checkbox。 + */ + +export interface CheckboxProps extends Omit, "type" | "size"> { + /** 复选框尺寸(视觉盒)。默认 md。 */ + size?: "sm" | "md"; + /** 半选态(视觉 + input.indeterminate) */ + indeterminate?: boolean; + /** 右侧标签 */ + children?: ReactNode; + /** bare:不渲染 label 根(外层已由调用方提供 label/点击区时用,避免 label 嵌套) */ + bare?: boolean; + /** 类名附加到根元素 */ + className?: string; +} + +const BOX_SIZE: Record<"sm" | "md", string> = { + sm: "size-3.5 rounded-[3px]", + md: "size-4 rounded", +}; + +export function Checkbox({ + size = "md", + indeterminate = false, + children, + bare = false, + className, + disabled, + checked, + ...props +}: CheckboxProps) { + const inputRef = useRef(null); + + useLayoutEffect(() => { + if (inputRef.current) { + inputRef.current.indeterminate = indeterminate; + } + }, [indeterminate]); + + const inner = ( + <> + + + + ); + + if (bare) { + return ( + + {inner} + + ); + } + + return ( + + ); +} diff --git a/web/src/components/ui/base/checkbox/index.ts b/web/src/components/ui/base/checkbox/index.ts new file mode 100644 index 000000000..3544a2cf0 --- /dev/null +++ b/web/src/components/ui/base/checkbox/index.ts @@ -0,0 +1,2 @@ +export { Checkbox, type CheckboxProps } from "./checkbox"; +export { CheckboxGroup, type CheckboxGroupProps, type CheckboxGroupOption } from "./checkbox-group"; diff --git a/web/src/components/ui/base/kbd/index.ts b/web/src/components/ui/base/kbd/index.ts new file mode 100644 index 000000000..2ca980768 --- /dev/null +++ b/web/src/components/ui/base/kbd/index.ts @@ -0,0 +1 @@ +export { Kbd, type KbdProps } from "./kbd"; diff --git a/web/src/components/ui/base/kbd/kbd.tsx b/web/src/components/ui/base/kbd/kbd.tsx new file mode 100644 index 000000000..801ff04be --- /dev/null +++ b/web/src/components/ui/base/kbd/kbd.tsx @@ -0,0 +1,32 @@ +import type { HTMLAttributes } from "react"; + +import { cn } from "@/lib/utils"; + +/** + * Kbd — 快捷键提示键帽。 + * + * 用途:展示键盘快捷键(⌘K、Shift+Enter、Esc 等),纯展示,非交互。 + * token:字号 `fs-tiny`(text-tiny)、字体 font-mono、背景 bg-background(半透明)、 + * 边框 var(--workspace-border)、文本 text-foreground。 + * 对标:接管私有实现 workspace-sidebar-nav.tsx / workspace-command-palette.tsx / + * canvas 快捷键弹窗中的 `` 重复样式;形态参考 shadcn kbd 与 BoardUI kbd。 + * 明暗:背景为背景色半透明 + 主题边框,明暗双主题自动适配,无额外差异。 + */ +export interface KbdProps extends HTMLAttributes {} + +export function Kbd({ className, children, ...props }: KbdProps) { + return ( + + {children} + + ); +} diff --git a/web/src/components/ui/base/segmented-control/index.ts b/web/src/components/ui/base/segmented-control/index.ts new file mode 100644 index 000000000..f48b8cb23 --- /dev/null +++ b/web/src/components/ui/base/segmented-control/index.ts @@ -0,0 +1 @@ +export { SegmentedControl, type SegmentedControlProps, type SegmentedOption, type SegmentedSize } from "./segmented-control"; diff --git a/web/src/components/ui/base/segmented-control/segmented-control.tsx b/web/src/components/ui/base/segmented-control/segmented-control.tsx new file mode 100644 index 000000000..1fbf22b84 --- /dev/null +++ b/web/src/components/ui/base/segmented-control/segmented-control.tsx @@ -0,0 +1,191 @@ +import { useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; + +/** + * SegmentedControl — 分段控制器(自研,无依赖)。 + * + * 设计约束(对齐 ui/README 与 ADR-0008): + * - 只消费三层 token 工具类(surface-tertiary / surface-strong / foreground / muted-foreground / ring …), + * 不写死任何色值;明暗自适应随主题 token。 + * - 选中项 thumb 走 bg-surface-strong(light #fff / dark #2a2a2a 抬升面)。 + * - 键盘:radio 组 roving tabindex(←/→/Home/End,自动跳过禁用项)。 + * - thumb 位置 useLayoutEffect 测量 + ResizeObserver 兜底;motion-reduce 下不播动画。 + * - API 对齐 AntD Segmented(value/onChange/options/block/size/disabled), + * 可直接置于 antd Form.Item(Form 注入 value/onChange,props 同名兼容)。 + * - icon 节点尺寸由调用方控制(与 AntD options.icon 行为一致),用 currentColor 跟随文字色。 + */ + +export type SegmentedSize = "sm" | "md"; + +export interface SegmentedOption { + value: V; + /** 文本标签(icon-only 时省略) */ + label?: ReactNode; + /** 图标节点(与 label 同给时渲染在左侧,尺寸自控) */ + icon?: ReactNode; + disabled?: boolean; + /** 原生 title */ + title?: string; +} + +export interface SegmentedControlProps { + value?: V; + onChange?: (value: V) => void; + options: Array>; + size?: SegmentedSize; + /** 填满父容器宽度(项均分) */ + block?: boolean; + /** 整体禁用 */ + disabled?: boolean; + ariaLabel?: string; + className?: string; +} + +const SIZE_CLASS: Record = { + sm: { + track: "h-7 rounded-md p-0.5", + thumb: "rounded-sm", + item: "px-2.5 text-xs", + }, + md: { + track: "h-8 rounded-lg p-0.5", + thumb: "rounded-md", + item: "px-3 text-sm", + }, +}; + +export function SegmentedControl({ + value, + onChange, + options, + size = "md", + block = false, + disabled = false, + ariaLabel, + className = "", +}: SegmentedControlProps) { + const trackRef = useRef(null); + const itemRefs = useRef>([]); + const [thumb, setThumb] = useState<{ left: number; width: number } | null>(null); + + const selectedIndex = options.findIndex((option) => option.value === value); + + /** 无选中项时兜底首个可用项,保证 roving tabindex 下键盘始终可达 */ + const focusableIndex = + selectedIndex >= 0 ? selectedIndex : options.findIndex((option) => !option.disabled); + + useLayoutEffect(() => { + const track = trackRef.current; + if (!track) return; + const measure = () => { + const target = itemRefs.current[selectedIndex]; + if (!target) { + setThumb(null); + return; + } + setThumb({ left: target.offsetLeft, width: target.offsetWidth }); + }; + measure(); + const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null; + observer?.observe(track); + return () => observer?.disconnect(); + }, [selectedIndex, options, value]); + + const select = (next: V, nextIndex: number) => { + if (disabled || options[nextIndex]?.disabled) return; + onChange?.(next); + itemRefs.current[nextIndex]?.focus(); + }; + + const onKeyDown = (event: KeyboardEvent, index: number) => { + if (disabled || !["ArrowRight", "ArrowLeft", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + if (event.key === "Home") { + const first = options.findIndex((option) => !option.disabled); + if (first >= 0) select(options[first].value, first); + return; + } + if (event.key === "End") { + let last = -1; + options.forEach((option, optionIndex) => { + if (!option.disabled) last = optionIndex; + }); + if (last >= 0) select(options[last].value, last); + return; + } + const isPrev = event.key === "ArrowLeft"; + for (let step = 1; step <= options.length; step += 1) { + const candidate = (index + (isPrev ? -step : step) + options.length * 2) % options.length; + if (!options[candidate].disabled) { + select(options[candidate].value, candidate); + return; + } + } + }; + + const itemBase = + "relative z-10 inline-flex select-none items-center justify-center gap-1.5 whitespace-nowrap font-medium outline-none transition-colors motion-reduce:transition-none " + + "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 "; + + return ( +
+ {thumb !== null && !disabled && ( +
+ ); +} diff --git a/web/src/components/ui/base/select/index.ts b/web/src/components/ui/base/select/index.ts new file mode 100644 index 000000000..19c5cb763 --- /dev/null +++ b/web/src/components/ui/base/select/index.ts @@ -0,0 +1 @@ +export { Select, type SelectOption, type SelectProps, type SelectSize } from "./select"; diff --git a/web/src/components/ui/base/select/select.tsx b/web/src/components/ui/base/select/select.tsx new file mode 100644 index 000000000..cfdf98fa8 --- /dev/null +++ b/web/src/components/ui/base/select/select.tsx @@ -0,0 +1,193 @@ +import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { + Button as AriaButton, + ListBox as AriaListBox, + ListBoxItem as AriaListBoxItem, + Popover as AriaPopover, + Select as AriaSelect, +} from "react-aria-components"; +import { Check, ChevronDown, X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** + * Select — 下拉选择(自研,react-aria-components 单选壳,无远程/多选)。 + * + * - API 对齐 AntD Select 的单选子集:value/onChange(options 值)/options/placeholder/ + * disabled/allowClear/size/className,可直接替换纯单选场景;可直接置于 antd Form.Item。 + * - 浮层定位、进出场、焦点与类型选择由 RAC 承担(ADR-0008:复杂控件以 RAC 为无障碍地基)。 + * - Popover isNonModal:避免 RAC modal 滚动锁把页面滚动条锁死(同 boardui 注释); + * isDismissable 与 isNonModal 互斥,故外部 dismiss 手动处理(全局 pointerdown capture)。 + * - 样式只吃语义 token:trigger border-border/bg-background/hover:border-foreground/40、 + * popover bg-surface-strong + hairline、item hover:bg-surface-hover、选中 Check 前景色,无裸色。 + * - 范围外(本批不做,后续补):mode="multiple"/"tags"、showSearch(需 ComboBox)、 + * loading、labelInValue、maxCount、suffixIcon。 + * 对标:AntD Select(单选);boardui base/select。 + */ + +export type SelectSize = "sm" | "md"; + +export interface SelectOption { + value: V; + label: ReactNode; + disabled?: boolean; + /** 原生 title */ + title?: string; +} + +export interface SelectProps { + value?: V; + onChange?: (value: V) => void; + options?: Array>; + placeholder?: string; + disabled?: boolean; + allowClear?: boolean; + size?: SelectSize; + ariaLabel?: string; + className?: string; +} + +const TRIGGER_CLASS: Record = { + sm: "h-7 text-xs", + md: "h-8 text-sm", +}; + +export function Select({ + value, + onChange, + options = [], + placeholder, + disabled = false, + allowClear = false, + size = "md", + ariaLabel, + className, +}: SelectProps) { + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const [open, setOpen] = useState(false); + const [popoverWidth, setPopoverWidth] = useState(null); + + useLayoutEffect(() => { + const measure = () => { + const width = triggerRef.current?.offsetWidth; + if (width) setPopoverWidth(width + 2); + }; + measure(); + const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null; + if (observer && triggerRef.current) observer.observe(triggerRef.current); + return () => observer?.disconnect(); + }, [open]); + + useLayoutEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node; + const insideTrigger = triggerRef.current?.contains(target); + const insidePopover = popoverRef.current?.contains(target); + if (!insideTrigger && !insidePopover) setOpen(false); + }; + window.addEventListener("pointerdown", onPointerDown, true); + return () => window.removeEventListener("pointerdown", onPointerDown, true); + }, [open]); + + const selected = options.find((option) => option.value === value); + const displayLabel = selected?.label ?? value; + + const clear = () => { + setOpen(false); + onChange?.(undefined as unknown as V); + }; + + return ( + { + setOpen(false); + if (key != null) onChange?.(key as V); + }} + aria-label={ariaLabel} + className={cn("inline-flex w-full", className)} + > + + + {displayLabel === undefined ? placeholder : displayLabel} + + {allowClear && value !== undefined && !disabled && ( + event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); + clear(); + }} + > + + + )} + + + + {options.map((option) => ( + + cn( + "flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors motion-reduce:transition-none", + "focus-visible:bg-surface-hover", + (isFocused || isSelected) && "bg-surface-hover", + isSelected ? "font-medium text-foreground" : "text-muted-foreground", + itemDisabled && "cursor-not-allowed opacity-50", + ) + } + > + {({ isSelected }) => ( + <> + + {option.label} + + {isSelected && } + + )} + + ))} + + + + ); +} diff --git a/web/src/components/ui/base/switch/index.ts b/web/src/components/ui/base/switch/index.ts new file mode 100644 index 000000000..ef59720d4 --- /dev/null +++ b/web/src/components/ui/base/switch/index.ts @@ -0,0 +1 @@ +export { Switch, type SwitchProps } from "./switch"; diff --git a/web/src/components/ui/base/switch/switch.tsx b/web/src/components/ui/base/switch/switch.tsx new file mode 100644 index 000000000..7f81962fb --- /dev/null +++ b/web/src/components/ui/base/switch/switch.tsx @@ -0,0 +1,156 @@ +import { useState, type ButtonHTMLAttributes, type ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +/** + * Switch — 开关(自研,button role=switch,无依赖)。 + * + * 设计约束(对齐 ui/README 与 ADR-0008): + * - + ); +} diff --git a/web/src/components/ui/base/tooltip/index.ts b/web/src/components/ui/base/tooltip/index.ts new file mode 100644 index 000000000..8c3ebff5e --- /dev/null +++ b/web/src/components/ui/base/tooltip/index.ts @@ -0,0 +1 @@ +export { Tooltip, type TooltipPlacement, type TooltipProps } from "./tooltip"; diff --git a/web/src/components/ui/base/tooltip/tooltip.tsx b/web/src/components/ui/base/tooltip/tooltip.tsx new file mode 100644 index 000000000..9cf66087d --- /dev/null +++ b/web/src/components/ui/base/tooltip/tooltip.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from "react"; +import { Tooltip as RACTooltip, TooltipTrigger, type Placement } from "react-aria-components"; + +import { cn } from "@/lib/utils"; + +/** + * Tooltip — 气泡提示(自研,react-aria-components overlay)。 + * + * - API 对齐 AntD Tooltip(title/placement/children),title 为空时不渲染浮层。 + * - 浮层定位、进出场、焦点管理由 RAC 承担(ADR-0008:复杂浮层以 RAC 为无障碍地基)。 + * - 样式只吃语义 token:bg-surface-strong(light #fff / dark #2a2a2a)+ text-foreground + + * border-border hairline,无裸色;motion 走 RAC 动画类 + motion-reduce 关停。 + * - trigger 包一层 inline-flex span:保证 RAC ref 定位可用(children 可为任意组件)。 + * - placement 映射 AntD → RAC(topLeft→top left …)。 + * 对标:AntD Tooltip;boardui base/tooltip。 + */ + +export type TooltipPlacement = "top" | "topLeft" | "topRight" | "bottom" | "bottomLeft" | "bottomRight" | "left" | "right"; + +const PLACEMENT_MAP: Record = { + top: "top", + topLeft: "top left", + topRight: "top right", + bottom: "bottom", + bottomLeft: "bottom left", + bottomRight: "bottom right", + left: "left", + right: "right", +}; + +export interface TooltipProps { + title?: ReactNode; + placement?: TooltipPlacement; + /** 悬停延迟 ms(默认 350,贴近 AntD mouseEnterDelay) */ + delay?: number; + className?: string; + children: ReactNode; +} + +export function Tooltip({ title, placement = "top", delay = 350, className, children }: TooltipProps) { + if (!title) return <>{children}; + + return ( + + {children} + + {title} + + + ); +} diff --git a/web/src/components/ui/product/callout/callout.tsx b/web/src/components/ui/product/callout/callout.tsx new file mode 100644 index 000000000..fb9601f9f --- /dev/null +++ b/web/src/components/ui/product/callout/callout.tsx @@ -0,0 +1,81 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import { AlertTriangle, CheckCircle2, Info, X, XCircle, type LucideIcon } from "lucide-react"; + +import { IconButton } from "@/components/ui/base/buttons"; + +import { cn } from "@/lib/utils"; + +/** + * Callout — 语义提示横幅(图标 + 标题 + 正文 + 可选操作)。 + * + * 用途:页面/面板内的轻量提示(信息/成功/告警/错误),可带操作按钮;替代 AntD + * Alert 的静态展示场景与页面私有提示块。 + * token:底 bg-surface-secondary、边框 var(--workspace-border)/border-border、 + * 标题 text-foreground、正文 text-muted-foreground、tone 图标色 + * text-status-{success|warning|error}(info/default 用 text-muted-foreground)。 + * 对标:AntD Alert;dbx ErrorBanner(错误横幅场景);形态参考 shadcn alert。 + * 明暗:全部颜色走 token,明暗双主题自动适配。 + */ +export type CalloutTone = "info" | "success" | "warning" | "error"; + +const calloutIconByTone: Record = { + info: Info, + success: CheckCircle2, + warning: AlertTriangle, + error: XCircle, +}; + +export interface CalloutProps extends Omit, "title"> { + /** 语义 tone:default 等同 info(中性) */ + tone?: CalloutTone | "default"; + title?: ReactNode; + /** 自定义图标组件引用;默认按 tone 取 Info/CheckCircle2/AlertTriangle/XCircle */ + icon?: LucideIcon; + action?: ReactNode; + /** 显示右上角关闭按钮(closable);点击后由调用方移除自身 */ + onClose?: () => void; + /** 关闭按钮的无障碍标签 */ + closeLabel?: string; +} + +export function Callout({ tone = "default", title, icon, action, onClose, closeLabel = "关闭", className, children, ...props }: CalloutProps) { + const resolved = tone === "default" ? "info" : tone; + const Icon = icon ?? calloutIconByTone[resolved]; + return ( +
+ +
+ {title != null ?

{title}

: null} + {children != null ? ( +
+ {children} +
+ ) : null} +
+ {action != null || onClose != null ? ( +
+ {action != null ?
{action}
: null} + {onClose != null ? : null} +
+ ) : null} +
+ ); +} diff --git a/web/src/components/ui/product/callout/index.ts b/web/src/components/ui/product/callout/index.ts new file mode 100644 index 000000000..55abfec26 --- /dev/null +++ b/web/src/components/ui/product/callout/index.ts @@ -0,0 +1 @@ +export { Callout, type CalloutProps, type CalloutTone } from "./callout"; diff --git a/web/src/components/ui/product/empty-state/empty-state.tsx b/web/src/components/ui/product/empty-state/empty-state.tsx new file mode 100644 index 000000000..2280ef667 --- /dev/null +++ b/web/src/components/ui/product/empty-state/empty-state.tsx @@ -0,0 +1,60 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { Inbox, type LucideIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** + * EmptyState — 空态占位(图标 + 标题 + 说明 + 可选操作)。 + * + * 用途:列表/面板/画布无数据或零结果时的引导占位;替代 AntD Empty 的使用场景 + * (搜索零结果、资源为空、列表为空等页面私有实现)。 + * token:图标圈底 bg-secondary 半透明、图标色 text-muted-foreground、标题 + * text-foreground、说明 text-muted-foreground、字号 fs-body/fs-caption;间距走 --space-*。 + * 图标契约:`icon` 传 lucide 组件引用(默认 Inbox),尺寸/颜色由本组件用 token 决定, + * 禁止调用方传 icon className 改尺寸。 + * 对标:AntD Empty;形态参考 BoardUI empty-state。 + * 明暗:全部颜色走 token,明暗双主题自动适配。 + */ +export const emptyStateVariants = cva("flex flex-col items-center justify-center text-center", { + variants: { + size: { + default: "gap-2.5 py-12", + compact: "gap-1.5 py-6", + }, + }, + defaultVariants: { + size: "default", + }, +}); + +export type EmptyStateProps = HTMLAttributes & + VariantProps & { + /** lucide 图标组件引用;默认 Inbox */ + icon?: LucideIcon; + title?: ReactNode; + description?: ReactNode; + /** 操作区(按钮/链接等),非必须 */ + action?: ReactNode; + }; + +export function EmptyState({ + size, + icon: Icon = Inbox, + title, + description, + action, + className, + ...props +}: EmptyStateProps) { + return ( +
+
+ +
+ {title != null ?

{title}

: null} + {description != null ?

{description}

: null} + {action != null ?
{action}
: null} +
+ ); +} diff --git a/web/src/components/ui/product/empty-state/index.ts b/web/src/components/ui/product/empty-state/index.ts new file mode 100644 index 000000000..1d98aa98b --- /dev/null +++ b/web/src/components/ui/product/empty-state/index.ts @@ -0,0 +1 @@ +export { EmptyState, emptyStateVariants, type EmptyStateProps } from "./empty-state"; diff --git a/web/src/components/video-settings-panel.tsx b/web/src/components/video-settings-panel.tsx index 6c9b6662d..b6b055d42 100644 --- a/web/src/components/video-settings-panel.tsx +++ b/web/src/components/video-settings-panel.tsx @@ -1,5 +1,5 @@ import { type ReactNode } from "react"; -import { Switch } from "antd"; +import { Switch } from "@/components/ui/base/switch"; import { ImageSettingsTheme } from "@/components/image-settings-panel"; import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceRatioOptions } from "@/lib/seedance-video"; @@ -357,7 +357,7 @@ function SwitchRow({ label, checked, theme, onChange }: { label: string; checked {label}
event.stopPropagation()}> - + ); diff --git a/web/src/components/workflow-field-mapping-editor.tsx b/web/src/components/workflow-field-mapping-editor.tsx index f64b9f7f5..c6404ef1b 100644 --- a/web/src/components/workflow-field-mapping-editor.tsx +++ b/web/src/components/workflow-field-mapping-editor.tsx @@ -1,9 +1,14 @@ +import { Button, Collapse, Input, InputNumber, Select, Tag } from "antd"; +import { Tooltip } from "@/components/ui/base/tooltip"; import { useMemo, useState } from "react"; -import { Button, Collapse, Empty, Input, InputNumber, Select, Switch, Tag, Tooltip } from "antd"; + +import { Switch } from "@/components/ui/base/switch"; +import { ToolButton } from "@/components/ui/base/buttons"; import { ListFilter, ListPlus, Power, PowerOff } from "lucide-react"; import type { WorkflowFieldMapping } from "@/stores/use-config-store"; import { workflowFieldChoiceValues, workflowFieldConfigurationError, workflowFieldNumberBounds, workflowFieldPresetOptions } from "@/lib/model-capabilities"; +import { EmptyState } from "@/components/ui/product/empty-state"; const sourceOptions = [ { label: "保留工作流默认值", value: "" }, @@ -61,7 +66,7 @@ export function WorkflowFieldMappingEditor({ fields, onChange, disabled = false }; if (!fields.length) { - return ; + return ; } return ( @@ -77,9 +82,13 @@ export function WorkflowFieldMappingEditor({ fields, onChange, disabled = false - + } + active={showOnlyEnabled} + label={showOnlyEnabled ? "显示全部" : "仅显示已开启"} + onClick={() => setShowOnlyEnabled((current) => !current)} + /> event.stopPropagation()}> - updateField(index, { enabled: checked })} /> + updateField(index, { enabled: checked })} />
), children: ( @@ -207,7 +216,7 @@ export function WorkflowFieldMappingEditor({ fields, onChange, disabled = false }; })} /> - {showOnlyEnabled && !visibleFields.length ? : null} + {showOnlyEnabled && !visibleFields.length ? : null} ); } diff --git a/web/src/components/workflow-graph-editor.tsx b/web/src/components/workflow-graph-editor.tsx index dca7d47f9..0664f1453 100644 --- a/web/src/components/workflow-graph-editor.tsx +++ b/web/src/components/workflow-graph-editor.tsx @@ -1,4 +1,6 @@ -import { Button, Empty, Select } from "antd"; +import { Button, Select } from "antd"; +import { IconButton } from "@/components/ui/base/buttons"; +import { EmptyState } from "@/components/ui/product/empty-state"; import { Maximize, Power, PowerOff, ZoomIn, ZoomOut } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; @@ -178,10 +180,10 @@ export function WorkflowGraphEditor({ workflowJson, workflowGraph, fields, onCha
-
) : ( - + )}
diff --git a/web/src/components/workflow-test-workbench.tsx b/web/src/components/workflow-test-workbench.tsx index 8ee5c43ef..1f9ad645f 100644 --- a/web/src/components/workflow-test-workbench.tsx +++ b/web/src/components/workflow-test-workbench.tsx @@ -1,4 +1,6 @@ -import { App, Button, Empty, Input, InputNumber, Progress, Select, Switch } from "antd"; +import { App, Button, Input, InputNumber, Progress, Select } from "antd"; +import { Switch } from "@/components/ui/base/switch"; +import { EmptyState } from "@/components/ui/product/empty-state"; import { FileAudio, FileImage, Film, Grip, Play, RotateCcw, Square, Upload, WandSparkles } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; @@ -256,7 +258,7 @@ export function WorkflowTestWorkbench({ provider, workflowId, workflowKind = "wo position={positions.output} onMove={(point) => setPositions((current) => ({ ...current, output: point }))} > -
{resultUrls.length ? : }
+
{resultUrls.length ? : }
0} /> diff --git a/web/src/lib/skin-themes.ts b/web/src/lib/skin-themes.ts index 1df4a285e..11babf1d6 100644 --- a/web/src/lib/skin-themes.ts +++ b/web/src/lib/skin-themes.ts @@ -309,10 +309,10 @@ export const DEFAULT_CLASSIC_SKIN: SkinDefinition = { info: "#2563eb", workspace: "#ffffff", workspaceGrid: "#f3f3f3", - adminBackground: "#f3f4f6", + adminBackground: "#f5f5f5", adminSurface: "#ffffff", - adminSubtle: "#f7f8fa", - adminStrong: "#eceff3", + adminSubtle: "#f6f6f6", + adminStrong: "#ececec", authBackground: "#08090c", authPanel: "#0b0c10", authCard: "#121318", @@ -361,9 +361,9 @@ export const DEFAULT_CLASSIC_SKIN: SkinDefinition = { info: "#60a5fa", workspace: "#181818", workspaceGrid: "#222222", - adminBackground: "#101010", + adminBackground: "#0f0f0f", adminSurface: "#181818", - adminSubtle: "#202020", + adminSubtle: "#222222", adminStrong: "#2a2a2a", authBackground: "#08090c", authPanel: "#0b0c10", diff --git a/web/src/main.tsx b/web/src/main.tsx index 43cd26632..c943790ed 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,3 +1,5 @@ +import "@fontsource-variable/inter"; +import "@fontsource-variable/jetbrains-mono"; import { runLocalRuntimeBootstrap } from "@/services/local-runtime-bootstrap"; import { bootstrapAppearance } from "@/services/appearance-bootstrap"; diff --git a/web/src/pages/admin/channels/channels-page.tsx b/web/src/pages/admin/channels/channels-page.tsx index 9250738d8..d9e921dee 100644 --- a/web/src/pages/admin/channels/channels-page.tsx +++ b/web/src/pages/admin/channels/channels-page.tsx @@ -1,4 +1,5 @@ -import { App, Button, Form, Input, InputNumber, Modal, Select, Switch } from "antd"; +import { App, Button, Form, Input, InputNumber, Modal, Select } from "antd"; +import { Switch } from "@/components/ui/base/switch"; import type { FormInstance } from "antd"; import type { ColumnsType } from "antd/es/table"; import { Pencil, Plus, Power, Search, Trash2 } from "lucide-react"; diff --git a/web/src/pages/admin/components/access-settings-panel.tsx b/web/src/pages/admin/components/access-settings-panel.tsx index 7a883dae2..d47604f4b 100644 --- a/web/src/pages/admin/components/access-settings-panel.tsx +++ b/web/src/pages/admin/components/access-settings-panel.tsx @@ -1,4 +1,5 @@ -import { App, Button, Form, Input, Select, Skeleton, Switch } from "antd"; +import { App, Button, Form, Input, Select, Skeleton } from "antd"; +import { Switch } from "@/components/ui/base/switch"; import { AlertTriangle, BadgeCheck, ChevronDown, Globe2, KeyRound, LockKeyhole, RefreshCw, RotateCcw, Save, ShieldCheck, UserPlus, UsersRound } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useBlocker } from "react-router"; diff --git a/web/src/pages/admin/components/admin-announcements-panel.tsx b/web/src/pages/admin/components/admin-announcements-panel.tsx index 308703bc7..cd72f216f 100644 --- a/web/src/pages/admin/components/admin-announcements-panel.tsx +++ b/web/src/pages/admin/components/admin-announcements-panel.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState, type ChangeEvent } from "react"; -import { App, Button, Form, Input, Modal, Select, Switch } from "antd"; +import { App, Button, Form, Input, Modal, Select } from "antd"; +import { Switch } from "@/components/ui/base/switch"; import type { InputRef } from "antd"; import type { ColumnsType } from "antd/es/table"; import { PencilLine, Pin, Plus, RefreshCw, Search, Send, Upload, X } from "lucide-react"; diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx index 212aeb9df..19b6c2a74 100644 --- a/web/src/pages/admin/components/admin-shell.tsx +++ b/web/src/pages/admin/components/admin-shell.tsx @@ -1,4 +1,5 @@ -import { ConfigProvider, Dropdown, Tooltip } from "antd"; +import { ConfigProvider, Dropdown } from "antd"; +import { Tooltip } from "@/components/ui/base/tooltip"; import type { MenuProps } from "antd"; import { ArrowLeft, @@ -131,7 +132,7 @@ export function AdminShell() {
- + @@ -293,7 +294,7 @@ function AdminNavigation({ collapsed }: { collapsed: boolean }) { return (
{!collapsed ? ( -
+
{group.label}
) : ( @@ -301,16 +302,16 @@ function AdminNavigation({ collapsed }: { collapsed: boolean }) { )}
{visibleItems.map((item) => ( - + cn( - "app-workspace-nav-link flex h-8 items-center rounded-md text-[var(--fs-body)] transition-colors", + "app-workspace-nav-link flex h-8 items-center rounded-md transition-colors", collapsed ? "justify-center px-0" : "gap-2.5 px-2.5", - isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-surface-hover hover:text-foreground", + isActive ? "is-active" : "text-foreground/62 hover:bg-surface-hover hover:text-foreground", ) } > diff --git a/web/src/pages/admin/components/admin-ui.tsx b/web/src/pages/admin/components/admin-ui.tsx index 30c83ba91..c9cf3afdb 100644 --- a/web/src/pages/admin/components/admin-ui.tsx +++ b/web/src/pages/admin/components/admin-ui.tsx @@ -1,3 +1,5 @@ +import { StatusBadge } from "@/components/ui/base/badges"; +import { EmptyState } from "@/components/ui/product/empty-state"; import { App, Button, Dropdown, Table } from "antd"; import type { ButtonProps, MenuProps, TableProps } from "antd"; import { saveAs } from "file-saver"; @@ -12,12 +14,18 @@ export const configuredSecretText = "已配置 · 留空不改"; export type AdminStatusTone = "neutral" | "success" | "warning" | "error" | "info"; +/** admin 状态徽章:归并到自研 StatusBadge(outline 圆点胶囊),admin 全部页面零改动统一视觉。 + * tone 归一:info 在 admin 中本就是前景中性(--admin-status-info: var(--foreground))→ neutral。 */ +const adminToneToBadgeTone: Record = { + neutral: "neutral", + success: "success", + warning: "warning", + error: "error", + info: "neutral", +}; + export function AdminStatusBadge({ label, tone = "neutral", title }: { label: string; tone?: AdminStatusTone; title?: string }) { - return ( - - {label} - - ); + return ; } export function AdminStatTile({ label, value, detail, trend }: { label: string; value: string | number; detail?: string; trend?: { value: string; tone?: AdminStatusTone } }) { @@ -133,14 +141,14 @@ export function AdminExportButton({ export function AdminTableEmpty({ filtered = false, title, description, action }: { filtered?: boolean; title?: string; description?: string; action?: ReactNode }) { return ( -
- - - -
{title || (filtered ? "没有符合筛选条件的数据" : "暂无数据")}
- {description ?

{description}

: null} - {action ?
{action}
: null} -
+ ); } diff --git a/web/src/pages/admin/components/admin-user-detail-drawer.tsx b/web/src/pages/admin/components/admin-user-detail-drawer.tsx index 2c776575f..3c6d80ec3 100644 --- a/web/src/pages/admin/components/admin-user-detail-drawer.tsx +++ b/web/src/pages/admin/components/admin-user-detail-drawer.tsx @@ -1,5 +1,7 @@ import { useEffect, useState } from "react"; -import { App, Button, Descriptions, Drawer, Empty, Progress, Skeleton, Tabs } from "antd"; +import { App, Button, Descriptions, Drawer, Progress, Skeleton, Tabs } from "antd"; +import { IconButton } from "@/components/ui/base/buttons"; +import { EmptyState } from "@/components/ui/product/empty-state"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { PaginationBar } from "@/components/layout/workspace-page"; @@ -97,8 +99,8 @@ export function AdminUserDetailDrawer({ userId, onClose, previousUserId, nextUse rootClassName="admin-drawer" extra={onNavigate ? (
-
) : null} > @@ -225,7 +227,7 @@ export function AdminUserDetailDrawer({ userId, onClose, previousUserId, nextUse ]} /> ) : ( - + )} ); diff --git a/web/src/pages/admin/components/analytics-panel.tsx b/web/src/pages/admin/components/analytics-panel.tsx index 855e9092f..9deb7f25b 100644 --- a/web/src/pages/admin/components/analytics-panel.tsx +++ b/web/src/pages/admin/components/analytics-panel.tsx @@ -1,5 +1,7 @@ +import { App, Button, DatePicker, Drawer, Form, Input, Modal, Select, Tabs, Tag } from "antd"; +import { Tooltip } from "@/components/ui/base/tooltip"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; -import { App, Button, DatePicker, Drawer, Form, Input, Modal, Select, Tabs, Tag, Tooltip } from "antd"; + import type { ColumnsType } from "antd/es/table"; import dayjs, { type Dayjs } from "dayjs"; import { AlertTriangle, BarChart3, CircleDollarSign, Clock3, Gauge, Pencil, Plus, RefreshCw, Settings2, Trash2, UsersRound, Workflow } from "lucide-react"; diff --git a/web/src/pages/admin/components/api-log-detail-drawer.tsx b/web/src/pages/admin/components/api-log-detail-drawer.tsx index bb50dfbb4..7e07f9840 100644 --- a/web/src/pages/admin/components/api-log-detail-drawer.tsx +++ b/web/src/pages/admin/components/api-log-detail-drawer.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; -import { App, Button, Descriptions, Drawer, Empty, Skeleton, Tabs, Typography } from "antd"; +import { App, Button, Descriptions, Drawer, Skeleton, Tabs, Typography } from "antd"; +import { EmptyState } from "@/components/ui/product/empty-state"; import { RefreshCw } from "lucide-react"; import { formatCredits } from "@/constant/credits"; @@ -49,7 +50,7 @@ export function ApiLogDetailDrawer({ logId, onClose, onLogUpdated }: { logId: st return ( - {loading ? : log ? : } + {loading ? : log ? : } ); } @@ -125,7 +126,7 @@ function requestKindText(value: ApiCallLog["requestKind"]) { } function PayloadPanel({ value, empty }: { value?: string; empty: string }) { - if (!value) return ; + if (!value) return ; return (
diff --git a/web/src/pages/admin/components/email-settings-panel.tsx b/web/src/pages/admin/components/email-settings-panel.tsx index 1f3d892a7..bcdec80b5 100644 --- a/web/src/pages/admin/components/email-settings-panel.tsx +++ b/web/src/pages/admin/components/email-settings-panel.tsx @@ -1,4 +1,6 @@ -import { App, Button, Form, Input, InputNumber, Select, Skeleton, Switch } from "antd"; +import { App, Button, Form, Input, InputNumber, Skeleton } from "antd"; +import { Select } from "@/components/ui/base/select"; +import { Switch } from "@/components/ui/base/switch"; import { AlertTriangle, AtSign, BadgeCheck, KeyRound, MailCheck, RefreshCw, RotateCcw, Save, Send, Server } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useBlocker } from "react-router"; diff --git a/web/src/pages/admin/components/feature-availability-panel.tsx b/web/src/pages/admin/components/feature-availability-panel.tsx index d7b2d8fba..4280c8ba7 100644 --- a/web/src/pages/admin/components/feature-availability-panel.tsx +++ b/web/src/pages/admin/components/feature-availability-panel.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; -import { App, Button, Skeleton, Switch } from "antd"; +import { App, Button, Skeleton } from "antd"; +import { Switch } from "@/components/ui/base/switch"; import { AlertTriangle, Clapperboard, Coins, ListChecks, LockKeyhole, MonitorCog, PlugZap, RadioTower, RefreshCw, ShieldCheck, Sparkles } from "lucide-react"; import { cn } from "@/lib/utils"; diff --git a/web/src/pages/admin/logical-models/model-routing-capabilities.tsx b/web/src/pages/admin/logical-models/model-routing-capabilities.tsx index 077b6f11d..85c4e991f 100644 --- a/web/src/pages/admin/logical-models/model-routing-capabilities.tsx +++ b/web/src/pages/admin/logical-models/model-routing-capabilities.tsx @@ -1,4 +1,6 @@ -import { Alert, Button, InputNumber, Select, Switch, Tag } from "antd"; +import { Button, InputNumber, Select, Tag } from "antd"; +import { Switch } from "@/components/ui/base/switch"; +import { Callout } from "@/components/ui/product/callout"; import { RotateCcw } from "lucide-react"; import type { ReactNode } from "react"; @@ -233,7 +235,7 @@ export function CapabilityScopeEditor({ capability, sourceSpecs, value, onChange
- {sourceError ? : null} + {sourceError ? {`${sourceError}。可重新选择,或采用全部可用范围。`} : null} {source.operations?.length ? ( @@ -263,7 +265,7 @@ export function CapabilityScopeEditor({ capability, sourceSpecs, value, onChange
- updateInput(current, definition.name, enabled ? limit : undefined, update)} /> + updateInput(current, definition.name, enabled ? limit : undefined, update)} />
) : null} - {!sourceInputs.length && !sourceOptions.length && !source.operations?.length ? : null} + {!sourceInputs.length && !sourceOptions.length && !source.operations?.length ? 线路仍可按优先级和权重参与路由。 : null}
); } @@ -481,7 +483,7 @@ function OptionRuleEditor({ definition, source, value, onChange }: { definition:
{constraintSummary(source, definition.unit)}
- onChange(enabled ? source : undefined)} /> + onChange(enabled ? source : undefined)} />
{source.values ? ( diff --git a/web/src/pages/admin/logs/logs-page.tsx b/web/src/pages/admin/logs/logs-page.tsx index 97e783a3e..897a58723 100644 --- a/web/src/pages/admin/logs/logs-page.tsx +++ b/web/src/pages/admin/logs/logs-page.tsx @@ -1,4 +1,5 @@ import { App, Button, Input, Modal, Select } from "antd"; +import { IconButton } from "@/components/ui/base/buttons"; import type { ColumnsType } from "antd/es/table"; import { Download, Eye, Play, Search } from "lucide-react"; import { saveAs } from "file-saver"; @@ -294,7 +295,7 @@ function MediaResult({ log, onPreview }: { log: ApiCallLog; onPreview: (url: str ) : null} {!previewUnavailable && log.mediaCount > 1 ? {log.mediaCount} : null} -
); } diff --git a/web/src/pages/admin/payments/payments-page.tsx b/web/src/pages/admin/payments/payments-page.tsx index a779c425e..103d75cd4 100644 --- a/web/src/pages/admin/payments/payments-page.tsx +++ b/web/src/pages/admin/payments/payments-page.tsx @@ -1,5 +1,7 @@ import { AlipayCircleFilled, WechatFilled } from "@ant-design/icons"; -import { Alert, App, Button, DatePicker, Drawer, Form, Input, InputNumber, Select, Switch, Tabs } from "antd"; +import { Callout } from "@/components/ui/product/callout"; +import { App, Button, DatePicker, Drawer, Form, Input, InputNumber, Select, Tabs } from "antd"; +import { Switch } from "@/components/ui/base/switch"; import type { ColumnsType } from "antd/es/table"; import dayjs, { type Dayjs } from "dayjs"; import { Eye, Plus, RefreshCw, Search, Settings2, XCircle } from "lucide-react"; @@ -497,7 +499,7 @@ export default function AdminPaymentsPage() { } scroll > - + 管理端仅提供查单、关单和对账。关单前始终先向渠道查单;对账发现已支付未入账订单时会幂等补发积分。 {providerDrawer ? (
- + @@ -703,7 +700,7 @@ export default function AdminPaymentsPage() { setDetailItems([]); }} > - {detailRun?.error ? : null} + {detailRun?.error ? {detailRun.error} : null}
-
- + + ariaLabel="皮肤预览模式" value={mode} options={[ { diff --git a/web/src/pages/admin/settings/libtv-settings-page.tsx b/web/src/pages/admin/settings/libtv-settings-page.tsx index 00008c1be..45f5ff306 100644 --- a/web/src/pages/admin/settings/libtv-settings-page.tsx +++ b/web/src/pages/admin/settings/libtv-settings-page.tsx @@ -1,4 +1,5 @@ -import { App, Button, Input, Skeleton, Switch } from "antd"; +import { App, Button, Input, Skeleton } from "antd"; +import { Switch } from "@/components/ui/base/switch"; import { AlertTriangle, BadgeCheck, CircleCheck, CloudDownload, KeyRound, RefreshCw, RotateCcw, Save, Server, ShieldCheck, Trash2, Wifi } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useBlocker } from "react-router"; diff --git a/web/src/pages/admin/settings/response-interception-settings-page.tsx b/web/src/pages/admin/settings/response-interception-settings-page.tsx index 87eae3fa4..ceb80a661 100644 --- a/web/src/pages/admin/settings/response-interception-settings-page.tsx +++ b/web/src/pages/admin/settings/response-interception-settings-page.tsx @@ -1,4 +1,5 @@ -import { App, Button, Input, Skeleton, Switch } from "antd"; +import { App, Button, Input, Skeleton } from "antd"; +import { Switch } from "@/components/ui/base/switch"; import { AlertTriangle, ArrowDown, ArrowUp, BadgeCheck, Eye, ListOrdered, Plus, RefreshCw, RotateCcw, Save, Search, ShieldAlert, ShieldCheck, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useBlocker } from "react-router"; diff --git a/web/src/pages/admin/settings/storage-settings-page.tsx b/web/src/pages/admin/settings/storage-settings-page.tsx index fa7d0cdda..8cd327d61 100644 --- a/web/src/pages/admin/settings/storage-settings-page.tsx +++ b/web/src/pages/admin/settings/storage-settings-page.tsx @@ -1,4 +1,6 @@ -import { App, Button, Form, Input, Select, Skeleton, Switch } from "antd"; +import { App, Button, Form, Input, Skeleton } from "antd"; +import { Select } from "@/components/ui/base/select"; +import { Switch } from "@/components/ui/base/switch"; import { AlertTriangle, BadgeCheck, Check, Cloud, Database, Globe2, HardDrive, KeyRound, LocateFixed, RefreshCw, RotateCcw, Save, Server, ShieldCheck, Wifi } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useBlocker } from "react-router"; diff --git a/web/src/pages/admin/storyboard-prompts/storyboard-prompts-page.tsx b/web/src/pages/admin/storyboard-prompts/storyboard-prompts-page.tsx index 0a696bcd7..18d415a80 100644 --- a/web/src/pages/admin/storyboard-prompts/storyboard-prompts-page.tsx +++ b/web/src/pages/admin/storyboard-prompts/storyboard-prompts-page.tsx @@ -1,4 +1,6 @@ -import { Alert, App, Button, Drawer, Form, Input, Popconfirm, Select, Tabs, Tag } from "antd"; +import { App, Button, Drawer, Form, Input, Popconfirm, Select, Tabs } from "antd"; +import { StatusBadge } from "@/components/ui/base/badges"; +import { Callout } from "@/components/ui/product/callout"; import type { ColumnsType } from "antd/es/table"; import { Braces, Copy, FileJson, FileText, Plus, Power, Search, ShieldCheck, Trash2 } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -212,19 +214,13 @@ export default function StoryboardPromptsPage() {
{pendingOperation ? ( - } - /> + }>{`切换到“${definitionByOperation.get(pendingOperation)?.label || pendingOperation}”会丢弃当前草稿。`} ) : null}
-

模板内容

{dirty ? 未保存 : null}
+

模板内容

{dirty ? : null}
{selectedDefinition?.variables.map((variable) => (
-
{items.map((item) =>
{item.file.name}{item.status === "uploading" ? : item.status === "done" ? 完成 : item.status === "error" ? 失败 : 待上传}
)}
+
{items.map((item) =>
{item.file.name}{item.status === "uploading" ? : item.status === "done" ? : item.status === "error" ? : }
)}
: null}
diff --git a/web/src/pages/assets/index.tsx b/web/src/pages/assets/index.tsx index 204dc9b54..1a15053cb 100644 --- a/web/src/pages/assets/index.tsx +++ b/web/src/pages/assets/index.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; import { App, Button, Drawer, Dropdown, Form, Input, Modal, Popconfirm, Progress, Select, Space, Tag, Typography } from "antd"; import type { MenuProps } from "antd"; +import { IconButton } from "@/components/ui/base/buttons"; import { useNavigate } from "react-router"; import { CollectionGrid, ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page"; @@ -559,7 +560,7 @@ export default function AssetsPage() { -
- : viewMode === "chat" ?
- setHistoryOpen(true)} /> + :
+ setHistoryOpen(true)} shots={videoShots} onJumpToShot={jumpToShot} />
-
{activeConversation.messages.map((item, index) =>
{activeConversation.messages.map((item, index) =>
retryFailedMessage(item, index)} onCreateVariant={() => createVariant(item, index)} - />)}
+ onEditUserMessage={(text) => { setPrompt(text); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }} + />
)}
- :
- { setSelectedShotIndex(index); setComposingNextShot(false); }} - onBeginCompose={beginComposeNextShot} - onCancelCompose={cancelComposeNextShot} - onNewConversation={startNewConversation} - onOpenHistory={() => setHistoryOpen(true)} - viewMode={viewMode} - onViewModeChange={setViewMode} - /> -
-
- {composingNextShot ? : visibleShot ? { if (visibleShotResultIndex >= 0 && visibleShot.result) retryFailedMessage(visibleShot.result, visibleShotResultIndex); }} - onCreateVariant={() => { if (visibleShotResultIndex >= 0 && visibleShot.result) createVariant(visibleShot.result, visibleShotResultIndex); }} - /> : null} -
-
-
- -
} - setHistoryOpen(false)} onSelect={selectConversation} onDelete={confirmDeleteConversation} /> + setHistoryOpen(false)} onSelect={selectConversation} onDelete={confirmDeleteConversation} onRename={renameConversationTitle} /> {libraryOpen ? ; } + + const creationAssetCategoryLabels: Record = { all: "全部素材", ...ASSET_CATEGORY_LABELS }; -function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelect, onDelete }: { open: boolean; conversations: CreationConversation[]; activeId: string; onClose: () => void; onSelect: (conversation: CreationConversation) => void; onDelete: (conversation: CreationConversation) => void }) { + +function creationConversationBucket(updatedAt: string): "today" | "yesterday" | "week" | "earlier" { + const at = new Date(updatedAt).getTime(); + if (!Number.isFinite(at)) return "earlier"; + const nowStart = new Date(); + nowStart.setHours(0, 0, 0, 0); + const targetStart = new Date(at); + targetStart.setHours(0, 0, 0, 0); + const days = Math.round((nowStart.getTime() - targetStart.getTime()) / 86400000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return "week"; + return "earlier"; +} + +const creationBucketLabels: Record<"today" | "yesterday" | "week" | "earlier", string> = { today: "今天", yesterday: "昨天", week: "近 7 天", earlier: "更早" }; +function CreationHistoryDrawer({ open, conversations, activeId, onNew, onClose, onSelect, onDelete, onRename }: { open: boolean; conversations: CreationConversation[]; activeId: string; onNew: () => void; onClose: () => void; onSelect: (conversation: CreationConversation) => void; onDelete: (conversation: CreationConversation) => void; onRename: (conversation: CreationConversation, title: string) => void }) { const [keyword, setKeyword] = useState(""); + const [renamingId, setRenamingId] = useState(null); + const [menuOpenId, setMenuOpenId] = useState(null); + const renameInputRef = useRef(null); + const skipRenameCommitRef = useRef(false); + + const assistantName = useAppearanceStore((state) => state.appearance.brandName); + const exportUser = useUserStore((state) => state.user); + + const { message: drawerToast } = App.useApp(); useEffect(() => { - if (open) setKeyword(""); + if (!open) return; + setKeyword(""); + setRenamingId(null); + setMenuOpenId(null); }, [open]); + const commitRename = (conversation: CreationConversation) => { + const shouldSkip = skipRenameCommitRef.current; + skipRenameCommitRef.current = false; + const value = renameInputRef.current?.value.trim() || ""; + setRenamingId(null); + if (shouldSkip || !value) return; + const original = conversation.title.trim() || "新创作"; + if (value === original) return; + onRename(conversation, value); + }; + + const cancelRename = () => { + skipRenameCommitRef.current = true; + setRenamingId(null); + }; + + const beginRename = (conversation: CreationConversation) => { + skipRenameCommitRef.current = false; + setMenuOpenId(null); + setRenamingId(conversation.id); + }; + const visibleConversations = useMemo(() => { const query = keyword.trim().toLowerCase(); if (!query) return conversations; @@ -1082,67 +1131,110 @@ function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelec }); }, [conversations, keyword]); + return } className="creation-history-drawer" rootClassName="creation-history-drawer-root" styles={{ body: { padding: 0 } }} title={
历史对话{conversations.length} 个对话
}>
+ + {visibleConversations.length ?
    - {visibleConversations.map((conversation) => { + {visibleConversations.flatMap((conversation, index) => { + const showGroupHead = !keyword.trim() && (index === 0 || creationConversationBucket(conversation.updatedAt) !== creationConversationBucket(visibleConversations[index - 1].updatedAt)); const latest = conversationPreviewMessage(conversation); const active = conversation.id === activeId; - return
  • - - -
  • ; + const HistoryTypeIcon = latest?.mode === "video" ? Clapperboard : latest?.mode === "image" ? ImageIcon : latest?.mode === "text" ? MessageSquareText : Sparkles; + return [ + showGroupHead ?
  • {creationBucketLabels[creationConversationBucket(conversation.updatedAt)]}

  • : null, +
  • + {renamingId === conversation.id ? ( +
    + event.currentTarget.select()} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); event.currentTarget.blur(); } else if (event.key === "Escape") { cancelRename(); } }} onBlur={() => commitRename(conversation)} /> +
    + ) : ( +
    + + + setMenuOpenId(open ? conversation.id : null)} overlayClassName="creation-history-menu-overlay" menu={{ items: [{ key: "rename", label: "重命名", icon: }, { key: "export", label: "导出对话", icon: }, { key: "delete", label: "删除对话", danger: true, icon: }], onClick: ({ key }) => { setMenuOpenId(null); if (key === "rename") { beginRename(conversation); } else if (key === "export") { downloadCreationConversation(conversation, assistantName, exportUser?.displayName || "你"); drawerToast.success("对话已导出为 Markdown"); } else { onDelete(conversation); } } }}> + + +
    + )} +
  • , + ]; })}
:
{keyword.trim() ? "没有找到匹配的对话" : "暂无历史对话"}
}
; } -function CreationViewSwitch({ viewMode, onChange }: { viewMode: CreationViewMode; onChange: (mode: CreationViewMode) => void }) { - return
- - -
; -} - -function CreationWorkspaceToolbar({ viewMode, onViewModeChange, onNewConversation, onOpenHistory }: { viewMode: CreationViewMode; onViewModeChange: (mode: CreationViewMode) => void; onNewConversation: () => void; onOpenHistory: () => void }) { +function CreationWorkspaceToolbar({ shots, onJumpToShot, onNewConversation, onOpenHistory }: { shots: CreationShotRailEntry[]; onJumpToShot: (shot: CreationShotRailEntry) => void; onNewConversation: () => void; onOpenHistory: () => void }) { + const [railOpen, setRailOpen] = useState(false); + const railRef = useRef(null); + useEffect(() => { + if (!railOpen) return; + const onPointerDown = (event: MouseEvent) => { if (railRef.current && !railRef.current.contains(event.target as Node)) setRailOpen(false); }; + window.addEventListener("mousedown", onPointerDown); + return () => window.removeEventListener("mousedown", onPointerDown); + }, [railOpen]); return
- -
- - +
+ + {railOpen ?
+
镜头时间线{shots.length ? `共 ${shots.length} 镜` : "空轨道"}
+ {shots.length ?
    {shots.map((shot) => { + const resultStatus = shot.result?.status; + const statusLabel = resultStatus === "done" ? "完成" : resultStatus === "error" ? "生成失败" : resultStatus === "pending" ? "生成中" : resultStatus === "cancelled" ? "已停止" : "待生成"; + return
  1. ; + })}
:

在下方发送一条视频消息,就会自动成为第 1 镜。

} +
: null} +
+
+ +
; } -function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant }: { item: CreationMessage; modelName: string; onRetryFailure: () => void; onCreateVariant: () => void }) { +function CreationMessageView({ item, shotNumber, onRetryFailure, onCreateVariant, onEditUserMessage }: { item: CreationMessage; shotNumber: number; onRetryFailure: () => void; onCreateVariant: () => void; onEditUserMessage: (text: string) => void }) { const brandName = useAppearanceStore((state) => state.appearance.brandName); - if (item.role === "user") return ; + if (item.role === "user") return ; const mode = item.mode || "text"; const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : item.status === "error" ? "生成失败" : ""; - const heading = <>{mode === "image" ? "图像生成" : mode === "video" ? "视频生成" : `${brandName} AI`}{mode !== "text" ? {item.status === "pending" ? `${brandName}正在生成${mode === "video" ? "视频" : "图像"}……` : item.status === "done" ? `你的${mode === "video" ? "视频" : "图像"}已创建` : null} : null}{modelName ? {modelName} : null}{item.createdAt ? : null}{stateLabel ? {stateLabel} : null}{mode !== "text" && item.status === "done" ? : null}; + const heading = + mode !== "text" ? ( + <>{shotNumber > 0 ? 镜 {shotNumber} : null}{mode === "image" ? "图像生成" : "视频生成"}{item.status === "pending" ? {brandName}正在生成{mode === "video" ? "视频" : "图像"}…… : item.status === "done" ? 你的{mode === "video" ? "视频" : "图像"}已创建 : null}{item.status === "done" ? : null}{item.createdAt ? : null}{stateLabel ? {stateLabel} : null} + ) : ( + <>{shotNumber > 0 ? 镜 {shotNumber} : null}{brandName}{item.createdAt ? : null}{stateLabel ? {stateLabel} : null} + ); const toolStatus: GenerationToolStatus = item.status === "pending" ? "running" : item.status === "error" ? "error" : item.status === "cancelled" ? "cancelled" : "completed"; return
- {mode === "text" ? <>
{heading}
{item.reasoning ? : null}
{item.content ? {item.content} : 正在生成…}
: 1} heading={heading}>} + {mode === "text" ? <>
{heading}
{item.reasoning ?
: null}
{item.content ? {item.content} : 正在生成…}
: } {item.error && mode === "text" ?
{generationErrorMessage(item.error)}
: null}
; } -function CreationUserMessage({ item }: { item: CreationMessage }) { +function CreationUserMessage({ item, shotNumber, onEditUserMessage }: { item: CreationMessage; shotNumber: number; onEditUserMessage: (text: string) => void }) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); const copyText = useCopyText(); const visiblePrompt = displayCreationPrompt(item.content, item.references || []); + const user = useUserStore((state) => state.user); + const userAvatarUrl = user?.avatarUrl?.trim(); return
-
你{item.createdAt ? : null}
+
{shotNumber > 0 ? 镜 {shotNumber} : null}{item.createdAt ? : null}{user?.displayName || "你"}{userAvatarUrl ? : }

{visiblePrompt}

{item.references?.length ? : null} {item.attachments?.length ?
{item.attachments.map((attachment) => { @@ -1153,6 +1245,7 @@ function CreationUserMessage({ item }: { item: CreationMessage }) { const previewUrl = kind === "image" ? imageUrl : url; return ; })}
: null} +
setPreviewUrl("")} />
; } @@ -1177,7 +1270,7 @@ function MediaResult({ item, onRetryFailure, onCreateVariant }: { item: Creation function CreationMediaPending({ mode, ratio }: { mode: CreationMode; ratio?: string }) { const brandName = useAppearanceStore((state) => state.appearance.brandName); - return
{brandName}正在生成{mode === "video" ? "视频" : "图像"}
; + return
{brandName}正在生成{mode === "video" ? "视频" : "图像"}
; } function CreationMessageReferences({ references }: { references: CreationReference[] }) { @@ -1289,6 +1382,7 @@ type ComposerProps = { prompt: string; setPrompt: (value: string) => void; busy: boolean; + generationActive: boolean; referenceReplacementBusy: boolean; attachments: CreationAttachment[]; referenceImageSize?: { width: number; height: number }; @@ -1344,6 +1438,7 @@ function CreationComposer(props: ComposerProps) { const [trackState, setTrackState] = useState({ canScrollLeft: false, canScrollRight: false, isExpanded: true, isDragging: false }); const previousAttachmentCountRef = useRef(0); const interactionBusy = props.busy || props.referenceReplacementBusy; + const hasWork = interactionBusy || props.generationActive; const canSubmit = Boolean(props.prompt.trim()) && !interactionBusy; const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const priceChannel = resolveModelChannel(props.config, props.model); @@ -1358,7 +1453,13 @@ function CreationComposer(props: ComposerProps) { }); const showCost = creditsEnabled && credits !== null; const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 }); - const actionLabel = props.referenceReplacementBusy ? "正在替换参考图" : props.busy ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送"; + const actionLabel = props.referenceReplacementBusy ? "正在替换参考图" : interactionBusy || (props.generationActive && !canSubmit) ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送"; + // Send-button working state must span the WHOLE generation (not just the + // submit-lock window): spinner + glow stay while a message is pending and + // the composer is empty; typing a next prompt returns the arrow so the + // user knows a new send is possible. + const showWorkingSpinner = interactionBusy || (props.generationActive && !canSubmit); + const showWorkingGlow = props.generationActive && !canSubmit; const placeholder = props.mode === "text" ? "描述你的故事、角色或想继续讨论的创意" : props.mode === "image" @@ -1559,8 +1660,8 @@ function CreationComposer(props: ComposerProps) { {props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? : null} {props.mode === "video" ? : null} {props.mode === "text" ? <> - - + + : null} {props.prompt.trim() || props.attachments.length || props.references.some((reference) => reference.active) ? : null} @@ -1569,16 +1670,18 @@ function CreationComposer(props: ComposerProps) { className={`canvas-node-composer-submit ${showCost ? "has-cost" : ""}`} disabled={interactionBusy || !canSubmit} style={{ - color: !interactionBusy && !canSubmit ? "var(--creation-faint)" : "var(--creation-text)", - "--canvas-composer-submit-action": !interactionBusy && !canSubmit ? "var(--creation-surface-hover)" : "var(--creation-text)", - "--canvas-composer-submit-action-fg": !interactionBusy && !canSubmit ? "var(--creation-faint)" : "var(--creation-bg)", + position: "relative", + color: !hasWork && !canSubmit ? "var(--creation-faint)" : "var(--creation-text)", + "--canvas-composer-submit-action": !hasWork && !canSubmit ? "var(--creation-surface-hover)" : "var(--creation-text)", + "--canvas-composer-submit-action-fg": !hasWork && !canSubmit ? "var(--creation-faint)" : "var(--creation-bg)", } as CSSProperties} onClick={interactionBusy ? undefined : props.onSubmit} aria-label={actionLabel} title={actionLabel} > + {showWorkingGlow ? : null} {showCost ? {formattedCredits} : null} - {interactionBusy ? : } + {showWorkingSpinner ? : } setPreviewUrl("")} /> @@ -1729,198 +1832,73 @@ const creationEmptySuggestions: Array<{ mode: CreationMode; icon: typeof Clapper ]; function CreationEmptySuggest({ onStartPrompt, onOpenLibrary }: { onStartPrompt: (mode: CreationMode, prompt: string) => void; onOpenLibrary: () => void }) { + const [main, ...rest] = creationEmptySuggestions; + const MainIcon = main.icon; return
- {creationEmptySuggestions.map((item) => { + +
{rest.map((item) => { const Icon = item.icon; return ; - })} + })}
; } -type CreationThinking = { title: string; hint: string; steps: string[] }; +type CreationThinking = { title: string; hint: string; steps: string[]; activity: string }; function thinkingFor(mode: CreationMode, brandName: string): CreationThinking { - if (mode === "image") return { title: "正在为你画这一镜", hint: `${brandName}正在理解你的构图意图,并把画面交给模型出图。`, steps: ["理解构图", "定调画风", "生成画面"] }; - if (mode === "text") return { title: "正在为你写这段", hint: `${brandName}正在梳理你的创作脉络,组织语言与结构。`, steps: ["梳理脉络", "组织语言", "输出段落"] }; - return { title: "正在为你拍这一镜", hint: `${brandName}正在拆解你的镜头脚本,设计运镜与光线,并交给模型渲染成片。`, steps: ["拆解镜头", "设计运镜", "定调布光", "渲染成片"] }; + if (mode === "image") return { title: "正在为你画这一镜", hint: `${brandName}正在理解你的构图意图,并把画面交给模型出图。`, steps: ["理解构图", "定调画风", "生成画面"], activity: "正在理解构图并把画面交给模型出图" }; + if (mode === "text") return { title: "正在为你写这段", hint: `${brandName}正在梳理你的创作脉络,组织语言与结构。`, steps: ["梳理脉络", "组织语言", "输出段落"], activity: "正在梳理脉络并组织语言" }; + return { title: "正在为你拍这一镜", hint: `${brandName}正在拆解你的镜头脚本,设计运镜与光线,并交给模型渲染成片。`, steps: ["拆解镜头", "设计运镜", "定调布光", "渲染成片"], activity: "正在按导演思路拆解镜头并渲染" }; } - -function directorNoteFor(mode: CreationMode, settings: CreationSettings): string { - if (mode === "video") return `已按 ${[`${settings.seconds}s`, ...(settings.videoQuality ? [videoResolutionLabel(settings.videoQuality)] : []), settings.ratio].join(" · ")} 渲染这一镜,等待你的下一句指令。`; - if (mode === "image") return `已按 ${settings.ratio} 出图 ${settings.count} 张,等待你的下一句指令。`; - return ""; -} - -function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCompose, onCancelCompose, onNewConversation, onOpenHistory, viewMode, onViewModeChange }: { shots: CreationShot[]; activeIndex: number; composing: boolean; onSelect: (index: number) => void; onBeginCompose: () => void; onCancelCompose: () => void; onNewConversation: () => void; onOpenHistory: () => void; viewMode: CreationViewMode; onViewModeChange: (mode: CreationViewMode) => void }) { - const [railOpen, setRailOpen] = useState(false); - const nextShotNumber = shots.length + 1; - const closeRail = () => setRailOpen(false); - const statusOf = (shot: CreationShot) => shot.result?.status || "queued"; - const shotTitle = (shot: CreationShot) => shot.user ? displayCreationPrompt(shot.user.content, shot.user.references || []).trim() || "未命名镜头" : "镜头"; - return
-
- - {railOpen ?
-
镜头时间线{composing ? `下一镜 ${formatShotOrdinal(nextShotNumber - 1)}` : `${shots.length} 个镜头`}
-
    - {shots.map((shot, index) => { - const status = statusOf(shot); - const title = shotTitle(shot); - const thumbUrl = shot.result?.resultUrls?.[0]; - const thumbIsVideo = shot.result?.mode === "video"; - return
  • - -
  • ; - })} - {composing ?
  • : null} -
- -
: null} -
-
- - - - -
-
; -} - -function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure, onCreateVariant }: { shot: CreationShot; shotNumber: number; modelName: string; busy: boolean; onRetryFailure: () => void; onCreateVariant: () => void }) { - const brandName = useAppearanceStore((state) => state.appearance.brandName); - const user = shot.user; - const result = shot.result; - const status = result?.status || "queued"; - const mode = result?.mode || user?.mode || "video"; - const briefVisible = Boolean(user?.content.trim() || user?.references?.length || user?.attachments?.length); - const copyText = useCopyText(); - const assets = useAssetStore((state) => state.assets); - const visiblePrompt = user ? displayCreationPrompt(user.content, user.references || []) : ""; - const resultUrls = result?.resultUrls || []; - const resultAssetIds = result && resultUrls.length ? creationResultAssetIds(assets, { messageId: result.id, taskIds: result.taskIds || [], resultUrls }) : []; - const canvasHandoffPath = result ? creationCanvasHandoffPath(resultAssetIds, resultUrls.length) : ""; - const canvasPath = canvasHandoffPath || "/canvas"; - return
-
-
- {formatShotOrdinal(shotNumber - 1)} - {mode === "video" ? : mode === "image" ? : }{modeLabels[mode]} - {modelName ? {modelName} : null} - {status === "pending" ? 生成中 : status === "error" ? 生成失败 : status === "done" ? 已完成 : 待生成} -
-
- {status === "error" ? : null} - {status === "done" && result?.resultUrls?.length ? : null} - {status === "done" && resultUrls.length ? {canvasHandoffPath ? "添加到画布" : "打开画布"} : null} - {resultUrls.map((url, index) => {resultUrls.length > 1 ? `下载 ${index + 1}` : <>下载})} -
-
-
-
- {briefVisible && user ?
-
-
{shotScriptLabels[mode]}{user.createdAt ? : null}
-
-

{visiblePrompt}

- {user.references?.length ? : null} - {user.attachments?.length ? : null} -
-
-
: null} - {briefVisible && user ? : null} -
- -
-
{brandName} AI{modelName ? {modelName} : null}{result?.createdAt ? : null}
-
- -
-
-
-
-
-
; -} - -function StoryboardNextShotCard({ shotNumber, onCancel }: { shotNumber: number; onCancel: () => void }) { - const brandName = useAppearanceStore((state) => state.appearance.brandName); - return
-
-
- {formatShotOrdinal(shotNumber - 1)}下一镜 - 待撰写 -
-
- -
-
-
-
- -
- {formatShotOrdinal(shotNumber - 1)} 等待你的脚本 - 在下方写下这一镜的镜头、画面或故事。{brandName}会拆解脚本、设计运镜并渲染成片,这一镜会作为 {formatShotOrdinal(shotNumber - 1)} 自动加入镜头轨道。 -
-
-
-
; -} - -function StoryboardBriefAttachments({ attachments }: { attachments: CreationAttachment[] }) { const [previewUrl, setPreviewUrl] = useState(""); - const [previewType, setPreviewType] = useState<"image" | "video">("image"); - return <>
{attachments.map((attachment) => { - const kind = creationAttachmentKind(attachment); - const previewable = kind === "image" || kind === "video"; - const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || ""; - const imageUrl = kind === "image" ? resolveResourceUrl(attachment.storageKey, url) : ""; - const previewUrl = kind === "image" ? imageUrl : url; - return ; - })}
setPreviewUrl("")} />; -} - -function StoryboardShotResult({ result, onRetryFailure, onCreateVariant, canvasPath, canvasHandoffAvailable }: { result?: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; canvasPath: string; canvasHandoffAvailable: boolean }) { - const brandName = useAppearanceStore((state) => state.appearance.brandName); - const [previewUrl, setPreviewUrl] = useState(""); - const [previewType, setPreviewType] = useState<"image" | "video">("image"); - const openPreview = (url: string, type: "image" | "video") => { setPreviewType(type); setPreviewUrl(url); }; - if (!result) return
这一镜还没开始——在下方写出你的脚本,我来接手。
; - const mode = result.mode || "video"; - const status = result.status || "queued"; - const resultUrls = result.resultUrls || []; - if (status === "pending" || status === "queued") { - const thinking = thinkingFor(mode, brandName); - return
- {thinking.title}{thinking.hint} - -
; - } - if (status === "error") return
{generationErrorMessage(result.error || "")}
; - if (status === "cancelled") return
{result.content || "已停止"}
; - if (mode === "text") return
{result.content ? {result.content} : 正在生成…}
; - if (!resultUrls.length) return
没有返回可预览结果
; - const note = result.settings ? directorNoteFor(mode, result.settings) : ""; - return <> - {mode === "video" ? :
{resultUrls.map((url) => )}
} - {note ?

导演手记{note}

: null} -
{mode === "video" ? "视频结果" : `${resultUrls.length} 张图片`}{canvasHandoffAvailable ? "添加到画布" : "打开画布"}{resultUrls.map((url, index) => {resultUrls.length > 1 ? `下载 ${index + 1}` : <>下载})}
- setPreviewUrl("")} /> - ; -} - function formatMessageTime(value: string) { const timestamp = new Date(value).getTime(); return Number.isFinite(timestamp) ? conversationTimeFormatter.format(timestamp) : ""; } +function buildConversationExportMarkdown(conversation: CreationConversation, assistantName: string, userName: string) { + const lines: string[] = [`# ${conversation.title.trim() || "新创作"}`, ""]; + for (const message of conversation.messages) { + const stamp = formatMessageTime(message.createdAt); + const modeTag = message.mode && message.mode !== "text" ? (message.mode === "image" ? "[图像生成] " : "[视频生成] ") : ""; + const speaker = message.role === "user" ? (userName || "我") : assistantName; + if (message.role === "user") { + const prompt = displayCreationPrompt(message.content, message.references || []).trim(); + if (!prompt) continue; + lines.push(`## ${speaker} · ${stamp}`, "", prompt, ""); + } else { + const body = (message.content || "").trim(); + if (body) lines.push(`## ${speaker} · ${stamp}`, "", `${modeTag}${body}`, ""); + else if (message.resultUrls?.length) lines.push(`## ${speaker} · ${stamp}`, "", `${modeTag}已生成,素材保留在项目中。`, ""); + else continue; + if (message.reasoning?.trim()) lines.push("> 思考过程:", message.reasoning.trim(), ""); + } + } + return lines.join("\n").trim() + "\n"; +} +function downloadCreationConversation(conversation: CreationConversation, assistantName: string, userName: string) { + const safeTitle = (conversation.title.trim() || "新创作").replace(/[\\/:*?"<>|]/g, "_").slice(0, 60) || "新创作"; + const blob = new Blob([buildConversationExportMarkdown(conversation, assistantName, userName)], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `${safeTitle}.md`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} function conversationPreviewMessage(conversation: CreationConversation) { let fallback: CreationMessage | undefined; for (let index = conversation.messages.length - 1; index >= 0; index -= 1) { @@ -2026,6 +2004,20 @@ function conversationTimestamp(value: string) { return Number.isFinite(timestamp) ? timestamp : 0; } +function formatHistoryRelativeTime(value: string) { + const timestamp = conversationTimestamp(value); + if (!timestamp) return ""; + const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000)); + if (seconds < 60) return "刚刚"; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes} 分钟前`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours} 小时前`; + const days = Math.round(hours / 24); + if (days < 7) return `${days} 天前`; + return historyDayFormatter.format(timestamp); +} + function formatConversationTime(value: string) { const timestamp = conversationTimestamp(value); if (!timestamp) return "时间未知"; diff --git a/web/src/pages/dev/director-repro-lab.tsx b/web/src/pages/dev/director-repro-lab.tsx index ff5f90339..7410c96f4 100644 --- a/web/src/pages/dev/director-repro-lab.tsx +++ b/web/src/pages/dev/director-repro-lab.tsx @@ -1,11 +1,13 @@ import { useCallback, useMemo, useState } from "react"; -import { Button, Switch, Table, Tag } from "antd"; +import { Button, Table } from "antd"; +import { Switch } from "@/components/ui/base/switch"; import { CanvasDirectorWorkbench } from "@/components/canvas/director/canvas-director-workbench"; import { DIRECTOR_REPRO_MATRIX, createDirectorReproScene, directorReproSceneIsOffline, injectDirectorReproModel, type DirectorReproModelVariant } from "@/lib/canvas/director/director-repro-fixture"; import { readDirectorReproSnapshot, type DirectorReproSnapshot } from "@/lib/canvas/director/director-repro-runtime"; import { resetDirectorDiagnosticDedupe } from "@/lib/canvas/director/director-diagnostics-recorder"; import { getClientDiagnosticEvents } from "@/services/diagnostics/client-diagnostics"; +import { StatusBadge } from "@/components/ui/base/badges"; import type { DirectorScene } from "@/types/director"; /** @@ -65,9 +67,7 @@ export default function DirectorReproLab() {

导演台 P0 复现台

- - {directorReproSceneIsOffline(scene) ? "fixture 无网络资产" : "已注入模型资产"} - + 对象数 {scene.objects.length} @@ -172,7 +172,7 @@ function DiagnosticEventList({ events }: { events: DirectorEventRow[] }) { scroll={{ y: 220 }} columns={[ { title: "时间", dataIndex: "timestamp", width: 200, render: (value: string) => {value} }, - { title: "级别", dataIndex: "level", width: 88, render: (value: string) => {value} }, + { title: "级别", dataIndex: "level", width: 96, render: (value: string) => }, { title: "稳定码", dataIndex: "code", render: (value: string) => {value} }, { title: "消息", dataIndex: "message" }, ]} diff --git a/web/src/pages/plugins/eagle.tsx b/web/src/pages/plugins/eagle.tsx index 54ebda97d..9e406d46b 100644 --- a/web/src/pages/plugins/eagle.tsx +++ b/web/src/pages/plugins/eagle.tsx @@ -1,6 +1,8 @@ import { ArrowLeft, ChevronDown, ChevronUp, Download, FileAudio, FileBox, FileImage, FileVideo, FolderOpen, FolderPlus, RefreshCw, Search, Settings2, Upload } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; -import { App, Button, Drawer, Empty, Input, Spin, Tag, Tree, Typography } from "antd"; +import { App, Button, Drawer, Input, Spin, Tag, Tree, Typography } from "antd"; +import { IconButton } from "@/components/ui/base/buttons"; +import { EmptyState } from "@/components/ui/product/empty-state"; import type { DataNode } from "antd/es/tree"; import { useNavigate } from "react-router"; @@ -183,7 +185,7 @@ export default function EagleLibraryPage() {
diff --git a/web/src/pages/plugins/index.tsx b/web/src/pages/plugins/index.tsx index 1e3deb591..3ca472f8f 100644 --- a/web/src/pages/plugins/index.tsx +++ b/web/src/pages/plugins/index.tsx @@ -1,4 +1,5 @@ -import { App, Button, Input, Modal, Select, Switch, Typography } from "antd"; +import { App, Button, Input, Modal, Select, Typography } from "antd"; +import { Switch } from "@/components/ui/base/switch"; import { AudioLines, CalendarDays, CheckCircle2, Clock3, CreditCard, ExternalLink, Film, FolderOpen, Image as ImageIcon, MessageSquareText, PlugZap, RefreshCw, Search, Settings2, ShieldCheck, SlidersHorizontal } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router"; diff --git a/web/src/pages/projects/detail.tsx b/web/src/pages/projects/detail.tsx index 139045eac..29158689a 100644 --- a/web/src/pages/projects/detail.tsx +++ b/web/src/pages/projects/detail.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Alert, App, Tooltip } from "antd"; +import { Alert, App } from "antd"; +import { Tooltip } from "@/components/ui/base/tooltip"; import { ArrowLeft, BookOpenText, Clapperboard, Images, LayoutDashboard, LayoutGrid, Plus, Scissors, Settings2, type LucideIcon } from "lucide-react"; import { Link, Navigate, useNavigate, useParams } from "react-router"; @@ -173,7 +174,7 @@ function ProjectWorkspaceTopBar({ detail, projectId, activeView, unitId, stage, const active = item.key === activeView; const href = item.key === "chapters" ? chapterHref : item.key === "workflow" ? workflowHref : `/projects/${projectId}/${item.key}`; return ( - + ) : ( - + )} ); diff --git a/web/src/pages/projects/detail/canvases.tsx b/web/src/pages/projects/detail/canvases.tsx index 3bbda2776..e663d7f16 100644 --- a/web/src/pages/projects/detail/canvases.tsx +++ b/web/src/pages/projects/detail/canvases.tsx @@ -1,6 +1,8 @@ +import { App, Button, Popconfirm, Select } from "antd"; +import { Tooltip } from "@/components/ui/base/tooltip"; import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { App, Button, Popconfirm, Select, Tooltip } from "antd"; + import { Link2, Unlink, X } from "lucide-react"; import { CanvasProjectCard } from "@/components/canvas/canvas-project-card"; diff --git a/web/src/pages/projects/detail/chapters.tsx b/web/src/pages/projects/detail/chapters.tsx index 83b653aa0..944e3860b 100644 --- a/web/src/pages/projects/detail/chapters.tsx +++ b/web/src/pages/projects/detail/chapters.tsx @@ -1,7 +1,10 @@ +import { App, Button, Dropdown, Form, Input, InputNumber, Modal, Popconfirm } from "antd"; +import { Tooltip } from "@/components/ui/base/tooltip"; import { useDeferredValue, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { Alert, App, Button, Dropdown, Form, Input, InputNumber, Modal, Popconfirm, Tooltip } from "antd"; + +import { Callout } from "@/components/ui/product/callout"; import CharacterCount from "@tiptap/extension-character-count"; import Color from "@tiptap/extension-color"; import Highlight from "@tiptap/extension-highlight"; @@ -630,7 +633,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
{selectedUnit?.title}
正文将作为分镜依据,生成结果会直接写入“分镜制作”。
- {storyboardImpact.shotCount ? : null} + {storyboardImpact.shotCount ? 继续后会先生成新分镜;生成成功后,再按确认内容整体替换旧镜头及其关联数据。 : null}