feat: unify workspace design and portable packaging

Establish shared scoped UI primitives and make repeatable Windows builds preserve existing user data safely.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-03 00:43:07 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent e8dc4d03fd
commit 09e9fbf5e2
14 changed files with 2306 additions and 640 deletions
+492
View File
@@ -0,0 +1,492 @@
# GoodBuddy 统一界面设计系统
## 1. 目的与适用范围
本文定义 GoodBuddy 桌面端的统一界面规则,适用于聊天与最近对话、知识库、智能心跳、任务与活动记录,以及后续新增的一级页面。
设计系统解决两类问题:
1. 统一跨页面的视觉语言、信息层级和交互反馈。
2. 保留不同工作场景所需的布局差异,不强行把阅读、工作台、仪表盘和数据视图做成同一种页面。
所有新界面必须优先使用本文定义的语义令牌、页面壳层和共享组件。现有界面迁移时应保持功能与数据语义不变,不以视觉统一为理由隐藏范围、状态或风险。
## 2. 核心原则
### 2.1 一致的语义,不要求相同的布局
- 相同含义使用相同的颜色、间距、控件和文案结构。
- 不同任务使用明确命名的页面壳层,不再为单个页面随意设置宽度。
- 页签只用于同级页面导航,分段控件只用于当前视图内的单选切换,筛选控件只改变当前数据集合。
### 2.2 范围始终可见
知识、任务、自动化和活动记录可能属于全局或某个项目。当前范围必须在页面标题区或对象标题旁持续显示,不能只通过入口位置、默认筛选或颜色暗示。
### 2.3 主任务优先
- 每个页面只保留一个视觉上最突出的主操作。
- 页面标题、范围、说明、主操作和导航按固定层级排列。
- 次要设置、筛选和批量操作不得与主任务争夺注意力。
### 2.4 状态明确且可恢复
- 加载、空内容、无结果、失败、只读和权限不足是不同状态,必须分别呈现。
- 可撤销操作优先提供撤销,不可撤销操作必须在执行前说明影响范围。
- 不依赖颜色单独表达状态或风险。
### 2.5 默认支持浅色与深色主题
组件不得直接使用原始颜色值。主题差异只在令牌层定义,业务组件仅引用语义令牌。
## 3. 设计令牌
令牌以 CSS 自定义属性实现。`:root` 提供浅色值,`[data-theme="dark"]` 覆盖深色值。组件样式不得新增只服务于单个页面的颜色、阴影、圆角或间距常量。
### 3.1 颜色令牌
| 令牌 | 用途 |
| --- | --- |
| `--surface-canvas` | 应用和页面底色 |
| `--surface-raised` | 卡片、面板、输入框和弹窗 |
| `--surface-subtle` | 次级区域和控件底色 |
| `--surface-muted` | 进度轨道和更弱的分隔表面 |
| `--text-primary` | 标题与主要正文 |
| `--text-secondary` | 辅助正文 |
| `--text-muted` | 占位、时间和弱提示 |
| `--text-on-accent` | 强调色或危险色表面上的文字 |
| `--border-default` | 常规边框 |
| `--border-control` | 输入框、分段控件等必须清晰可辨的控件边界 |
| `--border-subtle` | 区块分隔线 |
| `--accent``--accent-hover` | 强调文字、图标和交互反馈 |
| `--accent-solid``--accent-solid-hover` | 主按钮等需要反白文字的强调背景 |
| `--accent-selected``--accent-subtle` | 选中边框、选中背景和信息强调 |
| `--success``--success-subtle` | 成功状态 |
| `--warning` | 警告状态 |
| `--danger``--danger-strong` | 危险文本和状态 |
| `--danger-solid` | 最终确认按钮等需要反白文字的危险背景 |
| `--danger-border``--danger-subtle` | 危险入口和失败状态背景 |
浅色与深色具体值只在 `styles.css` 的主题根节点维护。状态组件必须同时显示文字或图标,不能仅靠颜色区分。
### 3.2 间距令牌
采用 4 像素基准:
| 令牌 | 值 | 典型用途 |
| --- | --- | --- |
| `--space-0` | `0` | 显式取消间距 |
| `--space-1` | `4px` | 图标内部微调 |
| `--space-2` | `8px` | 紧凑控件、图标与文字 |
| `--space-3` | `12px` | 表单字段内部、紧凑列表 |
| `--space-4` | `16px` | 默认组件间距 |
| `--space-6` | `24px` | 区块间距、窄屏页面内边距 |
使用规则:
- 同一组件内部优先使用 `8px``12px``16px`
- 同一区块内组件之间优先使用 `16px``24px`
- 页面级区块之间优先使用 `32px``40px`
- 不新增 `14px``18px``22px` 等非令牌间距。
### 3.3 字体令牌
界面字体使用系统无衬线字体栈,代码、标识符和原始日志使用等宽字体栈。
| 令牌 | 字号 / 行高 | 字重 | 用途 |
| --- | --- | --- | --- |
| `--font-caption` | `10px` | 时间、短标签和紧凑元数据 |
| `--font-body` | `12px` | 默认界面正文 |
| `--font-section-title` | `14px` | 卡片和区块标题 |
| `--font-page-title` | `24px` | 一级页面标题 |
连续阅读内容使用 `13px``14px`,持久辅助信息不得小于 `10px`。页面内不得通过同时放大字号、加粗和使用强调色制造多个同级主标题。
### 3.4 圆角、阴影与层级
| 令牌 | 值 | 用途 |
| --- | --- | --- |
| `--radius-control` | `8px` | 输入框、按钮、菜单项 |
| `--radius-card` | `12px` | 卡片和面板 |
| `--shadow-card` | 主题定义 | 卡片和选中分段控件 |
| `--shadow-dialog` | 主题定义 | 对话框和浮层 |
普通卡片通过表面色和边框区分,不默认添加阴影。阴影只表示真实的浮层关系。不允许页面自行创建高于 `--z-dialog` 的层级。
### 3.5 动效令牌
- `--motion-fast: 120ms`:悬停、按压、焦点反馈。
- `--motion-normal: 180ms`:菜单、折叠区域、轻量视图切换。
- `--motion-slow: 240ms`:对话框和抽屉。
- 默认缓动使用 `ease-out`,退出可使用 `ease-in`
-`prefers-reduced-motion: reduce` 下移除非必要位移和缩放,仅保留即时状态切换。
## 4. 页面壳层
所有一级页面使用 `PageShell`。壳层负责水平居中、最大宽度、响应式内边距、页面背景和滚动边界,业务页面不得再次设置独立的整体最大宽度。
| 变体 | 最大内容宽度 | 适用场景 | 页面映射 |
| --- | --- | --- | --- |
| `reading` | `820px` | 连续阅读、单列编辑、对话撰写 | 聊天正文与输入区 |
| `standard` | `960px` | 常规列表、设置、表单与任务管理 | 最近对话、任务 |
| `dashboard` | `1040px` | 指标、卡片网格、宽表格与审计数据 | 智能心跳、活动记录 |
| `master-detail` | 可用空间内流式铺开 | 左侧选择、右侧编辑或预览 | 知识库 |
共同规则:
- 宽度计算统一为 `min(100%, 变体最大宽度)`
- 页面左右内边距在常规桌面窗口为 `32px`,中等窗口为 `24px`,窄窗口为 `16px`
- 页面顶部和底部内边距默认为 `32px`
- `master-detail` 的左栏建议为 `280px``360px`,右栏占剩余空间。分隔线和滚动容器属于壳层,不由内容卡片模拟。
- 聊天输入区可以粘附在 `reading` 壳层底部,但消息内容和输入区必须共享同一阅读宽度。
- 宽表格可在 `dashboard` 内容区内部横向滚动,不得撑宽整个应用窗口。
## 5. 信息层级
一级页面按以下顺序组织:
1. `PageHeader`
2. `PageTabs`,仅在存在同级子页面时出现
3. 页面级状态或重要提示
4. 筛选与批量操作工具栏
5. 主内容
6. 与主内容就近关联的分页或加载状态
一个页面只能有一个可见的一级标题。卡片标题不得重复页面标题。面包屑只在层级超过两级且返回关系无法通过侧栏或主从布局表达时使用。
### 5.1 PageHeader
`PageHeader` 统一接收以下内容:
- `title`:必填,简短名词或任务名称。
- `description`:可选,一行说明当前页面能做什么,不重复标题。
- `scope`:涉及范围的数据页面必填,使用 `ScopeBadge`
- `primaryAction`:可选,页面唯一主操作。
- `secondaryActions`:可选,最多两个直接显示,其余进入更多菜单。
- `status`:可选,用于只读、同步中、连接异常等页面级状态。
布局规则:
- 标题和范围徽标在同一信息组中,范围不得放入更多菜单。
- 主操作位于标题区右侧,窄窗口下换行到标题下方并保持靠左。
- 不在内容卡片中再次渲染同名标题。
- 标题区默认不粘附。只有主内容长且页面级操作需要持续可用时才启用粘附。
## 6. 共享组件
### 6.1 PageTabs
用于同一一级页面内的同级内容面板,例如心跳的“成长概览”和“心跳计划”。
- 使用 `tablist``tab``tabpanel` 语义,当前项使用 `aria-selected="true"`
- 一级页面之间的导航由应用主导航承担,不复用 `PageTabs`
- 标签保持短名词,不显示句号,不用页签承载开关或过滤条件。
- 项目过多时优先重组信息架构,不把一级页签做成多行。
### 6.2 SegmentedControl
用于当前页面内互斥的视图或状态切换,例如“列表 / 网格”或少量单选状态。
- 选项数量为 2 至 4 个。
- 使用带 `aria-pressed` 的按钮组语义,支持方向键切换。
- 不能用于多选筛选、页面导航或执行即时命令。
- 控件宽度由内容决定,除移动窄屏外不默认等分整行。
### 6.3 筛选工具栏
筛选不创建第五种类似页签的视觉控件:
- 2 至 4 个互斥状态可使用 `SegmentedControl`
- 多维条件使用统一尺寸的选择框、搜索框和复选菜单。
- 已生效条件以文字和值明确展示,并提供“清除筛选”。
- 无匹配结果使用“无结果”状态,不能显示为首次使用的空状态。
- 筛选条件改变数据范围时,结果数量或当前条件必须可见。
### 6.4 ScopeBadge
`ScopeBadge` 表达对象或页面数据的归属范围:
- 全局范围显示“全局”,并配合通用范围图标。
- 项目范围显示“项目:项目名称”,不得只显示项目名称。
- 未知或已失效范围显示“范围不可用”,并提供修复入口或只读说明。
- 徽标始终包含文字,不以颜色作为唯一差异。
- 可切换范围时,徽标作为范围选择器的触发器,并提供明确的展开状态和键盘操作。
- 不可切换范围时,徽标为只读状态,不显示下拉箭头。
创建新对象时必须在提交按钮附近再次显示目标范围。跨范围移动属于显式操作,需要说明原范围、目标范围和对关联数据的影响。
### 6.5 EmptyState
`EmptyState` 统一包含:
- 能说明状态的图标或简洁插图。
- 一句标题。
- 一至两句原因或下一步说明。
- 最多一个主操作和一个次操作。
必须区分:
| 状态 | 标题示例 | 操作原则 |
| --- | --- | --- |
| 首次为空 | “还没有知识条目” | 提供创建或导入 |
| 筛选无结果 | “没有符合条件的结果” | 提供清除筛选 |
| 搜索无结果 | “未找到相关内容” | 建议修改关键词 |
| 加载失败 | “内容加载失败” | 提供重试并保留错误上下文 |
| 无权限或只读 | “当前范围不可编辑” | 说明原因和可行路径 |
空状态不得使用孤立的短句或仅显示图标。加载中不得先闪现空状态。
### 6.6 危险操作
共享危险操作样式包含:
- `danger-ghost`:列表行、菜单和工具栏中的删除入口。
- `danger-solid`:确认对话框中的最终危险操作。
- `danger-zone`:设置页中集中展示的高影响操作区域。
普通页面不得用实心红色按钮与主操作并列。危险入口必须使用具体动词和对象,例如“删除知识条目”,避免只写“确定”。
### 6.7 数据表格与活动列表
- 表头、单元格、空值、状态和行操作使用统一对齐规则。
- 文本默认左对齐,数值右对齐,状态与短标签可居中。
- 时间显示使用一致格式,并在需要时通过工具提示提供完整时间和时区。
- 行操作默认放在行末。高频安全操作可直接显示,低频或危险操作进入更多菜单。
- 活动记录必须保留操作者、动作、对象、范围、结果和时间等审计语义,不用纯图标代替关键字段。
- 表格密度可以选择“默认”或“紧凑”,但同一页面不得混用。
## 7. 交互状态
所有可交互组件必须实现:
- 默认:文本、边框和背景层级清晰。
- 悬停:提供轻量背景或边框反馈,不改变布局。
- 按下:反馈比悬停更强,持续时间使用 `--motion-fast`
- 键盘焦点:显示至少 `2px` 的高对比焦点环,不被容器裁切。
- 选中:同时使用背景、边框、图标或字重中的至少两种信号。
- 禁用:降低强调度,同时保留可读标签,并通过说明或工具提示解释原因。
- 加载:防止重复提交,保留原按钮宽度并显示进行中标签。
- 错误:就近显示可执行的错误说明,不只弹出短暂通知。
异步提交成功后更新内容并提供明确反馈。失败时保留用户输入和筛选上下文。
## 8. 范围与数据语义
### 8.1 页面范围
- 页面范围决定当前列表、搜索、创建和批量操作针对的数据集合。
- 页面标题区必须持续显示当前范围。
- 搜索框占位文案应反映范围,例如“搜索当前项目的知识”。
- 切换范围后清理不再有效的选择项,并明确提示数据集合已变化。
### 8.2 对象范围
- 详情页或主从布局的右侧面板显示所选对象自身的范围。
- 当页面范围与对象范围不一致时,必须显示解释,不得静默混合。
- 全局对象可被项目引用时,应分别表达“归属范围”和“当前引用关系”。
### 8.3 操作范围
批量操作、导入、删除、移动和自动化运行前,界面必须明确:
1. 将影响哪些对象。
2. 对象属于全局还是某个项目。
3. 操作是否可撤销。
4. 是否影响关联任务、知识引用或历史记录。
## 9. 破坏性操作政策
### 9.1 风险等级
| 等级 | 示例 | 要求 |
| --- | --- | --- |
| 低 | 移除可立即恢复的筛选、取消未保存草稿 | 通常无需确认,必要时提供撤销 |
| 中 | 删除单个可恢复对象、停止正在运行的任务 | 显式确认或操作后撤销,说明直接影响 |
| 高 | 永久删除、批量删除、清空历史、删除被引用对象 | 必须使用确认对话框,说明范围、数量和不可逆性 |
### 9.2 确认对话框
- 标题包含具体动作和对象,例如“永久删除 12 条活动记录?”
- 正文说明范围、关联影响和恢复可能性。
- 取消按钮在前,危险按钮在后。初始焦点放在取消按钮。
- 最终按钮使用 `danger-solid`,标签重复具体动作。
- 仅对高影响且不可恢复的操作要求输入对象名称或确认短语,避免把摩擦用于所有删除。
- 提交期间锁定重复操作。失败后保持对话框打开并显示可处理的错误信息。
### 9.3 撤销与反馈
- 可恢复删除优先在操作后显示带“撤销”的持久通知。
- 撤销窗口结束前,不把操作描述为“永久删除”。
- 删除成功后更新列表、选中项和计数。主从布局中被删除对象的详情面板应回到明确的未选择状态。
- 审计记录不得因普通对象删除而被静默移除。
## 10. 深色主题
- 深色主题通过语义令牌替换实现,不在组件中使用主题条件分支选择原始颜色。
- 表面层级主要依靠亮度和边框区分,避免大面积纯黑与高亮白形成刺眼对比。
- 输入框、代码块、表格悬停、选中行、弹窗遮罩和滚动条必须分别检查深色值。
- 图片、图表和状态色在深色背景下保持可读。图表系列不能只靠色相区分,还应使用形状、线型或标签。
- 焦点环、危险文本和弱文本在两种主题下都满足对比度要求。
- 原生控件和滚动条声明正确的 `color-scheme`
- 主题切换不得导致布局、边框宽度或字号变化。
## 11. 无障碍
### 11.1 键盘与焦点
- 所有交互均可通过键盘完成。
- 焦点顺序与视觉顺序一致,打开弹窗后焦点进入弹窗,关闭后返回触发元素。
- 页面导航链接按正常 Tab 顺序操作。同页签组和单选组使用方向键,菜单使用上下方向键和 Escape。
- 不给不可交互容器添加焦点,不用 `div` 模拟按钮而缺少按钮语义。
### 11.2 语义与标签
- 图标按钮必须有可访问名称,并在视觉上提供工具提示。
- 表单控件有持久标签,不能只使用占位文案。
- 状态变化使用适当的实时区域,但避免对频繁日志逐条播报。
- 错误信息与对应字段建立程序化关联。
- 表格使用正确的表头关系,复杂审计记录在窄屏下仍保持字段标签。
### 11.3 对比度与触控目标
- 正文与背景对比度至少为 `4.5:1`,大号文本至少为 `3:1`
- 控件边界、焦点和关键图形对比度至少为 `3:1`
- 常规交互目标建议至少 `32px × 32px`。紧凑表格可使用 `28px` 高度,但相邻目标之间必须有足够间隔。
- 文字缩放至 `200%` 时,核心操作、范围信息和错误提示不能被裁切。
## 12. 响应式与窗口规则
GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证任务连续性,不简单隐藏信息。
### 12.1 宽窗口,`1200px` 及以上
- 使用各 `PageShell` 的完整最大宽度。
- `dashboard` 可使用 3 至 4 列卡片。
- `master-detail` 保持双栏,详情区获得主要空间。
### 12.2 中等窗口,`960px` 至 `1199px`
- 页面左右内边距为 `24px`
- 仪表盘降为 2 列。
- 主从布局缩小左栏,但不得低于 `280px`
- 表格优先收起低优先级列到详情或行展开区,不隐藏范围、状态和时间。
### 12.3 窄窗口,`720px` 至 `959px`
- `PageHeader` 的操作区换行。
- `master-detail` 变为单面板导航。进入详情后提供明确返回列表的按钮。
- 仪表盘使用单列或 2 列,取决于卡片最小宽度。
- `PageTabs` 可单行横向滚动,不换成下拉菜单。
- 宽表格在自身容器内横向滚动,并固定关键标识列时确保键盘可达。
### 12.4 极窄窗口,小于 `720px`
- 页面左右内边距为 `16px`
- 标题、范围徽标和操作纵向排列,但范围不得隐藏。
- 主操作可以占满可用宽度,次操作进入更多菜单。
- 分段控件可等分整行,筛选工具栏改为可展开区域。
- 聊天输入区保持可见,并考虑窗口安全边距。
- 对话框使用接近全宽的布局,仍保留 `16px` 外边距。
## 13. 页面应用规范
### 13.1 聊天
- 使用 `reading` 壳层,消息流与输入区共享宽度。
- 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。
- 模式、模型或工具权限属于上下文控制,不与页面导航页签混用。
- 空对话展示可执行的起始建议,发送失败保留输入并提供重试。
### 13.2 最近对话
- 使用 `standard` 壳层和统一 `PageHeader`
- 搜索、范围和时间筛选位于筛选工具栏。
- 行项目统一显示标题、范围、最近更新时间和必要状态。
- 删除入口使用 `danger-ghost`,并按数据可恢复性执行确认或撤销策略。
### 13.3 知识库
- 使用 `master-detail` 壳层。
- 左侧负责范围、搜索、筛选和条目选择,右侧负责详情、编辑和预览。
- 两侧均只使用语义颜色令牌,禁止内联浅色背景或边框。
- 选中条目后持续显示对象范围。没有选中项与知识库为空必须使用不同状态。
- 窄窗口切换为单面板导航,不把双栏压缩到不可读。
### 13.4 智能心跳
- 使用 `dashboard` 壳层。
- 顶部先呈现运行状态、当前范围和主操作,再呈现指标和配置。
- 状态卡片使用统一状态令牌,不只依赖颜色。
- 运行历史与配置使用明确区块,不以多套相似页签混合导航、开关和筛选。
### 13.5 任务与活动
- 任务使用 `standard` 壳层,活动记录使用 `dashboard` 壳层。
- “任务 / 活动”作为同级页面时使用 `PageTabs`
- 任务状态筛选使用 `SegmentedControl` 或筛选工具栏,不再模拟页签。
- 活动记录保留审计字段和范围,支持独立容器横向滚动。
- 批量停止、删除和清空历史遵循破坏性操作政策。
## 14. 文案规则
- 使用简体中文,动词直接、对象明确。
- 页面标题使用名词,例如“知识库”“智能心跳”“活动记录”。
- 按钮使用“动词 + 对象”,例如“新建任务”“导入知识”“停止运行”。
- 状态文案描述事实,例如“正在同步”“上次运行失败”,不使用含糊的“异常”。
- 错误提示包含发生了什么、用户可以做什么。保留必要的错误上下文,但不得暴露凭据、授权头、私人文档或未脱敏的提供商响应。
- 同一概念固定使用一个名称,不交替使用“项目空间”“工作区”“范围”等近义词。产品内统一使用“范围”表达全局与项目归属。
## 15. 迁移检查清单
### 15.1 基础层
- [ ] 建立浅色与深色语义颜色令牌,移除业务组件中的原始颜色值。
- [ ] 建立间距、字体、圆角、阴影、层级和动效令牌。
- [ ] 为主题切换、减少动态效果和原生控件设置全局规则。
- [ ] 建立组件交互状态和焦点环基线。
### 15.2 页面壳层与层级
- [ ] 实现 `PageShell``reading``standard``dashboard``master-detail` 变体。
- [ ] 按页面映射替换 `820px``900px``1040px` 和全宽等分散宽度声明。
- [ ] 每个一级页面只保留一个 `PageHeader` 和一个一级标题。
- [ ] 移除内容卡片中与页面标题重复的标题。
### 15.3 共享组件
- [ ] 实现并迁移 `PageHeader`
- [ ] 使用 `PageTabs` 统一同级页面导航。
- [ ] 使用 `SegmentedControl` 统一少量互斥视图和状态切换。
- [ ] 建立统一筛选工具栏,移除以页签样式伪装的筛选。
- [ ] 实现 `ScopeBadge` 并覆盖全局、项目、失效和可切换状态。
- [ ] 实现 `EmptyState` 的首次为空、无结果、失败和只读变体。
- [ ] 实现 `danger-ghost``danger-solid``danger-zone`
### 15.4 页面迁移
- [ ] 聊天迁移到 `reading`,统一消息流与输入区宽度。
- [ ] 最近对话迁移到 `standard`,统一搜索、范围、时间和删除行为。
- [ ] 知识库迁移到 `master-detail`,清除内联浅色样式并补齐窄窗口单面板流程。
- [ ] 智能心跳迁移到 `dashboard`,统一状态卡片、配置和运行历史层级。
- [ ] 任务迁移到 `standard`,活动记录迁移到 `dashboard`,统一导航、筛选和表格行为。
### 15.5 验收
- [ ] 在浅色与深色主题下检查所有页面、弹窗、菜单、输入框、表格和空状态。
- [ ] 在大于等于 `1200px``960px``720px` 和小于 `720px` 的窗口宽度检查布局。
- [ ] 仅使用键盘完成导航、筛选、创建、编辑、确认和取消。
- [ ] 验证焦点恢复、可访问名称、表单错误关联和实时状态播报。
- [ ] 验证页面范围、对象范围和操作范围在关键流程中始终可见。
- [ ] 验证删除、批量操作、停止运行和清空历史符合风险等级策略。
- [ ] 验证加载中、首次为空、筛选无结果、搜索无结果、失败和只读状态不会互相混用。
## 16. 完成标准
一个页面只有在满足以下条件时才视为完成迁移:
1. 使用明确的 `PageShell` 变体,没有页面专属整体宽度。
2. 使用统一标题层级、共享导航和筛选控件。
3. 所有颜色、间距、字体、圆角和阴影来自令牌。
4. 全局或项目范围在浏览、创建、编辑和危险操作中均可见。
5. 浅色、深色、键盘和各窗口宽度下均可完成核心任务。
6. 空状态、错误状态和危险操作符合本文规则。
+244 -9
View File
@@ -1,32 +1,263 @@
const { spawnSync } = require('node:child_process')
const {
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
statSync
statSync,
writeFileSync
} = require('node:fs')
const { join } = require('node:path')
const { dirname, join, parse, resolve } = require('node:path')
const root = join(__dirname, '..')
const packageJson = JSON.parse(
readFileSync(join(root, 'package.json'), 'utf8')
)
const outputRoot = join(root, 'dist')
const stagingRoot = join(outputRoot, '.portable-stage-x64')
const stagingRoot = join(
outputRoot,
`.portable-stage-x64-${process.pid}`
)
const unpackedPath = join(stagingRoot, 'win-unpacked')
const portableName = `GoodBuddy-${packageJson.version}-win-x64-portable`
const portablePath = join(outputRoot, portableName)
const portablePath = process.env.GOODBUDDY_OUT_DIR
? resolve(process.env.GOODBUDDY_OUT_DIR)
: join(outputRoot, portableName)
const markerName = '.goodbuddy-portable.json'
if (process.platform !== 'win32' || process.arch !== 'x64') {
throw new Error('Portable 目录当前必须在 Windows x64 上构建')
}
if (statSync(portablePath, { throwIfNoEntry: false })) {
if (
portablePath === parse(portablePath).root ||
portablePath === root ||
portablePath === outputRoot
) {
throw new Error(
`输出目录已存在,请先移动或删除${portablePath}`
`拒绝使用不安全的输出目录:${portablePath}`
)
}
function assertOutputUnlocked(directory) {
if (!existsSync(directory)) {
return
}
const probe = `${directory}.build-lock-check-${process.pid}`
try {
renameSync(directory, probe)
renameSync(probe, directory)
} catch (error) {
if (!existsSync(directory) && existsSync(probe)) {
try {
renameSync(probe, directory)
} catch {
// The original lock error is more useful.
}
}
if (
error?.code === 'EPERM' ||
error?.code === 'EBUSY' ||
error?.code === 'EACCES'
) {
throw new Error(
`无法更新 ${directory},请先关闭其中正在运行的 GoodBuddy.exe`,
{ cause: error }
)
}
throw error
}
}
function ensureElectronRuntime() {
const electronRoot = join(root, 'node_modules', 'electron')
const electronDist = join(electronRoot, 'dist')
const executable = join(electronDist, 'electron.exe')
const versionFile = join(electronDist, 'version')
const installedPackagePath = join(electronRoot, 'package.json')
const lock = JSON.parse(
readFileSync(join(root, 'package-lock.json'), 'utf8')
)
const lockedVersion =
lock.packages?.['node_modules/electron']?.version
if (
!existsSync(installedPackagePath) ||
!existsSync(executable) ||
!existsSync(versionFile)
) {
throw new Error('缺少本地 Electron 运行时,请先运行 npm ci')
}
const installedVersion = JSON.parse(
readFileSync(installedPackagePath, 'utf8')
).version
const runtimeVersion = readFileSync(versionFile, 'utf8')
.trim()
.replace(/^v/u, '')
if (
!lockedVersion ||
installedVersion !== lockedVersion ||
runtimeVersion !== lockedVersion
) {
throw new Error(
`Electron 版本不一致(锁定 ${lockedVersion ?? '缺失'},依赖 ${installedVersion},运行时 ${runtimeVersion}),请运行 npm ci`
)
}
return electronDist
}
function copyDirectoryContents(source, destination) {
mkdirSync(destination, { recursive: true })
for (const entry of readdirSync(source, { withFileTypes: true })) {
cpSync(
join(source, entry.name),
join(destination, entry.name),
{ recursive: entry.isDirectory(), force: true }
)
}
}
function portableRequiredPaths(directory) {
return [
join(directory, 'GoodBuddy.exe'),
join(directory, 'resources', 'app.asar'),
join(
directory,
'resources',
'runtimes',
'opencode',
'opencode.exe'
),
join(
directory,
'resources',
'runtimes',
'continue',
'package.json'
)
]
}
function assertPortableOutput(directory) {
const requiredPaths = portableRequiredPaths(directory)
const missing = requiredPaths.filter(
(filePath) => !statSync(filePath, { throwIfNoEntry: false })?.isFile()
)
if (missing.length > 0) {
throw new Error(
`Portable 目录缺少必要文件:${missing.join(', ')}`
)
}
}
function assertReplaceableOutput(directory) {
if (!existsSync(directory)) {
return
}
const entries = readdirSync(directory)
if (entries.every((entry) => entry === 'data')) {
return
}
try {
const marker = JSON.parse(
readFileSync(join(directory, markerName), 'utf8')
)
if (
marker.productName === 'GoodBuddy' &&
marker.formatVersion === 1
) {
return
}
} catch {
// Fall back to recognizing portable outputs created before markers.
}
if (
portableRequiredPaths(directory).every(
(filePath) =>
statSync(filePath, { throwIfNoEntry: false })?.isFile()
)
) {
return
}
throw new Error(
`拒绝覆盖未识别的目录:${directory}。请选择空目录或已有的 GoodBuddy portable 目录`
)
}
function writePortableMarker(directory) {
writeFileSync(
join(directory, markerName),
`${JSON.stringify({
formatVersion: 1,
productName: 'GoodBuddy',
version: packageJson.version
}, null, 2)}\n`,
'utf8'
)
}
function replacePortableOutput(source, destination) {
const replacement = `${destination}.replacement-${process.pid}`
const backup = `${destination}.previous-${process.pid}`
mkdirSync(dirname(destination), { recursive: true })
rmSync(replacement, { recursive: true, force: true })
rmSync(backup, { recursive: true, force: true })
copyDirectoryContents(source, replacement)
try {
writePortableMarker(replacement)
assertPortableOutput(replacement)
if (!existsSync(destination)) {
renameSync(replacement, destination)
return
}
assertReplaceableOutput(destination)
assertOutputUnlocked(destination)
renameSync(destination, backup)
try {
renameSync(replacement, destination)
const preservedData = join(backup, 'data')
if (existsSync(preservedData)) {
const destinationData = join(destination, 'data')
if (existsSync(destinationData)) {
throw new Error(
`新构建意外包含 data 目录,无法保留 ${preservedData}`
)
}
renameSync(preservedData, destinationData)
}
assertPortableOutput(destination)
} catch (error) {
try {
const movedData = join(destination, 'data')
const originalData = join(backup, 'data')
if (existsSync(movedData) && !existsSync(originalData)) {
renameSync(movedData, originalData)
}
if (existsSync(destination)) {
renameSync(destination, replacement)
}
if (existsSync(backup)) {
renameSync(backup, destination)
}
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
`更新失败且无法恢复原 portable 目录:${destination}`,
{ cause: rollbackError }
)
}
throw error
}
rmSync(backup, { recursive: true, force: true })
} finally {
rmSync(replacement, { recursive: true, force: true })
}
}
const electronDist = ensureElectronRuntime()
mkdirSync(outputRoot, { recursive: true })
rmSync(stagingRoot, { recursive: true, force: true })
@@ -37,7 +268,7 @@ const result = spawnSync(
'--dir',
'--x64',
`--config.directories.output=${stagingRoot}`,
'--config.electronDist=node_modules/electron/dist'
`--config.electronDist=${electronDist}`
],
{
cwd: root,
@@ -62,8 +293,12 @@ if (!statSync(unpackedPath, { throwIfNoEntry: false })?.isDirectory()) {
rmSync(stagingRoot, { recursive: true, force: true })
throw new Error('Electron Builder 未生成 portable 目录')
}
renameSync(unpackedPath, portablePath)
rmSync(stagingRoot, { recursive: true, force: true })
try {
assertPortableOutput(unpackedPath)
replacePortableOutput(unpackedPath, portablePath)
} finally {
rmSync(stagingRoot, { recursive: true, force: true })
}
console.log(`Portable 目录构建完成:${portablePath}`)
console.log(`启动文件:${join(portablePath, 'GoodBuddy.exe')}`)
+30 -1
View File
@@ -121,6 +121,13 @@ describe('ActivityPanel', () => {
)
fireEvent.click(screen.getByRole('button', { name: '清空记录' }))
expect(onClear).not.toHaveBeenCalled()
expect(
screen.getByText('永久清空 1 条活动记录?此操作不可撤销。')
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '确认清空 1 条活动记录' })
)
expect(onClear).toHaveBeenCalledOnce()
rerender(
@@ -133,7 +140,7 @@ describe('ActivityPanel', () => {
)
expect(
screen.getByText(
'尚无活动记录。任务请求、工具调用和审批决定会显示在这里。'
'任务请求、工具调用和审批决定会显示在这里。'
)
).toBeInTheDocument()
expect(
@@ -141,6 +148,28 @@ describe('ActivityPanel', () => {
).toBeDisabled()
})
it('uses the shared page hierarchy and explicit global scope', () => {
render(
<ActivityPanel
onClear={vi.fn()}
onOpenConversation={vi.fn()}
records={[]}
tokenUsage={makeTokenUsage()}
/>
)
expect(
screen.getByRole('heading', { level: 1, name: '任务与活动' })
).toBeInTheDocument()
expect(screen.getByText('全部项目')).toHaveClass('scope-badge')
expect(screen.getByLabelText('Token 用量分组')).toHaveClass(
'segmented-control'
)
expect(screen.getByLabelText('筛选活动')).toHaveClass(
'segmented-control'
)
})
it('never renders more than 500 records', () => {
const records = Array.from(
{ length: MAX_ACTIVITY_RECORDS + 1 },
+53 -65
View File
@@ -10,6 +10,12 @@ import {
groupTokenUsage,
type TokenUsageGroup
} from './token-usage'
import {
DestructiveConfirmActions,
EmptyState,
PageHeader,
SegmentedControl
} from './WorkspacePrimitives'
type ActivityFilter = 'all' | 'active' | 'failed'
@@ -122,7 +128,7 @@ function emptyMessage(filter: ActivityFilter): string {
if (filter === 'failed') {
return '当前没有失败、取消或中断的活动。'
}
return '尚无活动记录。任务请求、工具调用和审批决定会显示在这里。'
return '任务请求、工具调用和审批决定会显示在这里。'
}
export function ActivityPanel({
@@ -134,6 +140,7 @@ export function ActivityPanel({
const [filter, setFilter] = useState<ActivityFilter>('all')
const [tokenGroup, setTokenGroup] =
useState<TokenUsageGroup>('project')
const [confirmingClear, setConfirmingClear] = useState(false)
const visibleRecords = useMemo(
() => records.slice(0, MAX_ACTIVITY_RECORDS),
@@ -162,24 +169,31 @@ export function ActivityPanel({
aria-labelledby="activity-panel-title"
className="activity-panel"
>
<header className="activity-panel__header">
<div>
<p className="eyebrow">ACTIVITY AUDIT</p>
<h2 id="activity-panel-title">
<Activity aria-hidden="true" size={20} />
</h2>
</div>
<button
className="secondary-button activity-panel__clear"
disabled={visibleRecords.length === 0}
onClick={onClear}
type="button"
>
<Trash2 aria-hidden="true" size={15} />
</button>
</header>
<PageHeader
actions={
<DestructiveConfirmActions
confirmAriaLabel={`确认清空 ${visibleRecords.length} 条活动记录`}
confirmLabel={`清空 ${visibleRecords.length} 条记录`}
confirming={confirmingClear}
disabled={!confirmingClear && visibleRecords.length === 0}
icon={<Trash2 aria-hidden="true" size={15} />}
message={`永久清空 ${visibleRecords.length} 条活动记录?此操作不可撤销。`}
onCancel={() => setConfirmingClear(false)}
onConfirm={() => {
onClear()
setConfirmingClear(false)
}}
onRequestConfirm={() => setConfirmingClear(true)}
triggerLabel="清空记录"
/>
}
description="查看全部项目中的任务请求、工具调用、审批结果和 Token 用量。"
eyebrow="ACTIVITY AUDIT"
headingId="activity-panel-title"
icon={<Activity size={20} />}
scope={{ kind: 'all-projects' }}
title="任务与活动"
/>
<section
aria-labelledby="token-usage-title"
@@ -187,27 +201,12 @@ export function ActivityPanel({
>
<header className="token-usage__header">
<h3 id="token-usage-title">Token </h3>
<div
aria-label="Token 用量分组"
className="token-usage__groups"
role="group"
>
{tokenGroups.map((item) => (
<button
aria-pressed={tokenGroup === item.value}
className={
tokenGroup === item.value
? 'token-usage__group token-usage__group--active'
: 'token-usage__group'
}
key={item.value}
onClick={() => setTokenGroup(item.value)}
type="button"
>
{item.label}
</button>
))}
</div>
<SegmentedControl
ariaLabel="Token 用量分组"
onChange={setTokenGroup}
options={tokenGroups}
value={tokenGroup}
/>
</header>
<dl aria-label="Token 用量统计" className="token-usage__stats">
@@ -301,33 +300,22 @@ export function ActivityPanel({
</div>
</dl>
<div
aria-label="筛选活动"
className="activity-panel__filters"
role="group"
>
{filters.map((item) => (
<button
aria-pressed={filter === item.value}
className={
filter === item.value
? 'activity-filter activity-filter--active'
: 'activity-filter'
}
key={item.value}
onClick={() => setFilter(item.value)}
type="button"
>
{item.label}
</button>
))}
<div className="activity-panel__filters">
<SegmentedControl
ariaLabel="筛选活动"
onChange={setFilter}
options={filters}
value={filter}
/>
</div>
{filteredRecords.length === 0 ? (
<div className="activity-panel__empty">
<Activity aria-hidden="true" size={24} />
<p>{emptyMessage(filter)}</p>
</div>
<EmptyState
description={emptyMessage(filter)}
icon={<Activity size={24} />}
level="section"
title={filter === 'all' ? '尚无活动记录' : '没有匹配的活动'}
/>
) : (
<ol className="activity-list">
{filteredRecords.map((record, index) => {
@@ -344,7 +332,7 @@ export function ActivityPanel({
{kindLabels[record.kind]}
</span>
<span
className={`activity-item__status activity-item__status--${record.status}`}
className={`status-badge activity-item__status activity-item__status--${record.status}`}
>
{statusLabels[record.status]}
</span>
+8
View File
@@ -441,6 +441,7 @@ describe('App', () => {
})
expect(await screen.findByText('这是回答内容')).toBeInTheDocument()
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
})
it('keeps a draft in chat when Enter is pressed while the runtime loads', async () => {
@@ -573,6 +574,13 @@ describe('App', () => {
fireEvent.click(screen.getByText('任务与活动'))
const stats = await screen.findByLabelText('Token 用量统计')
expect(
screen.getByRole('heading', { level: 1, name: '任务与活动' })
).toBeInTheDocument()
expect(screen.queryByLabelText('专家角色')).not.toBeInTheDocument()
expect(
screen.queryByLabelText('切换助手工作栏')
).not.toBeInTheDocument()
await waitFor(() =>
expect(api.usage.getTokenSummary).toHaveBeenCalledOnce()
)
+35 -21
View File
@@ -69,6 +69,7 @@ import {
import { KnowledgeWorkspace } from './KnowledgeWorkspace'
import { HeartbeatCenter } from './HeartbeatCenter'
import { MarkdownRenderer } from './MarkdownRenderer'
import { PageShell, ScopeBadge } from './WorkspacePrimitives'
import {
ProjectSwitcher,
workModeLabels
@@ -580,6 +581,10 @@ function App(): React.JSX.Element {
() => conversations.find((conversation) => conversation.id === activeId),
[activeId, conversations]
)
const activeProject = useMemo(
() => projects.find((project) => project.id === activeProjectId),
[activeProjectId, projects]
)
const filteredConversations = useMemo(() => {
const query = searchQuery.trim().toLocaleLowerCase()
return conversations.filter(
@@ -2078,7 +2083,7 @@ function App(): React.JSX.Element {
type="button"
>
<History size={17} />
<span></span>
<span></span>
</button>
<button
className={
@@ -2127,7 +2132,7 @@ function App(): React.JSX.Element {
</nav>
<div className="conversation-list">
<p className="section-label"></p>
<p className="section-label"></p>
{filteredConversations.map((conversation) => (
<div className="conversation-row" key={conversation.id}>
<button
@@ -2224,7 +2229,7 @@ function App(): React.JSX.Element {
>
<span>
{view === 'knowledge'
? '本地知识库'
? '知识库'
: view === 'heartbeat'
? '智能心跳'
: view === 'activity'
@@ -2236,6 +2241,21 @@ function App(): React.JSX.Element {
{view === 'chat' && <Edit3 size={14} />}
</button>
)}
{view === 'chat' && (
<ScopeBadge
scope={
activeProject
? {
kind: 'project',
projectName: activeProject.name
}
: {
kind: 'unavailable',
explanation: '当前项目尚未加载。'
}
}
/>
)}
<div className="topbar__actions">
{view === 'chat' && (
<>
@@ -2257,9 +2277,7 @@ function App(): React.JSX.Element {
</button>
</>
)}
{view !== 'settings' &&
view !== 'heartbeat' &&
view !== 'knowledge' && (
{view === 'chat' && (
<select
aria-label="专家角色"
className="topbar__expert"
@@ -2294,9 +2312,7 @@ function App(): React.JSX.Element {
<span className="runtime-capability-badge"></span>
)}
</span>
{view !== 'settings' &&
view !== 'heartbeat' &&
view !== 'knowledge' && (
{view === 'chat' && (
<button
aria-label="切换助手工作栏"
aria-pressed={assistantSidebarOpen}
@@ -2341,7 +2357,7 @@ function App(): React.JSX.Element {
</header>
{view === 'chat' ? (
<>
<PageShell variant="reading">
<section className="chat" ref={scrollRef}>
{activeConversation?.messages.length === 1 && (
<div className="welcome">
@@ -2924,9 +2940,9 @@ function App(): React.JSX.Element {
{appInfo?.shortcut && ` 快捷唤起:${appInfo.shortcut}`}
</p>
</footer>
</>
</PageShell>
) : view === 'knowledge' ? (
<div className="workspace-panel-scroll workspace-panel-scroll--knowledge">
<PageShell variant="master-detail">
<KnowledgeWorkspace
documents={knowledgeSnapshot.documents}
evidence={knowledgeSnapshot.evidence}
@@ -3072,11 +3088,12 @@ function App(): React.JSX.Element {
selectedLibraryId={knowledgeSnapshot.selectedLibraryId}
sources={knowledgeSnapshot.sources}
/>
</div>
</PageShell>
) : view === 'heartbeat' ? (
<div className="workspace-panel-scroll workspace-panel-scroll--heartbeat">
<PageShell variant="dashboard">
<HeartbeatCenter
configs={assistantHeartbeats}
currentProjectName={activeProject?.name}
entries={heartbeatEntries}
memories={assistantMemories}
onCreate={createHeartbeat}
@@ -3090,7 +3107,7 @@ function App(): React.JSX.Element {
runs={heartbeatRuns}
tasks={assistantTasks}
/>
</div>
</PageShell>
) : view === 'settings' ? (
<SettingsPanel
appearanceTheme={appearanceTheme}
@@ -3110,14 +3127,14 @@ function App(): React.JSX.Element {
presentation="page"
/>
) : (
<div className="workspace-panel-scroll">
<PageShell variant="dashboard">
<ActivityPanel
onClear={() => setActivityRecords([])}
onOpenConversation={openActivityConversation}
records={activityRecords}
tokenUsage={tokenUsage}
/>
</div>
</PageShell>
)}
</main>
<RightAssistantSidebar
@@ -3204,10 +3221,7 @@ function App(): React.JSX.Element {
}}
onTabChange={setAssistantSidebarTab}
open={
assistantSidebarOpen &&
view !== 'settings' &&
view !== 'heartbeat' &&
view !== 'knowledge'
assistantSidebarOpen && view === 'chat'
}
schedules={assistantSchedules}
tab={assistantSidebarTab}
+25 -1
View File
@@ -19,6 +19,7 @@ import { HeartbeatCenter, type HeartbeatCenterProps } from './HeartbeatCenter'
const config: AssistantHeartbeatConfig = {
id: 'heartbeat-1',
projectId: 'project-1',
name: '智能成长回顾',
timezone: 'Asia/Shanghai',
recurrence: {
@@ -103,6 +104,7 @@ function createProps(
entries: [entry],
memories: [memory],
tasks: [task],
currentProjectName: '默认项目',
onCreate: vi.fn(async () => {}),
onSetPaused: vi.fn(async () => {}),
onRemove: vi.fn(async () => {}),
@@ -124,8 +126,12 @@ describe('HeartbeatCenter', () => {
render(<HeartbeatCenter {...createProps()} />)
expect(
screen.getByRole('heading', { name: '智能心跳' })
screen.getByRole('heading', { level: 1, name: '智能心跳' })
).toBeInTheDocument()
expect(screen.getByText('项目:默认项目 + 全局')).toHaveClass(
'scope-badge'
)
expect(screen.getByText(/ 09:00 · /u)).toBeInTheDocument()
expect(screen.getByText('1 个计划运行中')).toBeInTheDocument()
expect(screen.getByText('50%')).toBeInTheDocument()
expect(
@@ -217,6 +223,24 @@ describe('HeartbeatCenter', () => {
expect(screen.getByText(entry.highlights[0]!)).toBeInTheDocument()
})
it('explains the irreversible impact before deleting a plan', () => {
render(<HeartbeatCenter {...createProps()} />)
fireEvent.click(screen.getByRole('tab', { name: '心跳计划' }))
fireEvent.click(
screen.getByRole('button', {
name: `删除 ${config.name}`
})
)
const confirmation = screen.getByRole('alertdialog', {
name: `确认删除 ${config.name}`
})
expect(confirmation).toHaveTextContent(
'将永久删除此计划、运行历史和关联结果,且无法恢复。'
)
})
it('guides first-time users to create a heartbeat plan', () => {
render(
<HeartbeatCenter
+75 -83
View File
@@ -21,6 +21,11 @@ import type {
HeartbeatCreateInput
} from '../../shared/assistant-contracts'
import { HeartbeatSettings } from './HeartbeatSettings'
import {
EmptyState,
PageHeader,
PageTabs
} from './WorkspacePrimitives'
type HeartbeatCenterTab =
| 'overview'
@@ -48,6 +53,7 @@ export type HeartbeatCenterProps = {
status: 'completed' | 'cancelled'
) => Promise<void>
onUseFollowUpTask: (task: AssistantTask) => void
currentProjectName?: string
}
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
@@ -136,7 +142,8 @@ export function HeartbeatCenter({
onRefresh,
onSetMemoryStatus,
onSetTaskStatus,
onUseFollowUpTask
onUseFollowUpTask,
currentProjectName = '当前项目'
}: HeartbeatCenterProps): React.JSX.Element {
const [tab, setTab] = useState<HeartbeatCenterTab>('overview')
const [pendingAction, setPendingAction] = useState<string>()
@@ -260,55 +267,53 @@ export function HeartbeatCenter({
aria-labelledby="heartbeat-center-title"
className="heartbeat-center"
>
<header className="heartbeat-center__hero">
<div>
<p className="eyebrow">SMART HEARTBEAT</p>
<h2 id="heartbeat-center-title">
<HeartPulse aria-hidden="true" size={22} />
</h2>
<p>
GoodBuddy
</p>
</div>
<div className="heartbeat-center__hero-actions">
<button
aria-label="刷新智能心跳"
className="secondary-button"
disabled={pendingAction !== undefined}
onClick={() => void runAction('refresh', onRefresh)}
type="button"
>
<RefreshCw aria-hidden="true" size={14} />
</button>
{primaryConfig ? (
<PageHeader
actions={
<>
<button
className="primary-button"
aria-label="刷新智能心跳"
className="secondary-button"
disabled={pendingAction !== undefined}
onClick={() =>
void runAction(`run:${primaryConfig.id}`, () =>
onRunNow(primaryConfig.id)
)
}
onClick={() => void runAction('refresh', onRefresh)}
type="button"
>
<Play aria-hidden="true" size={14} />
{pendingAction === `run:${primaryConfig.id}`
? '心跳中…'
: '运行一次心跳'}
<RefreshCw aria-hidden="true" size={14} />
</button>
) : (
<button
className="primary-button"
onClick={() => setTab('plans')}
type="button"
>
</button>
)}
</div>
</header>
{primaryConfig ? (
<button
className="primary-button"
disabled={pendingAction !== undefined}
onClick={() =>
void runAction(`run:${primaryConfig.id}`, () =>
onRunNow(primaryConfig.id)
)
}
type="button"
>
<Play aria-hidden="true" size={14} />
{pendingAction === `run:${primaryConfig.id}`
? '心跳中…'
: '运行一次心跳'}
</button>
) : (
<button
className="primary-button"
onClick={() => setTab('plans')}
type="button"
>
</button>
)}
</>
}
description="定期回顾经历、沉淀记忆、发现问题,并把每次变化转化为可处理的成长建议。"
eyebrow="SMART HEARTBEAT"
headingId="heartbeat-center-title"
icon={<HeartPulse size={22} />}
scope={{ kind: 'mixed', projectName: currentProjectName }}
title="智能心跳"
/>
{error && (
<p className="heartbeat-center__error" role="alert">
@@ -316,31 +321,13 @@ export function HeartbeatCenter({
</p>
)}
<nav
aria-label="智能心跳视图"
className="heartbeat-center__tabs"
role="tablist"
>
{tabs.map((item) => (
<button
aria-controls={`heartbeat-panel-${item.id}`}
aria-selected={tab === item.id}
className={
tab === item.id
? 'heartbeat-center__tab heartbeat-center__tab--active'
: 'heartbeat-center__tab'
}
id={`heartbeat-tab-${item.id}`}
key={item.id}
onClick={() => setTab(item.id)}
role="tab"
type="button"
>
{item.label}
{item.count ? <span>{item.count}</span> : null}
</button>
))}
</nav>
<PageTabs
ariaLabel="智能心跳视图"
idPrefix="heartbeat"
onChange={setTab}
tabs={tabs}
value={tab}
/>
{tab === 'overview' && (
<div
@@ -372,18 +359,21 @@ export function HeartbeatCenter({
</span>
</div>
{configs.length === 0 ? (
<div className="heartbeat-center__empty">
<HeartPulse aria-hidden="true" size={24} />
<strong></strong>
<p> GoodBuddy </p>
<button
className="primary-button"
onClick={() => setTab('plans')}
type="button"
>
</button>
</div>
<EmptyState
action={
<button
className="primary-button"
onClick={() => setTab('plans')}
type="button"
>
</button>
}
description="配置每日或每周心跳,让 GoodBuddy 持续回顾和学习。"
icon={<HeartPulse size={24} />}
level="section"
title="尚未建立成长节奏"
/>
) : (
<div className="heartbeat-center__config-grid">
{configs.map((config) => (
@@ -403,7 +393,9 @@ export function HeartbeatCenter({
<strong>{config.name}</strong>
<small>
{recurrenceLabel(config)} ·{' '}
{config.projectId ? '当前项目' : '全局'}
{config.projectId
? currentProjectName
: '全局'}
</small>
</div>
</header>
+23 -38
View File
@@ -4,6 +4,7 @@ import type {
AssistantHeartbeatConfig,
HeartbeatCreateInput
} from '../../shared/assistant-contracts'
import { DestructiveConfirmActions } from './WorkspacePrimitives'
type HeartbeatSettingsProps = {
heartbeats: AssistantHeartbeatConfig[]
@@ -203,45 +204,29 @@ export function HeartbeatSettings({
>
</button>
{confirmingRemoveId === heartbeat.id ? (
<>
<button
aria-label={`确认删除 ${heartbeat.name}`}
disabled={pendingAction !== undefined}
onClick={() =>
void runAction(
`remove:${heartbeat.id}`,
async () => {
await onRemove(heartbeat.id)
setConfirmingRemoveId(undefined)
}
)
<DestructiveConfirmActions
cancelAriaLabel={`取消删除 ${heartbeat.name}`}
confirmAriaLabel={`确认删除 ${heartbeat.name}`}
confirmLabel="确认删除计划"
confirming={confirmingRemoveId === heartbeat.id}
disabled={pendingAction !== undefined}
message="将永久删除此计划、运行历史和关联结果,且无法恢复。"
onCancel={() => setConfirmingRemoveId(undefined)}
onConfirm={() =>
void runAction(
`remove:${heartbeat.id}`,
async () => {
await onRemove(heartbeat.id)
setConfirmingRemoveId(undefined)
}
type="button"
>
</button>
<button
aria-label={`取消删除 ${heartbeat.name}`}
disabled={pendingAction !== undefined}
onClick={() => setConfirmingRemoveId(undefined)}
type="button"
>
</button>
</>
) : (
<button
aria-label={`删除 ${heartbeat.name}`}
disabled={pendingAction !== undefined}
onClick={() =>
setConfirmingRemoveId(heartbeat.id)
}
type="button"
>
</button>
)}
)
}
onRequestConfirm={() =>
setConfirmingRemoveId(heartbeat.id)
}
triggerAriaLabel={`删除 ${heartbeat.name}`}
triggerLabel="删除"
/>
</div>
</article>
))}
@@ -223,12 +223,22 @@ describe('KnowledgeWorkspace', () => {
const workspace = screen.getByLabelText('知识工作区')
expect(workspace).toHaveClass('knowledge-workspace')
expect(workspace).toHaveStyle({
background: 'var(--surface-canvas)'
})
expect(workspace.querySelector('aside')).toHaveClass(
'knowledge-workspace__sidebar'
)
expect(workspace.querySelector('main')).toHaveClass(
'knowledge-workspace__main'
)
expect(workspace.querySelector('main')).toHaveStyle({
background: 'var(--surface-raised)'
})
expect(screen.getByText('全局')).toHaveClass('scope-badge')
expect(screen.getByRole('tablist', { name: '知识库视图' })).toHaveClass(
'page-tabs'
)
expect(screen.getByLabelText('搜索文档').closest('label')).toHaveClass(
'knowledge-documents__search'
)
@@ -331,6 +341,26 @@ describe('KnowledgeWorkspace', () => {
).toBeInTheDocument()
})
it('keeps loading distinct from the first-library empty state', () => {
render(
<KnowledgeWorkspace
{...createProps({
libraries: [],
loading: true,
selectedLibraryId: undefined
})}
/>
)
expect(screen.getByText('正在加载知识库')).toBeInTheDocument()
expect(
screen.queryByText('建立第一个知识库')
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: '新建知识库' })
).toBeDisabled()
})
it('confirms that deleting a managed library removes managed copies', async () => {
const onDeleteLibrary = vi.fn()
render(
+228 -194
View File
@@ -30,6 +30,12 @@ import {
useRef,
useState
} from 'react'
import {
EmptyState,
PageHeader,
PageTabs,
type PageTab
} from './WorkspacePrimitives'
export type KnowledgeStorageMode = 'reference' | 'managed'
export type KnowledgeGraphStrategy =
@@ -242,64 +248,51 @@ const styles = {
workspace: {
display: 'grid',
overflow: 'hidden',
border: '1px solid #d9d9d9',
borderRadius: 8,
background: '#f5f5f5',
color: '#1f1f1f',
boxShadow: '0 2px 8px rgba(0, 0, 0, .06)'
border: '1px solid var(--border-default)',
borderRadius: 'var(--radius-card)',
background: 'var(--surface-canvas)',
color: 'var(--text-primary)',
boxShadow: 'var(--shadow-card)'
},
sidebar: {
display: 'flex',
flexDirection: 'column' as const,
gap: 16,
background: '#fafafa'
background: 'var(--surface-subtle)'
},
surface: {
border: '1px solid #d9d9d9',
borderRadius: 8,
background: '#ffffff'
border: '1px solid var(--border-default)',
borderRadius: 'var(--radius-control)',
background: 'var(--surface-raised)'
},
button: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 7,
minHeight: 36,
padding: '8px 12px',
border: '1px solid #d9d9d9',
borderRadius: 6,
background: '#ffffff',
color: '#1f1f1f',
cursor: 'pointer',
font: 'inherit'
},
primaryButton: {
background: '#1677ff',
borderColor: '#1677ff',
color: '#ffffff',
fontWeight: 700
},
input: {
width: '100%',
boxSizing: 'border-box' as const,
minHeight: 40,
padding: '9px 11px',
border: '1px solid #d9d9d9',
borderRadius: 6,
border: '1px solid var(--border-control)',
borderRadius: 'var(--radius-control)',
outline: 'none',
background: '#ffffff',
color: '#1f1f1f',
background: 'var(--surface-raised)',
color: 'var(--text-primary)',
font: 'inherit'
},
label: {
display: 'grid',
gap: 7,
color: '#595959',
color: 'var(--text-secondary)',
fontSize: 13,
fontWeight: 650
},
muted: {
color: '#8c8c8c',
color: 'var(--text-muted)',
fontSize: 13,
lineHeight: 1.55
}
@@ -380,7 +373,7 @@ function ProgressBar({
height: 5,
overflow: 'hidden',
borderRadius: 999,
background: '#f0f0f0'
background: 'var(--surface-muted)'
}}
>
<span
@@ -388,7 +381,8 @@ function ProgressBar({
display: 'block',
width: `${value}%`,
height: '100%',
background: value === 100 ? '#52c41a' : '#1677ff',
background:
value === 100 ? 'var(--success)' : 'var(--accent)',
transition: 'width .2s ease'
}}
/>
@@ -452,7 +446,7 @@ function CreateLibraryWizard({
}}
>
<div>
<span style={{ color: '#1677ff', fontSize: 12, fontWeight: 800 }}>
<span style={{ color: 'var(--accent)', fontSize: 12, fontWeight: 800 }}>
NEW KNOWLEDGE BASE
</span>
<h2 style={{ margin: '5px 0 0', fontSize: 22 }}></h2>
@@ -565,17 +559,27 @@ function CreateLibraryWizard({
</label>
)}
{error && (
<p aria-live="polite" role="alert" style={{ color: '#ff4d4f', margin: 0 }}>
<p
aria-live="polite"
role="alert"
style={{ color: 'var(--danger)', margin: 0 }}
>
{error}
</p>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={onCancel} style={styles.button} type="button">
<button
className="secondary-button"
onClick={onCancel}
style={styles.button}
type="button"
>
</button>
<button
className="primary-button"
disabled={saving}
style={{ ...styles.button, ...styles.primaryButton }}
style={styles.button}
type="submit"
>
{saving ? (
@@ -627,7 +631,7 @@ function DeleteLibraryDialog({
display: 'grid',
placeItems: 'center',
padding: 20,
background: 'rgba(0, 0, 0, .45)'
background: 'var(--overlay-backdrop)'
}}
>
<div
@@ -635,10 +639,10 @@ function DeleteLibraryDialog({
...styles.surface,
width: 'min(440px, 100%)',
padding: 20,
boxShadow: '0 6px 16px rgba(0, 0, 0, .08)'
boxShadow: 'var(--shadow-dialog)'
}}
>
<AlertCircle color="#ff4d4f" aria-hidden="true" size={26} />
<AlertCircle color="var(--danger)" aria-hidden="true" size={26} />
<h2 style={{ margin: '12px 0 8px' }}>{library.name}</h2>
<p style={{ ...styles.muted, margin: 0 }}>
{library.storageMode === 'managed'
@@ -646,7 +650,11 @@ function DeleteLibraryDialog({
: '此知识库引用原文件。删除后只会移除索引和图谱,不会删除磁盘上的原文件。'}
</p>
{error && (
<p aria-live="polite" role="alert" style={{ color: '#ff4d4f' }}>
<p
aria-live="polite"
role="alert"
style={{ color: 'var(--danger)' }}
>
{error}
</p>
)}
@@ -658,18 +666,19 @@ function DeleteLibraryDialog({
marginTop: 18
}}
>
<button disabled={deleting} onClick={onCancel} style={styles.button}>
<button
className="secondary-button"
disabled={deleting}
onClick={onCancel}
style={styles.button}
>
</button>
<button
className="danger-button"
disabled={deleting}
onClick={() => void confirm()}
style={{
...styles.button,
background: '#ff4d4f',
borderColor: '#ff4d4f',
color: '#ffffff'
}}
style={styles.button}
>
<Trash2 aria-hidden="true" size={15} />
{deleting ? '删除中…' : '确认删除'}
@@ -771,6 +780,7 @@ function DocumentsView({
</div>
<div className="knowledge-documents__import-actions">
<button
className="secondary-button"
onClick={() => fileInputRef.current?.click()}
style={styles.button}
type="button"
@@ -779,6 +789,7 @@ function DocumentsView({
</button>
<button
className="secondary-button"
onClick={() =>
void run('directory', () =>
onImportDirectory(
@@ -795,6 +806,7 @@ function DocumentsView({
</button>
<button
className="secondary-button"
onClick={() => setUrlOpen((current) => !current)}
style={styles.button}
type="button"
@@ -895,14 +907,16 @@ function DocumentsView({
value={url}
/>
<button
className="primary-button"
disabled={pending === 'url'}
style={{ ...styles.button, ...styles.primaryButton }}
style={styles.button}
type="submit"
>
</button>
<button
aria-label="关闭 URL 导入"
className="secondary-button"
onClick={() => setUrlOpen(false)}
style={styles.button}
type="button"
@@ -931,11 +945,15 @@ function DocumentsView({
style={{
marginTop: 12,
padding: 18,
border: `1px dashed ${dragging ? '#1677ff' : '#d9d9d9'}`,
border: `1px dashed ${
dragging ? 'var(--accent)' : 'var(--border-default)'
}`,
borderRadius: 8,
textAlign: 'center',
background: dragging ? '#e6f4ff' : '#fafafa',
color: dragging ? '#1677ff' : '#8c8c8c'
background: dragging
? 'var(--accent-subtle)'
: 'var(--surface-subtle)',
color: dragging ? 'var(--accent)' : 'var(--text-muted)'
}}
>
<UploadCloud aria-hidden="true" size={22} />
@@ -945,7 +963,11 @@ function DocumentsView({
</div>
{error && (
<p aria-live="polite" role="alert" style={{ color: '#ff4d4f' }}>
<p
aria-live="polite"
role="alert"
style={{ color: 'var(--danger)' }}
>
{error}
</p>
)}
@@ -1011,16 +1033,16 @@ function DocumentsView({
borderRadius: 999,
background:
source.status === 'failed'
? '#fff2f0'
? 'var(--danger-subtle)'
: source.status === 'ready'
? '#f6ffed'
: '#e6f4ff',
? 'var(--success-subtle)'
: 'var(--accent-subtle)',
color:
source.status === 'failed'
? '#ff4d4f'
? 'var(--danger)'
: source.status === 'ready'
? '#52c41a'
: '#1677ff',
? 'var(--success)'
: 'var(--accent)',
fontSize: 12
}}
>
@@ -1040,7 +1062,13 @@ function DocumentsView({
</div>
)}
{source.error && (
<div style={{ color: '#ff4d4f', fontSize: 12, marginTop: 5 }}>
<div
style={{
color: 'var(--danger)',
fontSize: 12,
marginTop: 5
}}
>
{source.error}
</div>
)}
@@ -1049,6 +1077,7 @@ function DocumentsView({
{source.status === 'syncing' ? (
<button
aria-label={`暂停 ${source.name}`}
className="secondary-button"
disabled={pending === source.id}
onClick={() =>
void run(source.id, () => onPauseSource(source.id))
@@ -1062,6 +1091,7 @@ function DocumentsView({
) : source.status === 'failed' ? (
<button
aria-label={`重试 ${source.name}`}
className="secondary-button"
disabled={pending === source.id}
onClick={() =>
void run(source.id, () => onRetrySource(source.id))
@@ -1075,6 +1105,7 @@ function DocumentsView({
) : (
<button
aria-label={`同步 ${source.name}`}
className="secondary-button"
disabled={pending === source.id}
onClick={() =>
void run(source.id, () => onSyncSource(source.id))
@@ -1088,6 +1119,7 @@ function DocumentsView({
)}
<button
aria-label={`移除来源 ${source.name}`}
className="danger-button danger-button--quiet"
disabled={pending === source.id}
onClick={() =>
void run(source.id, () => onRemoveSource(source.id))
@@ -1122,7 +1154,11 @@ function DocumentsView({
<Search
aria-hidden="true"
size={15}
style={{ position: 'absolute', left: 11, color: '#8c8c8c' }}
style={{
position: 'absolute',
left: 11,
color: 'var(--text-muted)'
}}
/>
<span style={{ position: 'absolute', clip: 'rect(0 0 0 0)' }}>
@@ -1154,9 +1190,9 @@ function DocumentsView({
>
<thead
style={{
color: '#595959',
color: 'var(--text-secondary)',
textAlign: 'left',
background: '#fafafa'
background: 'var(--surface-subtle)'
}}
>
<tr>
@@ -1172,7 +1208,7 @@ function DocumentsView({
<tr
key={document.id}
style={{
borderTop: '1px solid #f0f0f0'
borderTop: '1px solid var(--border-subtle)'
}}
>
<td style={{ padding: 10 }}>
@@ -1196,18 +1232,20 @@ function DocumentsView({
style={{
color:
document.status === 'failed'
? '#ff4d4f'
? 'var(--danger)'
: document.status === 'ready'
? '#52c41a'
? 'var(--success)'
: document.status === 'indexing'
? '#1677ff'
: '#faad14'
? 'var(--accent)'
: 'var(--warning)'
}}
>
{documentStatusLabels[document.status]}
</span>
{document.error && (
<div style={{ color: '#ff4d4f', marginTop: 4 }}>
<div
style={{ color: 'var(--danger)', marginTop: 4 }}
>
{document.error}
</div>
)}
@@ -1304,10 +1342,15 @@ function EntityEditor({
/>
</label>
<div style={{ display: 'flex', gap: 8 }}>
<button style={{ ...styles.button, ...styles.primaryButton }}>
<button className="primary-button" style={styles.button}>
{node ? '保存实体' : '新增实体'}
</button>
<button onClick={onCancel} style={styles.button} type="button">
<button
className="secondary-button"
onClick={onCancel}
style={styles.button}
type="button"
>
</button>
</div>
@@ -1414,10 +1457,15 @@ function RelationForm({
/>
</label>
<div style={{ display: 'flex', gap: 7 }}>
<button style={{ ...styles.button, ...styles.primaryButton }}>
<button className="primary-button" style={styles.button}>
{relation ? '保存关系' : '新增关系'}
</button>
<button onClick={onCancel} style={styles.button} type="button">
<button
className="secondary-button"
onClick={onCancel}
style={styles.button}
type="button"
>
</button>
</div>
@@ -1586,6 +1634,7 @@ function GraphView({
))}
</select>
<button
className="secondary-button"
onClick={() => {
setSelectedNodeId(undefined)
setCreatingEntity(true)
@@ -1598,6 +1647,7 @@ function GraphView({
</button>
<button
aria-label="缩小图谱"
className="secondary-button"
disabled={zoom <= 0.5}
onClick={() =>
setZoom((current) => Math.max(0.5, current - 0.15))
@@ -1609,12 +1659,17 @@ function GraphView({
</button>
<span
aria-live="polite"
style={{ minWidth: 42, color: '#8c8c8c', fontSize: 12 }}
style={{
minWidth: 42,
color: 'var(--text-muted)',
fontSize: 12
}}
>
{Math.round(zoom * 100)}%
</span>
<button
aria-label="放大图谱"
className="secondary-button"
disabled={zoom >= 2}
onClick={() =>
setZoom((current) => Math.min(2, current + 0.15))
@@ -1631,7 +1686,7 @@ function GraphView({
display: 'grid',
placeItems: 'center',
padding: 30,
color: '#8c8c8c',
color: 'var(--text-muted)',
textAlign: 'center'
}}
>
@@ -1667,7 +1722,7 @@ function GraphView({
className="knowledge-graph__svg"
style={{
width: '100%',
background: '#fafafa',
background: 'var(--surface-subtle)',
touchAction: 'none'
}}
viewBox={`0 0 ${900 / zoom} ${560 / zoom}`}
@@ -1681,7 +1736,10 @@ function GraphView({
refX="17"
refY="3.5"
>
<polygon fill="#8c8c8c" points="0 0, 7 3.5, 0 7" />
<polygon
fill="var(--text-muted)"
points="0 0, 7 3.5, 0 7"
/>
</marker>
</defs>
{visibleRelations.map((relation) => {
@@ -1694,7 +1752,7 @@ function GraphView({
<g key={relation.id}>
<line
markerEnd="url(#knowledge-arrow)"
stroke="#8c8c8c"
stroke="var(--text-muted)"
strokeWidth="1.5"
x1={source.x}
x2={target.x}
@@ -1702,7 +1760,7 @@ function GraphView({
y2={target.y}
/>
<text
fill="#595959"
fill="var(--text-secondary)"
fontSize="11"
textAnchor="middle"
x={(source.x + target.x) / 2}
@@ -1751,13 +1809,19 @@ function GraphView({
}}
>
<circle
fill={selected ? '#bae0ff' : '#e6f4ff'}
fill={
selected
? 'var(--accent-selected)'
: 'var(--accent-subtle)'
}
r={selected ? 30 : 26}
stroke={selected ? '#1677ff' : '#4096ff'}
stroke={
selected ? 'var(--accent)' : 'var(--accent-hover)'
}
strokeWidth={selected ? 3 : 2}
/>
<text
fill="#1f1f1f"
fill="var(--text-primary)"
fontSize="12"
fontWeight="700"
textAnchor="middle"
@@ -1768,7 +1832,7 @@ function GraphView({
: node.label}
</text>
<text
fill="#595959"
fill="var(--text-secondary)"
fontSize="10"
textAnchor="middle"
y="44"
@@ -1822,13 +1886,14 @@ function GraphView({
}}
>
<div>
<span style={{ color: '#1677ff', fontSize: 12 }}>
<span style={{ color: 'var(--accent)', fontSize: 12 }}>
{selectedNode.type}
</span>
<h3 style={{ margin: '4px 0 0' }}>{selectedNode.label}</h3>
</div>
<button
aria-label="关闭实体详情"
className="secondary-button"
onClick={() => setSelectedNodeId(undefined)}
style={{ ...styles.button, padding: 7 }}
type="button"
@@ -1860,6 +1925,7 @@ function GraphView({
)}
<div style={{ display: 'flex', gap: 7 }}>
<button
className="secondary-button"
onClick={() => setEditingEntity(true)}
style={styles.button}
type="button"
@@ -1868,6 +1934,7 @@ function GraphView({
</button>
<button
className="danger-button danger-button--quiet"
onClick={() => void onDeleteEntity(selectedNode.id)}
style={styles.button}
type="button"
@@ -1883,7 +1950,7 @@ function GraphView({
style={{
margin: '16px 0',
border: 0,
borderTop: '1px solid #f0f0f0'
borderTop: '1px solid var(--border-subtle)'
}}
/>
<div
@@ -1895,6 +1962,7 @@ function GraphView({
>
<strong></strong>
<button
className="secondary-button"
onClick={() => setRelationForm('new')}
style={{ ...styles.button, padding: '6px 9px' }}
type="button"
@@ -1964,6 +2032,7 @@ function GraphView({
<div style={{ display: 'flex', gap: 6, marginTop: 7 }}>
<button
aria-label={`编辑关系 ${relation.type}`}
className="secondary-button"
onClick={() => setRelationForm(relation)}
style={{ ...styles.button, padding: 6 }}
type="button"
@@ -1972,6 +2041,7 @@ function GraphView({
</button>
<button
aria-label={`删除关系 ${relation.type}`}
className="danger-button danger-button--quiet"
onClick={() => void onDeleteRelation(relation.id)}
style={{ ...styles.button, padding: 6 }}
type="button"
@@ -1980,6 +2050,7 @@ function GraphView({
</button>
{other && (
<button
className="secondary-button"
onClick={() => setSelectedNodeId(other.id)}
style={{
...styles.button,
@@ -2018,6 +2089,7 @@ function GraphView({
</select>
<button
aria-label="合并到目标实体"
className="secondary-button"
disabled={!mergeTargetId}
onClick={() => {
void onMergeEntities(selectedNode.id, mergeTargetId)
@@ -2034,7 +2106,7 @@ function GraphView({
style={{
margin: '16px 0',
border: 0,
borderTop: '1px solid #f0f0f0'
borderTop: '1px solid var(--border-subtle)'
}}
/>
<strong> ({selectedEvidence.length})</strong>
@@ -2046,7 +2118,7 @@ function GraphView({
display: 'grid',
gap: 8,
paddingLeft: 20,
color: '#595959'
color: 'var(--text-secondary)'
}}
>
{selectedEvidence.map((item) => (
@@ -2170,6 +2242,22 @@ export function KnowledgeWorkspace({
}, [onSelectLibrary, selectedLibrary, selectedLibraryId])
const visibleTab =
selectedLibrary?.graphEnabled === false ? 'documents' : tab
const workspaceTabs: ReadonlyArray<PageTab<WorkspaceTab>> = [
{
id: 'documents',
label: '文档与来源',
icon: <FileText aria-hidden="true" size={15} />
},
...(selectedLibrary?.graphEnabled
? [
{
id: 'graph' as const,
label: '知识图谱',
icon: <Network aria-hidden="true" size={15} />
}
]
: [])
]
return (
<section
@@ -2179,34 +2267,20 @@ export function KnowledgeWorkspace({
style={styles.workspace}
>
<aside className="knowledge-workspace__sidebar" style={styles.sidebar}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10
}}
>
<span
style={{
display: 'grid',
width: 34,
height: 34,
placeItems: 'center',
borderRadius: 10,
background: '#1677ff',
color: '#ffffff'
}}
>
<Database aria-hidden="true" size={18} />
</span>
<div>
<strong style={{ display: 'block' }}></strong>
<span style={styles.muted}>{libraries.length} </span>
</div>
</div>
<PageHeader
compact
description={`${libraries.length} 个知识库 · 跨项目共享`}
eyebrow="KNOWLEDGE"
headingId="knowledge-workspace-title"
icon={<Database size={18} />}
scope={{ kind: 'global' }}
title="知识工作区"
/>
<button
className="primary-button"
disabled={loading}
onClick={() => setCreating(true)}
style={{ ...styles.button, ...styles.primaryButton, width: '100%' }}
style={{ ...styles.button, width: '100%' }}
type="button"
>
<Plus aria-hidden="true" size={16} />
@@ -2222,12 +2296,12 @@ export function KnowledgeWorkspace({
style={{
...styles.surface,
padding: 13,
color: '#8c8c8c',
color: 'var(--text-muted)',
fontSize: 13,
lineHeight: 1.55
}}
>
使
</div>
) : (
<ul
@@ -2254,14 +2328,16 @@ export function KnowledgeWorkspace({
padding: 11,
border: `1px solid ${
selected
? '#1677ff'
? 'var(--accent)'
: 'transparent'
}`,
borderRadius: 6,
borderRadius: 'var(--radius-control)',
background: selected
? '#e6f4ff'
? 'var(--accent-subtle)'
: 'transparent',
color: selected ? '#1677ff' : '#1f1f1f',
color: selected
? 'var(--accent)'
: 'var(--text-primary)',
textAlign: 'left',
cursor: 'pointer'
}}
@@ -2306,47 +2382,38 @@ export function KnowledgeWorkspace({
<main
className="knowledge-workspace__main"
style={{ minWidth: 0, background: '#ffffff' }}
style={{ minWidth: 0, background: 'var(--surface-raised)' }}
>
{creating ? (
{loading ? (
<EmptyState
description="正在读取知识库、来源和索引状态。"
icon={<LoaderCircle size={28} />}
level="page"
title="正在加载知识库"
/>
) : creating ? (
<CreateLibraryWizard
onCancel={() => setCreating(false)}
onCreate={onCreateLibrary}
/>
) : !selectedLibrary ? (
<div
style={{
display: 'grid',
minHeight: 620,
placeItems: 'center',
padding: 30,
textAlign: 'center'
}}
>
<div>
<BookOpen
aria-hidden="true"
color="#1677ff"
size={42}
/>
<h2></h2>
<p style={styles.muted}>
</p>
<EmptyState
action={
<button
className="primary-button"
onClick={() => setCreating(true)}
style={{
...styles.button,
...styles.primaryButton,
marginTop: 8
}}
style={styles.button}
type="button"
>
<Plus aria-hidden="true" size={16} />
</button>
</div>
</div>
}
description="集中组织文件、目录和网页来源,并生成可追溯、可跨项目使用的索引与图谱。"
icon={<BookOpen size={34} />}
level="page"
title="建立第一个知识库"
/>
) : (
<>
<header
@@ -2358,7 +2425,7 @@ export function KnowledgeWorkspace({
display: 'flex',
alignItems: 'center',
gap: 7,
color: '#1677ff',
color: 'var(--accent)',
fontSize: 12,
fontWeight: 750
}}
@@ -2383,7 +2450,7 @@ export function KnowledgeWorkspace({
style={{
display: 'flex',
alignItems: 'center',
color: '#595959',
color: 'var(--text-secondary)',
fontSize: 12,
gap: 6
}}
@@ -2424,6 +2491,7 @@ export function KnowledgeWorkspace({
)}
<button
aria-label={`删除知识库 ${selectedLibrary.name}`}
className="danger-button danger-button--quiet"
onClick={() => setDeletingLibrary(selectedLibrary)}
style={styles.button}
type="button"
@@ -2433,55 +2501,21 @@ export function KnowledgeWorkspace({
</button>
</div>
</header>
<div
aria-label="知识库视图"
className="knowledge-workspace__tabs"
role="tablist"
>
<button
aria-selected={visibleTab === 'documents'}
onClick={() => setTab('documents')}
role="tab"
style={{
...styles.button,
background:
visibleTab === 'documents'
? '#e6f4ff'
: 'transparent',
borderColor:
visibleTab === 'documents' ? '#1677ff' : 'transparent',
color:
visibleTab === 'documents' ? '#1677ff' : '#595959'
}}
type="button"
>
<FileText aria-hidden="true" size={15} />
</button>
{selectedLibrary.graphEnabled && (
<button
aria-selected={visibleTab === 'graph'}
onClick={() => setTab('graph')}
role="tab"
style={{
...styles.button,
background:
visibleTab === 'graph'
? '#e6f4ff'
: 'transparent',
borderColor:
visibleTab === 'graph' ? '#1677ff' : 'transparent',
color:
visibleTab === 'graph' ? '#1677ff' : '#595959'
}}
type="button"
>
<Network aria-hidden="true" size={15} />
</button>
)}
<div className="knowledge-workspace__tabs">
<PageTabs
ariaLabel="知识库视图"
idPrefix="knowledge"
onChange={setTab}
tabs={workspaceTabs}
value={visibleTab}
/>
</div>
<div className="knowledge-workspace__body">
<div
aria-labelledby={`knowledge-tab-${visibleTab}`}
className="knowledge-workspace__body"
id={`knowledge-panel-${visibleTab}`}
role="tabpanel"
>
{visibleTab === 'documents' ? (
<DocumentsView
documents={libraryDocuments}
@@ -0,0 +1,217 @@
/// <reference types="node" />
import {
cleanup,
fireEvent,
render,
screen
} from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
DestructiveConfirmActions,
EmptyState,
PageHeader,
PageShell,
PageTabs,
SegmentedControl
} from './WorkspacePrimitives'
const stylesheet = readFileSync(
join(process.cwd(), 'src', 'renderer', 'src', 'styles.css'),
'utf8'
)
function themeTokens(selector: string): Record<string, string> {
const selectorIndex = stylesheet.indexOf(selector)
const blockStart = stylesheet.indexOf('{', selectorIndex)
const blockEnd = stylesheet.indexOf('}', blockStart)
if (selectorIndex < 0 || blockStart < 0 || blockEnd < 0) {
throw new Error(`Missing token block ${selector}`)
}
const block = stylesheet.slice(blockStart + 1, blockEnd)
return Object.fromEntries(
[...block.matchAll(/--([\w-]+):\s*(#[\da-f]{3,6});/giu)].map(
([, name, value]) => [name, value]
)
)
}
function contrast(foreground: string, background: string): number {
const luminance = (hex: string): number => {
const compact = hex.slice(1)
const expanded =
compact.length === 3
? compact
.split('')
.map((value) => `${value}${value}`)
.join('')
: compact
const [red, green, blue] = expanded
.match(/../gu)!
.map((value) => Number.parseInt(value, 16) / 255)
.map((value) =>
value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4
)
return red! * 0.2126 + green! * 0.7152 + blue! * 0.0722
}
const first = luminance(foreground)
const second = luminance(background)
return (
(Math.max(first, second) + 0.05) /
(Math.min(first, second) + 0.05)
)
}
describe('WorkspacePrimitives', () => {
afterEach(() => {
cleanup()
})
it('renders a consistent page shell and scoped header', () => {
render(
<PageShell variant="dashboard">
<PageHeader
description="跨项目记录"
eyebrow="AUDIT"
headingId="page-title"
scope={{ kind: 'all-projects' }}
title="任务与活动"
/>
</PageShell>
)
expect(
screen.getByRole('heading', { level: 1, name: '任务与活动' })
).toBeInTheDocument()
expect(screen.getByText('全部项目')).toHaveClass('scope-badge')
expect(screen.getByText('全部项目').closest('.page-shell')).toHaveClass(
'page-shell--dashboard'
)
})
it('supports arrow-key tab and segmented-control selection', () => {
const onTabChange = vi.fn()
const onSegmentChange = vi.fn()
render(
<>
<PageTabs
ariaLabel="视图"
idPrefix="example"
onChange={onTabChange}
tabs={[
{ id: 'first', label: '第一个' },
{ id: 'second', label: '第二个' }
]}
value="first"
/>
<SegmentedControl
ariaLabel="筛选"
onChange={onSegmentChange}
options={[
{ value: 'all', label: '全部' },
{ value: 'failed', label: '失败' }
]}
value="all"
/>
</>
)
fireEvent.keyDown(screen.getByRole('tab', { name: '第一个' }), {
key: 'ArrowRight'
})
expect(onTabChange).toHaveBeenCalledWith('second')
fireEvent.keyDown(screen.getByRole('button', { name: '全部' }), {
key: 'End'
})
expect(onSegmentChange).toHaveBeenCalledWith('failed')
})
it('distinguishes page and section empty states', () => {
const { rerender } = render(
<EmptyState
description="创建内容后会显示在这里。"
level="page"
title="尚无内容"
/>
)
expect(screen.getByText('尚无内容').parentElement).toHaveClass(
'empty-state--page'
)
rerender(
<EmptyState
description="没有符合当前筛选的内容。"
level="section"
title="没有匹配结果"
/>
)
expect(screen.getByText('没有匹配结果').parentElement).toHaveClass(
'empty-state--section'
)
})
it('uses the shared destructive confirmation flow', () => {
const onRequestConfirm = vi.fn()
const onConfirm = vi.fn()
const onCancel = vi.fn()
const { rerender } = render(
<DestructiveConfirmActions
confirmLabel="确认删除"
confirming={false}
onCancel={onCancel}
onConfirm={onConfirm}
onRequestConfirm={onRequestConfirm}
triggerLabel="删除"
/>
)
fireEvent.click(screen.getByRole('button', { name: '删除' }))
expect(onRequestConfirm).toHaveBeenCalledOnce()
rerender(
<DestructiveConfirmActions
confirmLabel="确认删除"
confirming
message="删除此对象?"
onCancel={onCancel}
onConfirm={onConfirm}
onRequestConfirm={onRequestConfirm}
triggerLabel="删除"
/>
)
expect(screen.getByRole('button', { name: '取消' })).toHaveFocus()
fireEvent.click(screen.getByRole('button', { name: '确认删除' }))
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(onConfirm).toHaveBeenCalledOnce()
expect(onCancel).toHaveBeenCalledOnce()
})
it.each([
[':root', 'light'],
[":root[data-theme='dark']", 'dark']
])('keeps shared %s semantic tokens contrast-safe', (selector) => {
const tokens = themeTokens(selector)
const textPairs = [
['text-muted', 'surface-raised'],
['accent', 'accent-subtle'],
['success', 'success-subtle'],
['danger', 'danger-subtle'],
['text-on-accent', 'accent-solid'],
['text-on-accent', 'danger-solid']
] as const
for (const [foreground, background] of textPairs) {
expect(
contrast(tokens[foreground]!, tokens[background]!),
`${foreground} on ${background}`
).toBeGreaterThanOrEqual(4.5)
}
expect(
contrast(tokens['border-control']!, tokens['surface-raised']!)
).toBeGreaterThanOrEqual(3)
})
})
+380
View File
@@ -0,0 +1,380 @@
import {
FolderKanban,
Globe2,
Layers3,
TriangleAlert
} from 'lucide-react'
import {
useEffect,
useRef,
type KeyboardEvent,
type ReactNode
} from 'react'
export type WorkspaceScope =
| { kind: 'global' }
| { kind: 'all-projects' }
| { kind: 'project'; projectName: string }
| { kind: 'mixed'; projectName?: string }
| { kind: 'unavailable'; explanation: string }
export type PageTab<T extends string> = {
id: T
label: string
count?: number
icon?: ReactNode
}
export type SegmentedOption<T extends string> = {
value: T
label: string
}
function nextControlIndex(
event: KeyboardEvent<HTMLButtonElement>,
currentIndex: number,
itemCount: number
): number | undefined {
if (event.key === 'Home') {
return 0
}
if (event.key === 'End') {
return itemCount - 1
}
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
return (currentIndex + 1) % itemCount
}
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
return (currentIndex - 1 + itemCount) % itemCount
}
return undefined
}
export function PageShell({
children,
variant
}: {
children: ReactNode
variant: 'reading' | 'standard' | 'dashboard' | 'master-detail'
}): React.JSX.Element {
return (
<div
className={`workspace-panel-scroll page-shell page-shell--${variant}`}
>
{children}
</div>
)
}
export function ScopeBadge({
scope
}: {
scope: WorkspaceScope
}): React.JSX.Element {
const content =
scope.kind === 'global'
? { icon: <Globe2 size={12} />, label: '全局' }
: scope.kind === 'all-projects'
? { icon: <Layers3 size={12} />, label: '全部项目' }
: scope.kind === 'project'
? {
icon: <FolderKanban size={12} />,
label: `项目:${scope.projectName}`
}
: scope.kind === 'mixed'
? {
icon: <Layers3 size={12} />,
label: scope.projectName
? `项目:${scope.projectName} + 全局`
: '当前项目 + 全局'
}
: {
icon: <TriangleAlert size={12} />,
label: '范围不可用'
}
return (
<span
className="scope-badge"
title={scope.kind === 'unavailable' ? scope.explanation : undefined}
>
<span aria-hidden="true">{content.icon}</span>
{content.label}
</span>
)
}
export function PageHeader({
actions,
compact = false,
description,
eyebrow,
headingId,
icon,
scope,
title
}: {
actions?: ReactNode
compact?: boolean
description?: ReactNode
eyebrow?: string
headingId: string
icon?: ReactNode
scope?: WorkspaceScope
title: string
}): React.JSX.Element {
return (
<header
className={
compact ? 'page-header page-header--compact' : 'page-header'
}
>
<div className="page-header__content">
{eyebrow && <p className="eyebrow">{eyebrow}</p>}
<div className="page-header__title-row">
{icon && (
<span aria-hidden="true" className="page-header__icon">
{icon}
</span>
)}
<h1 id={headingId}>{title}</h1>
{scope && <ScopeBadge scope={scope} />}
</div>
{description && (
<p className="page-header__description">{description}</p>
)}
</div>
{actions && <div className="page-header__actions">{actions}</div>}
</header>
)
}
export function PageTabs<T extends string>({
ariaLabel,
idPrefix,
onChange,
tabs,
value
}: {
ariaLabel: string
idPrefix: string
onChange: (value: T) => void
tabs: readonly PageTab<T>[]
value: T
}): React.JSX.Element {
return (
<nav aria-label={ariaLabel} className="page-tabs" role="tablist">
{tabs.map((tab, index) => (
<button
aria-controls={`${idPrefix}-panel-${tab.id}`}
aria-selected={value === tab.id}
className={
value === tab.id
? 'page-tabs__tab page-tabs__tab--active'
: 'page-tabs__tab'
}
id={`${idPrefix}-tab-${tab.id}`}
key={tab.id}
onClick={() => onChange(tab.id)}
onKeyDown={(event) => {
const nextIndex = nextControlIndex(
event,
index,
tabs.length
)
if (nextIndex === undefined) {
return
}
event.preventDefault()
onChange(tabs[nextIndex]!.id)
const controls =
event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>(
'[role="tab"]'
)
controls?.[nextIndex]?.focus()
}}
role="tab"
tabIndex={value === tab.id ? 0 : -1}
type="button"
>
{tab.icon && (
<span aria-hidden="true" className="page-tabs__icon">
{tab.icon}
</span>
)}
{tab.label}
{tab.count ? (
<span className="page-tabs__count">{tab.count}</span>
) : null}
</button>
))}
</nav>
)
}
export function SegmentedControl<T extends string>({
ariaLabel,
onChange,
options,
value
}: {
ariaLabel: string
onChange: (value: T) => void
options: readonly SegmentedOption<T>[]
value: T
}): React.JSX.Element {
return (
<div
aria-label={ariaLabel}
className="segmented-control"
role="group"
>
{options.map((option, index) => (
<button
aria-pressed={value === option.value}
className={
value === option.value
? 'segmented-control__option segmented-control__option--active'
: 'segmented-control__option'
}
key={option.value}
onClick={() => onChange(option.value)}
onKeyDown={(event) => {
const nextIndex = nextControlIndex(
event,
index,
options.length
)
if (nextIndex === undefined) {
return
}
event.preventDefault()
onChange(options[nextIndex]!.value)
const controls =
event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>(
'button'
)
controls?.[nextIndex]?.focus()
}}
type="button"
>
{option.label}
</button>
))}
</div>
)
}
export function EmptyState({
action,
description,
icon,
level = 'section',
title
}: {
action?: ReactNode
description: ReactNode
icon?: ReactNode
level?: 'page' | 'section' | 'table'
title?: string
}): React.JSX.Element {
return (
<div className={`empty-state empty-state--${level}`}>
{icon && (
<span aria-hidden="true" className="empty-state__icon">
{icon}
</span>
)}
{title && <strong>{title}</strong>}
<p>{description}</p>
{action && <div className="empty-state__action">{action}</div>}
</div>
)
}
export function DestructiveConfirmActions({
cancelAriaLabel,
confirmAriaLabel,
confirmLabel,
confirming,
disabled = false,
icon,
message,
onCancel,
onConfirm,
onRequestConfirm,
triggerAriaLabel,
triggerLabel
}: {
cancelAriaLabel?: string
confirmAriaLabel?: string
confirmLabel: string
confirming: boolean
disabled?: boolean
icon?: ReactNode
message?: string
onCancel: () => void
onConfirm: () => void
onRequestConfirm: () => void
triggerAriaLabel?: string
triggerLabel: string
}): React.JSX.Element {
const cancelRef = useRef<HTMLButtonElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const wasConfirming = useRef(confirming)
useEffect(() => {
if (confirming && !wasConfirming.current) {
cancelRef.current?.focus()
} else if (
!confirming &&
wasConfirming.current &&
!triggerRef.current?.disabled
) {
triggerRef.current?.focus()
}
wasConfirming.current = confirming
}, [confirming])
return confirming ? (
<div
aria-label={confirmAriaLabel}
aria-live="assertive"
className="danger-confirm"
role="alertdialog"
>
{message && <span>{message}</span>}
<button
aria-label={cancelAriaLabel}
className="secondary-button"
disabled={disabled}
onClick={onCancel}
ref={cancelRef}
type="button"
>
</button>
<button
aria-label={confirmAriaLabel}
className="danger-button"
disabled={disabled}
onClick={onConfirm}
type="button"
>
{confirmLabel}
</button>
</div>
) : (
<button
aria-label={triggerAriaLabel}
className="danger-button danger-button--quiet"
disabled={disabled}
onClick={onRequestConfirm}
ref={triggerRef}
type="button"
>
{icon}
{triggerLabel}
</button>
)
}
File diff suppressed because it is too large Load Diff