feat(ui): 浅蓝主题、外观设置与圆桌看板主题适配,优化工作区布局
新增 lightblue 主题与外观设置联动,圆桌 Step3 看板/report 统一配色;工作区侧边栏、输入框与轻应用 iframe 主题参数同步更新。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
30d4f9667b
commit
c702119e66
39 files changed
+2562
-1441
No files matched your search
@@ -0,0 +1,375 @@
|
||||
# 浅蓝色 UI 规范
|
||||
|
||||
> 本文档由设计稿截图(`1.1 全局样式-色彩`、`1.2 全局样式-色彩附录`)识别整理而成。
|
||||
> 全部色值已与"色彩附录"编号表逐一核对,保证准确。
|
||||
|
||||
---
|
||||
|
||||
## 1.1 色彩体系总览
|
||||
|
||||
调色体系分为 **主色** 与 **辅色** 两大类。每个色相按用途分为若干阶梯:
|
||||
|
||||
| 阶梯 | 数量 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 浅色 | 3 | 用于大面积底色 |
|
||||
| 中亮色 | 6 | 用于标签、图表、提示等 |
|
||||
| 深色 | 4(+基准色,共 5 个深阶) | 用于小面积按钮、图标等 |
|
||||
| 线性渐变色 | 1 | 用于特殊装饰性小面积底色 |
|
||||
| 径向渐变色 | 1 | 用于特殊装饰性小面积底色 |
|
||||
|
||||
> 中性色(灰阶)略有不同:浅色用于背景 / 白色文字及分割线,中亮色用于备注文字及图标,深色用于正文,且**无渐变色**。
|
||||
|
||||
---
|
||||
|
||||
## 主色
|
||||
|
||||
### 主色 - 红(基准 `#CF1322`)
|
||||
|
||||
| 用途 | 色值 |
|
||||
| --- | --- |
|
||||
| 浅色 | `#FEFAFA` · `#FEF6F6` · `#FEEDED` |
|
||||
| 中亮色 | `#FDDADC` · `#FCB5B9` · `#FA9196` · `#F86C73` · `#F74750` · `#F5222D` |
|
||||
| 深色 | `#CF1322` · `#BC0D1E` · `#A8071A` · `#820014` · `#5C0011` |
|
||||
| 线性渐变色 | `#F5222D` → `#A8071A` |
|
||||
| 径向渐变色 | `#F86C73` → `#F5222D` |
|
||||
|
||||
### 主色 - 蓝(基准 `#0062D9`)
|
||||
|
||||
| 用途 | 色值 |
|
||||
| --- | --- |
|
||||
| 浅色 | `#F9FCFF` · `#F4F9FF` · `#EAF4FF` |
|
||||
| 中亮色 | `#D4E9FF` · `#AAD2FF` · `#80BCFF` · `#55A6FE` · `#2B8FFE` · `#0079FE` |
|
||||
| 深色 | `#0062D9` · `#0056C6` · `#004AB3` · `#00368C` · `#002466` |
|
||||
| 线性渐变色 | `#0079FE` → `#004AB3` |
|
||||
| 径向渐变色 | `#55A6FE` → `#0079FE` |
|
||||
|
||||
---
|
||||
|
||||
## 辅色
|
||||
|
||||
### 辅色 - 橙(基准 `#D97B00`)
|
||||
|
||||
| 用途 | 色值 |
|
||||
| --- | --- |
|
||||
| 浅色 | `#FFFCF9` · `#FFFAF4` · `#FFF6EA` |
|
||||
| 中亮色 | `#FFEED4` · `#FFDDAA` · `#FFCC80` · `#FFBB55` · `#FFAA2B` · `#FF9900` |
|
||||
| 深色 | `#D97B00` · `#C66D00` · `#B35F00` · `#8C4600` · `#663000` |
|
||||
| 线性渐变色 | `#FF9900` → `#B35F00` |
|
||||
| 径向渐变色 | `#FFBB55` → `#FF9900` |
|
||||
|
||||
### 辅色 - 绿(基准 `#1EB07F`)
|
||||
|
||||
| 用途 | 色值 |
|
||||
| --- | --- |
|
||||
| 浅色 | `#FAFEFD` · `#F6FDFB` · `#EEFCF7` |
|
||||
| 中亮色 | `#DCF8EE` · `#BAF1DD` · `#97EBCD` · `#74E4BC` · `#52DDAB` · `#2FD69A` |
|
||||
| 深色 | `#1EB07F` · `#189D72` · `#118A65` · `#07634B` · `#043D30` |
|
||||
| 线性渐变色 | `#2FD69A` → `#118A65` |
|
||||
| 径向渐变色 | `#74E4BC` → `#2FD69A` |
|
||||
|
||||
### 辅色 - 青蓝(基准 `#2F9ACC`)
|
||||
|
||||
| 用途 | 色值 |
|
||||
| --- | --- |
|
||||
| 浅色 | `#FBFDFE` · `#F7FCFE` · `#EFFAFE` |
|
||||
| 中亮色 | `#E0F5FD` · `#C1EBFB` · `#A2E1F9` · `#83D7F7` · `#64CDF5` · `#45C3F3` |
|
||||
| 深色 | `#2F9ACC` · `#2788B9` · `#1E76A6` · `#115580` · `#0B3959` |
|
||||
| 线性渐变色 | `#45C3F3` → `#1E76A6` |
|
||||
| 径向渐变色 | `#83D7F7` → `#45C3F3` |
|
||||
|
||||
### 中性色 - 灰(基准 `#222222`)
|
||||
|
||||
| 用途 | 色值 |
|
||||
| --- | --- |
|
||||
| 浅色(背景、白色文字及分割线) | `#FFFFFF` · `#FAFAFA` · `#F5F5F5` |
|
||||
| 中亮色(备注文字及图标) | `#E8E8E8` · `#DBDBDB` · `#C1C1C1` · `#A6A6A6` · `#7F7F7F` · `#575757` |
|
||||
| 深色(正文) | `#3D3D3D` · `#222222` |
|
||||
|
||||
---
|
||||
|
||||
## 1.2 色彩附录(编号对照表)
|
||||
|
||||
编号规则:`ZS` = 主色(主色调),`FS` = 辅色 / 中性色。`-01~-03` 为浅色,`-04~-09` 为中亮色,`-10~-13` 为深色,`-14` 为线性渐变色,`-15` 为径向渐变色;不带后缀的为该色相基准深色。
|
||||
|
||||
### 主色 ZS
|
||||
|
||||
| 编号 | 红 (ZS-001) | 编号 | 蓝 (ZS-002) |
|
||||
| --- | --- | --- | --- |
|
||||
| ZS-001 | `#CF1322` | ZS-002 | `#0062D9` |
|
||||
| ZS-001-01 | `#FEFAFA` | ZS-002-01 | `#F9FCFF` |
|
||||
| ZS-001-02 | `#FEF6F6` | ZS-002-02 | `#F4F9FF` |
|
||||
| ZS-001-03 | `#FEEDED` | ZS-002-03 | `#EAF4FF` |
|
||||
| ZS-001-04 | `#FDDADC` | ZS-002-04 | `#D4E9FF` |
|
||||
| ZS-001-05 | `#FCB5B9` | ZS-002-05 | `#AAD2FF` |
|
||||
| ZS-001-06 | `#FA9196` | ZS-002-06 | `#80BCFF` |
|
||||
| ZS-001-07 | `#F86C73` | ZS-002-07 | `#55A6FE` |
|
||||
| ZS-001-08 | `#F74750` | ZS-002-08 | `#2B8FFE` |
|
||||
| ZS-001-09 | `#F5222D` | ZS-002-09 | `#0079FE` |
|
||||
| ZS-001-10 | `#BC0D1E` | ZS-002-10 | `#0056C6` |
|
||||
| ZS-001-11 | `#A8071A` | ZS-002-11 | `#004AB3` |
|
||||
| ZS-001-12 | `#820014` | ZS-002-12 | `#00368C` |
|
||||
| ZS-001-13 | `#5C0011` | ZS-002-13 | `#002466` |
|
||||
| ZS-001-14 | `#F5222D` → `#A8071A` | ZS-002-14 | `#0079FE` → `#004AB3` |
|
||||
| ZS-001-15 | `#F86C73` → `#F5222D` | ZS-002-15 | `#55A6FE` → `#0079FE` |
|
||||
|
||||
### 辅色 / 中性色 FS
|
||||
|
||||
| 编号 | 橙 (FS-001) | 绿 (FS-002) | 青蓝 (FS-003) | 中性灰 (FS-004) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| FS-00n | `#D97B00` | `#1EB07F` | `#2F9ACC` | `#222222` |
|
||||
| -01 | `#FFFCF9` | `#FAFEFD` | `#FBFDFE` | `#FFFFFF` |
|
||||
| -02 | `#FFFAF4` | `#F6FDFB` | `#F7FCFE` | `#FAFAFA` |
|
||||
| -03 | `#FFF6EA` | `#EEFCF7` | `#EFFAFE` | `#F5F5F5` |
|
||||
| -04 | `#FFEED4` | `#DCF8EE` | `#E0F5FD` | `#E8E8E8` |
|
||||
| -05 | `#FFDDAA` | `#BAF1DD` | `#C1EBFB` | `#DBDBDB` |
|
||||
| -06 | `#FFCC80` | `#97EBCD` | `#A2E1F9` | `#C1C1C1` |
|
||||
| -07 | `#FFBB55` | `#74E4BC` | `#83D7F7` | `#A6A6A6` |
|
||||
| -08 | `#FFAA2B` | `#52DDAB` | `#64CDF5` | `#7F7F7F` |
|
||||
| -09 | `#FF9900` | `#2FD69A` | `#45C3F3` | `#575757` |
|
||||
| -10 | `#C66D00` | `#189D72` | `#2788B9` | `#3D3D3D` |
|
||||
| -11 | `#B35F00` | `#118A65` | `#1E76A6` | — |
|
||||
| -12 | `#8C4600` | `#07634B` | `#115580` | — |
|
||||
| -13 | `#663000` | `#043D30` | `#0B3959` | — |
|
||||
| -14 | `#FF9900` → `#B35F00` | `#2FD69A` → `#118A65` | `#45C3F3` → `#1E76A6` | — |
|
||||
| -15 | `#FFBB55` → `#FF9900` | `#74E4BC` → `#2FD69A` | `#83D7F7` → `#45C3F3` | — |
|
||||
|
||||
> 注:中性灰(FS-004)无渐变阶,最深至 `#222222`(基准)/`#3D3D3D`(FS-004-10),用于正文文字。
|
||||
|
||||
---
|
||||
|
||||
# 组件规范
|
||||
|
||||
> 以下为各组件的视觉规范与实现方式。颜色统一引用上文色值,建议在项目中先抽取为 CSS 变量 / Tailwind token,组件只引用语义化变量。
|
||||
|
||||
## 语义化 Token 建议
|
||||
|
||||
把色彩体系映射成语义变量,组件层只用语义名,便于换肤与维护:
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* 品牌 / 主色(蓝) */
|
||||
--color-primary: #0062D9; /* 主按钮、实底选中:ZS-002 深色基准 */
|
||||
--color-primary-hover: #0079FE; /* 悬浮态:ZS-002-09 */
|
||||
--color-primary-active: #0056C6; /* 按下态:ZS-002-10 */
|
||||
--color-primary-bg: #EAF4FF; /* 浅底选中底色:ZS-002-03 */
|
||||
--color-primary-border: #0079FE; /* 描边选中边框 */
|
||||
|
||||
/* 中性 */
|
||||
--color-text: #3D3D3D; /* 正文:FS-004-10 */
|
||||
--color-text-secondary: #A6A6A6; /* 次要 / 占位符:FS-004-07 */
|
||||
--color-border: #DBDBDB; /* 默认边框:FS-004-05 */
|
||||
--color-bg-disabled: #F5F5F5; /* 失效底色:FS-004-03 */
|
||||
--color-fill-disabled: #A6A6A6; /* 主按钮失效底色:FS-004-07 */
|
||||
--color-surface: #FFFFFF; /* 卡片 / 控件底 */
|
||||
}
|
||||
```
|
||||
|
||||
> 项目使用 Tailwind v4 + shadcn,可在 `@theme` 中声明这些变量后用 `bg-primary` / `text-primary` 等使用;下文示例同时给出等价的 Tailwind 写法。
|
||||
|
||||
---
|
||||
|
||||
## 2.1 基础 - 按钮(Button)
|
||||
|
||||
### 按钮尺寸
|
||||
|
||||
| 规格 | 高度 | 圆角 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| 小 | **28px** | 4px | 空间小的板块内部,如表格中、卡片标题等 |
|
||||
| 正常 | **32px** | 4px | 常用按钮(默认尺寸) |
|
||||
|
||||
水平内边距约 12–16px,字号 14px,图标与文字间距 4–8px。
|
||||
|
||||
### 常用按钮类型
|
||||
|
||||
| 类型 | 视觉 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 主按钮 | 实底 `--color-primary`,白字 | 一个操作区域**只能有一个**主按钮 |
|
||||
| 主按钮·失效 | 实底灰 `--color-fill-disabled`,白字 | 主按钮禁用态 |
|
||||
| 次要按钮 | 白底 + `--color-primary` 边框 + 蓝字 | 重要度低于主按钮,高于默认按钮 |
|
||||
| 默认按钮 | 白底 + `--color-border` 边框 + `--color-text` | 默认动作(取消等) |
|
||||
| 默认·失效 | `--color-bg-disabled` 底 + 浅边框 + `--color-text-secondary` | 次要/默认按钮禁用态 |
|
||||
| 文本按钮 | 无边框无底,蓝字(可加下划线) | 表格操作、文件下载等 |
|
||||
| 图标按钮 | 仅图标,可选 描边/实底/浅底 × 方形/圆形 | 增强辨识度或节省空间 |
|
||||
|
||||
实现(Tailwind 示意):
|
||||
|
||||
```tsx
|
||||
// 主按钮
|
||||
<button className="h-8 px-4 rounded text-sm text-white bg-[#0062D9]
|
||||
hover:bg-[#0079FE] active:bg-[#0056C6]
|
||||
disabled:bg-[#A6A6A6] disabled:cursor-not-allowed">确定</button>
|
||||
|
||||
// 次要按钮(次按钮)
|
||||
<button className="h-8 px-4 rounded text-sm text-[#0062D9] bg-white
|
||||
border border-[#0062D9] hover:bg-[#EAF4FF]">点击</button>
|
||||
|
||||
// 默认按钮
|
||||
<button className="h-8 px-4 rounded text-sm text-[#3D3D3D] bg-white
|
||||
border border-[#DBDBDB] hover:border-[#0079FE] hover:text-[#0079FE]
|
||||
disabled:bg-[#F5F5F5] disabled:text-[#A6A6A6] disabled:border-[#DBDBDB]">取消</button>
|
||||
|
||||
// 文本按钮
|
||||
<button className="text-sm text-[#0062D9] hover:underline">文本按钮样式</button>
|
||||
|
||||
// 图标按钮(实底圆形)
|
||||
<button className="size-7 grid place-items-center rounded-full text-white bg-[#0062D9]
|
||||
hover:bg-[#0079FE]"><TrashIcon className="size-4" /></button>
|
||||
```
|
||||
|
||||
图标按钮三种底 × 两种形状:
|
||||
- **描边**:白底 + `--color-border` 边框,图标用 `--color-text`
|
||||
- **实底**:`--color-primary` 底 + 白图标
|
||||
- **浅底**:`--color-primary-bg`(`#EAF4FF`)底 + 蓝图标
|
||||
- 形状:方形 `rounded`(4px)/ 圆形 `rounded-full`
|
||||
|
||||
### 按钮组合
|
||||
|
||||
| 组合 | 说明 |
|
||||
| --- | --- |
|
||||
| 文字 + 下拉按钮 | 按钮右侧带 `⌄` 箭头;有 默认 / 主按钮 两种变体 |
|
||||
| 图标 + 文字按钮 | `+ 正常`,前置图标;有 描边 / 实底 两种变体 |
|
||||
| 文本按钮 + 图标 | 蓝色文本链按钮:`更多 »` `展开 ⌄` `下一页 »|` `收起 ⌃` `|⌜ 上一页` |
|
||||
| 主按钮 + 默认按钮 | `确定`(主) `取消`(默认) `返回`(默认) 并排,间距 8–12px |
|
||||
|
||||
### 其他状态
|
||||
|
||||
- **悬浮状态(hover)**:主按钮悬浮时底色变亮(`--color-primary-hover` `#0079FE`),用于 Banner / 登录 / 视觉冲击强的场景。
|
||||
- **加载中(loading)**:按钮内前置旋转图标 `⟳ 加载中`,期间 `disabled` 并降低交互;用于异步操作等待反馈。
|
||||
|
||||
```tsx
|
||||
<button className="h-8 px-4 rounded text-sm text-white bg-[#0062D9] inline-flex items-center gap-1.5" disabled>
|
||||
<Spinner className="size-4 animate-spin" /> 加载中
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5.9 输入 - 单选框(选择按钮 / Segmented Radio)
|
||||
|
||||
一组互斥选项,外观为按钮组。两种排布 × 三种选中样式。
|
||||
|
||||
### 排布方式
|
||||
|
||||
| 排布 | 说明 |
|
||||
| --- | --- |
|
||||
| 拼接式 | 选项首尾相连,仅最外侧两角圆角,相邻共用 1px 边框 |
|
||||
| 分离式 | 选项之间留间距(约 8px),每个独立 4px 圆角,各自带完整边框 |
|
||||
|
||||
### 选中(已选)样式 × 三种
|
||||
|
||||
| 样式 | 已选 | 未选 |
|
||||
| --- | --- | --- |
|
||||
| 描边式 | 白底 + 蓝边框 `--color-primary` + 蓝字 | 白底 + `--color-border` 边框 + `--color-text` |
|
||||
| 浅底式 | `--color-primary-bg`(`#EAF4FF`) 底 + 蓝字 + 蓝边框 | 同上 |
|
||||
| 实底式 | `--color-primary` 实底 + 白字 | 同上 |
|
||||
|
||||
- **带统计**:选项文字后追加计数,如 `已选(230)` / `未选(230)`,计数用次要色 `--color-text-secondary`。
|
||||
|
||||
实现(拼接式 + 描边选中):
|
||||
|
||||
```tsx
|
||||
<div className="inline-flex rounded overflow-hidden border border-[#DBDBDB] divide-x divide-[#DBDBDB]">
|
||||
{options.map((o) => (
|
||||
<button key={o.value}
|
||||
className={cn(
|
||||
"h-8 px-4 text-sm bg-white",
|
||||
value === o.value
|
||||
? "text-[#0062D9] ring-1 ring-inset ring-[#0079FE] z-10" // 描边选中
|
||||
: "text-[#3D3D3D] hover:text-[#0079FE]"
|
||||
)}>
|
||||
{o.label}{o.count != null && <span className="text-[#A6A6A6]">({o.count})</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
> 浅底式把选中态换成 `bg-[#EAF4FF] text-[#0062D9]`;实底式换成 `bg-[#0062D9] text-white`。
|
||||
> 分离式去掉 `divide-x` / `overflow-hidden`,外层用 `gap-2`,每个按钮单独 `rounded border`。
|
||||
|
||||
### 按钮式页签(Tab as Button)
|
||||
|
||||
图标 + 文字的页签型按钮:
|
||||
- **激活** 两种:描边(白底 + 蓝边框 + 蓝字)/ 实底(`--color-primary` 底 + 白字)
|
||||
- **正常**:白底 + `--color-border` 边框 + `--color-text`,前置图标
|
||||
- 结构与拼接式选择按钮一致,区别在每项前置一个功能图标。
|
||||
|
||||
---
|
||||
|
||||
## 5.11 输入 - 选择器(Select / Dropdown)
|
||||
|
||||
单项输入选择,下拉单选。
|
||||
|
||||
### 状态
|
||||
|
||||
| 状态 | 触发框 |
|
||||
| --- | --- |
|
||||
| 占位 | `请选择` 占位文字用 `--color-text-secondary`,灰边框 `--color-border`,右侧 `⌄` |
|
||||
| 展开/聚焦 | 边框变 `--color-primary`,箭头翻转为 `⌃` |
|
||||
| 已选 | 显示选中项文字(正文色),保持聚焦时蓝边框 |
|
||||
|
||||
### 下拉面板
|
||||
|
||||
- 白底、圆角 4px、投影(如 `shadow-lg`),最大高度后出现滚动条
|
||||
- 选项默认 `--color-text`;**hover / 选中** 项:`--color-primary-bg`(`#EAF4FF`) 底 + `--color-primary` 文字
|
||||
- **分组**:分组标题用小号灰字 `--color-text-secondary`,组之间用 1px 分割线 `--color-border`
|
||||
|
||||
实现示意:
|
||||
|
||||
```tsx
|
||||
// 触发框
|
||||
<button className="h-8 w-full px-3 inline-flex items-center justify-between rounded
|
||||
border border-[#DBDBDB] bg-white text-sm
|
||||
data-[state=open]:border-[#0062D9]">
|
||||
<span className={selected ? "text-[#3D3D3D]" : "text-[#A6A6A6]"}>{selected ?? "请选择"}</span>
|
||||
<ChevronDown className="size-4 text-[#A6A6A6] transition-transform data-[state=open]:rotate-180" />
|
||||
</button>
|
||||
|
||||
// 下拉项
|
||||
<li className={cn(
|
||||
"h-9 px-3 flex items-center text-sm cursor-pointer rounded-sm",
|
||||
active ? "bg-[#EAF4FF] text-[#0062D9]" : "text-[#3D3D3D] hover:bg-[#EAF4FF]"
|
||||
)}>{label}</li>
|
||||
|
||||
// 分组标题
|
||||
<div className="px-3 py-1.5 text-xs text-[#A6A6A6]">分组标题1</div>
|
||||
```
|
||||
|
||||
> 推荐直接基于 Radix `Select`(项目已有 shadcn 封装)改这些类名落地。
|
||||
|
||||
---
|
||||
|
||||
## 5.14 输入 - 开关(Switch)
|
||||
|
||||
两种形态:
|
||||
|
||||
### 1. 滑块开关(多用于表单、表格中)
|
||||
|
||||
- **开**:胶囊底 `--color-primary`,白色圆形滑块在右,左侧白字「开」
|
||||
- **关**:胶囊底灰 `--color-text-secondary`/`#C1C1C1`,滑块在左,右侧灰字「关」
|
||||
- 切换时滑块带左右过渡动画
|
||||
|
||||
```tsx
|
||||
<button role="switch" aria-checked={on}
|
||||
className={cn(
|
||||
"relative h-6 w-12 rounded-full transition-colors text-[11px] text-white",
|
||||
on ? "bg-[#0062D9]" : "bg-[#C1C1C1]"
|
||||
)}>
|
||||
<span className={cn("absolute top-0.5 size-5 rounded-full bg-white transition-all",
|
||||
on ? "left-[26px]" : "left-0.5")} />
|
||||
<span className={cn("absolute top-1/2 -translate-y-1/2", on ? "left-2" : "right-2 text-[#7F7F7F]")}>
|
||||
{on ? "开" : "关"}
|
||||
</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
### 2. 单选框作为开关(分段式)
|
||||
|
||||
- `开 | 关` 两段式:激活段 `--color-primary` 实底白字,未激活段白底灰字 + `--color-border` 边框
|
||||
- 即 5.9 拼接式「实底选择按钮」的两选项特例
|
||||
|
||||
```tsx
|
||||
<div className="inline-flex rounded overflow-hidden border border-[#DBDBDB]">
|
||||
<button className={cn("h-7 px-4 text-sm", on ? "bg-[#0062D9] text-white" : "bg-white text-[#3D3D3D]")}>开</button>
|
||||
<button className={cn("h-7 px-4 text-sm border-l border-[#DBDBDB]", !on ? "bg-[#0062D9] text-white" : "bg-white text-[#3D3D3D]")}>关</button>
|
||||
</div>
|
||||
```
|
||||
@@ -66,6 +66,7 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
attribute="class"
|
||||
storageKey="strategy-theme"
|
||||
defaultTheme="light"
|
||||
themes={["light", "dark", "lightblue"]}
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
forcedTheme={forcedTheme}
|
||||
|
||||
@@ -29,6 +29,8 @@ interface AgentSelectorProps {
|
||||
selectedAgentId: string | null;
|
||||
onAgentChange: (agentId: string | null) => void;
|
||||
disabled?: boolean;
|
||||
/** 紧凑模式(CM 参考图):触发器改为灰底胶囊 + 主色机器人图标。 */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +46,7 @@ export function AgentSelector({
|
||||
selectedAgentId,
|
||||
onAgentChange,
|
||||
disabled,
|
||||
compact = false,
|
||||
}: AgentSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
@@ -76,12 +79,17 @@ export function AgentSelector({
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full border px-3! shadow-none transition-colors",
|
||||
selectedAgent
|
||||
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
|
||||
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
compact
|
||||
? "border-transparent bg-gray-50/60 text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
: selectedAgent
|
||||
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
|
||||
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
||||
)}
|
||||
>
|
||||
<BotIcon className="size-3" />
|
||||
<BotIcon
|
||||
className={cn("size-3", compact && "text-[var(--primary)]")}
|
||||
/>
|
||||
<span className="max-w-[140px] truncate text-xs font-normal">
|
||||
{triggerLabel}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* 紧凑模式(正常 App)新对话落地页的品牌欢迎块 —— 按 CM-assistant 参考图重画。
|
||||
* 仅展示,无交互。配色走主题 token(--workspace-brand / --primary),红/蓝/深蓝主题自适应。
|
||||
* 非紧凑模式仍用 <Welcome/>,本组件不参与。
|
||||
*/
|
||||
export function CompactWelcome({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full flex-col items-center justify-center text-center select-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* CM logo:浅底圆环 + 主色渐变圆角方块 */}
|
||||
<div
|
||||
className="mb-3.5 inline-flex items-center justify-center rounded-full p-3"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--workspace-brand) 5%, transparent)",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--workspace-brand) 12%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex size-14 items-center justify-center rounded-2xl shadow-md"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to top right, var(--workspace-brand), color-mix(in srgb, var(--workspace-brand) 55%, white))",
|
||||
}}
|
||||
>
|
||||
<span className="text-2xl font-black tracking-wide text-white italic">
|
||||
CM
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="flex items-center justify-center gap-2 text-2xl font-bold tracking-tight text-foreground/90">
|
||||
<span className="text-2xl">👋</span> 欢迎使用CM助手
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed">
|
||||
基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@ import {
|
||||
Download,
|
||||
FileJson,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Pin,
|
||||
@@ -71,18 +72,22 @@ import {
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function RecentChatList() {
|
||||
export function RecentChatList({ compact = false }: { compact?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(
|
||||
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
|
||||
);
|
||||
// 紧凑模式下整列由 WorkspaceSidebar 统一收起,列表本身始终展开、不显示标题折叠钮。
|
||||
const collapsed = compact ? false : internalCollapsed;
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
const next = !collapsed;
|
||||
setCollapsed(next);
|
||||
if (compact) return;
|
||||
const next = !internalCollapsed;
|
||||
setInternalCollapsed(next);
|
||||
localStorage.setItem("sidebar:recent-chats-collapsed", String(next));
|
||||
};
|
||||
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } =
|
||||
@@ -189,17 +194,29 @@ export function RecentChatList() {
|
||||
<>
|
||||
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
|
||||
<SidebarGroupLabel
|
||||
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
|
||||
className={cn(
|
||||
"text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase select-none flex items-center justify-between transition-colors",
|
||||
!compact && "cursor-pointer hover:text-muted-foreground",
|
||||
)}
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<span>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
|
||||
? t.sidebar.recentChats
|
||||
: t.sidebar.demoChats}
|
||||
<span className="flex items-center gap-2">
|
||||
<span>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
|
||||
? t.sidebar.recentChats
|
||||
: t.sidebar.demoChats}
|
||||
</span>
|
||||
{compact && (
|
||||
<span className="text-muted-foreground bg-muted rounded-full px-1.5 py-0.5 text-[10px] font-normal tracking-normal tabular-nums">
|
||||
{visibleThreads.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
{!compact && (
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent
|
||||
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
|
||||
@@ -223,13 +240,26 @@ export function RecentChatList() {
|
||||
className={
|
||||
"rounded-lg h-7 px-3 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60 " +
|
||||
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)] " +
|
||||
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none"
|
||||
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none" +
|
||||
(compact
|
||||
? " h-9 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border"
|
||||
: "")
|
||||
}
|
||||
>
|
||||
<Link
|
||||
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={pathOfThread(thread)}
|
||||
>
|
||||
{compact && (
|
||||
<MessageSquare
|
||||
className={
|
||||
"size-3.5 shrink-0 " +
|
||||
(isActive
|
||||
? "text-[var(--primary)]"
|
||||
: "text-muted-foreground/70")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { LayoutGridIcon, ListIcon, MoonIcon, SunIcon } from "lucide-react";
|
||||
import { DropletIcon, LayoutGridIcon, ListIcon, MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useMemo, type ComponentType, type SVGProps } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -58,15 +58,15 @@ export function AppearanceSettingsPage() {
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
|
||||
const handleSetGlobalDefault = async (value: "light" | "dark") => {
|
||||
const handleSetGlobalDefault = async (
|
||||
value: "light" | "dark" | "lightblue",
|
||||
) => {
|
||||
if (value === globalDefault) return;
|
||||
try {
|
||||
await updateAppearance.mutateAsync({ default_theme: value });
|
||||
toast.success(
|
||||
value === "light"
|
||||
? "已将系统默认主题设置为浅色"
|
||||
: "已将系统默认主题设置为深色",
|
||||
);
|
||||
const label =
|
||||
value === "light" ? "浅色" : value === "dark" ? "深色" : "浅蓝";
|
||||
toast.success(`已将系统默认主题设置为${label}`);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
@@ -107,12 +107,20 @@ export function AppearanceSettingsPage() {
|
||||
description: t.settings.appearance.darkDescription,
|
||||
icon: MoonIcon,
|
||||
},
|
||||
{
|
||||
id: "lightblue",
|
||||
label: t.settings.appearance.lightblue,
|
||||
description: t.settings.appearance.lightblueDescription,
|
||||
icon: DropletIcon,
|
||||
},
|
||||
],
|
||||
[
|
||||
t.settings.appearance.dark,
|
||||
t.settings.appearance.darkDescription,
|
||||
t.settings.appearance.light,
|
||||
t.settings.appearance.lightDescription,
|
||||
t.settings.appearance.lightblue,
|
||||
t.settings.appearance.lightblueDescription,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -130,7 +138,7 @@ export function AppearanceSettingsPage() {
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
active={currentTheme === option.id}
|
||||
mode={option.id as "light" | "dark"}
|
||||
mode={option.id as "light" | "dark" | "lightblue"}
|
||||
onSelect={(value) => setTheme(value)}
|
||||
/>
|
||||
))}
|
||||
@@ -165,9 +173,23 @@ export function AppearanceSettingsPage() {
|
||||
<MoonIcon className="mr-1.5 size-4" />
|
||||
深色
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalDefault === "lightblue" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetGlobalDefault("lightblue")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<DropletIcon className="mr-1.5 size-4" />
|
||||
浅蓝
|
||||
</Button>
|
||||
<p className="text-muted-foreground ml-2 text-xs">
|
||||
当前系统默认:
|
||||
{globalDefault === "light" ? "浅色" : "深色"}
|
||||
{globalDefault === "light"
|
||||
? "浅色"
|
||||
: globalDefault === "dark"
|
||||
? "深色"
|
||||
: "浅蓝"}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
@@ -370,8 +392,8 @@ function ThemePreviewCard({
|
||||
label: string;
|
||||
description: string;
|
||||
active: boolean;
|
||||
mode: "light" | "dark";
|
||||
onSelect: (mode: "light" | "dark") => void;
|
||||
mode: "light" | "dark" | "lightblue";
|
||||
onSelect: (mode: "light" | "dark" | "lightblue") => void;
|
||||
}) {
|
||||
const previewMode = mode;
|
||||
return (
|
||||
@@ -401,14 +423,20 @@ function ThemePreviewCard({
|
||||
"relative overflow-hidden rounded-md border text-xs transition-colors",
|
||||
previewMode === "dark"
|
||||
? "border-neutral-800 bg-neutral-900 text-neutral-200"
|
||||
: "border-slate-200 bg-white text-slate-900",
|
||||
: previewMode === "lightblue"
|
||||
? "border-blue-200 bg-white text-slate-900"
|
||||
: "border-slate-200 bg-white text-slate-900",
|
||||
)}
|
||||
>
|
||||
<div className="border-border/50 flex items-center gap-2 border-b px-3 py-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
previewMode === "dark" ? "bg-emerald-400" : "bg-emerald-500",
|
||||
previewMode === "dark"
|
||||
? "bg-emerald-400"
|
||||
: previewMode === "lightblue"
|
||||
? "bg-blue-500"
|
||||
: "bg-emerald-500",
|
||||
)}
|
||||
/>
|
||||
<div className="h-2 w-10 rounded-full bg-current/20" />
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -33,10 +33,19 @@ const NEW_CHAT_CLASS =
|
||||
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
|
||||
"dark:text-primary-foreground dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25";
|
||||
|
||||
// 紧凑模式(CM 参考图):白卡描边、居中、主色 + 图标
|
||||
const NEW_CHAT_COMPACT_CLASS =
|
||||
"h-9 justify-center gap-2 rounded-xl bg-card text-[13px] font-semibold text-foreground transition-colors " +
|
||||
"border border-[color-mix(in_srgb,var(--primary)_18%,transparent)] " +
|
||||
"hover:bg-[color-mix(in_srgb,var(--primary)_4%,transparent)] hover:text-[var(--primary)] hover:border-[color-mix(in_srgb,var(--primary)_32%,transparent)] " +
|
||||
"data-[active=true]:bg-card data-[active=true]:text-[var(--primary)] data-[active=true]:shadow-none";
|
||||
|
||||
export function WorkspaceNavChatList({
|
||||
showCollapseTrigger = false,
|
||||
compact = false,
|
||||
}: {
|
||||
showCollapseTrigger?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
@@ -60,21 +69,46 @@ export function WorkspaceNavChatList({
|
||||
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
|
||||
<SidebarMenu className="gap-1">
|
||||
<SidebarMenuItem>
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats/new"}
|
||||
asChild
|
||||
className={cn(NEW_CHAT_CLASS, showCollapseTrigger && "min-w-0 flex-1")}
|
||||
>
|
||||
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
|
||||
<MessageSquarePlus />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{showCollapseTrigger && (
|
||||
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
{compact ? (
|
||||
// 紧凑模式:新对话占满整行白卡样式 + 右侧收起钮(点击收起整列历史栏)。
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats/new"}
|
||||
asChild
|
||||
className={cn(NEW_CHAT_COMPACT_CLASS, "min-w-0 flex-1")}
|
||||
>
|
||||
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
|
||||
<Plus className="stroke-[2.5] text-[var(--primary)]" />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{showCollapseTrigger && (
|
||||
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats/new"}
|
||||
asChild
|
||||
className={cn(
|
||||
NEW_CHAT_CLASS,
|
||||
showCollapseTrigger && "min-w-0 flex-1",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/page/workspace/chats/new"
|
||||
onClick={handleNewChatClick}
|
||||
>
|
||||
<MessageSquarePlus />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{showCollapseTrigger && (
|
||||
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
{!showCollapseTrigger && (
|
||||
<>
|
||||
|
||||
@@ -27,11 +27,47 @@ export function WorkspaceSidebar({
|
||||
const { theme } = useTheme();
|
||||
const useWhiteSidebar = compactChrome && theme === "light";
|
||||
|
||||
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
||||
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
|
||||
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
|
||||
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
|
||||
if (compactChrome) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!isSidebarOpen}
|
||||
className={cn(
|
||||
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
|
||||
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="none"
|
||||
className={cn(
|
||||
"h-full w-(--sidebar-width)",
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
<RecentChatList compact />
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="icon"
|
||||
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
|
||||
// 非紧凑沿用 icon。
|
||||
collapsible={compactChrome ? "none" : "icon"}
|
||||
className={cn(
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
@@ -45,10 +81,15 @@ export function WorkspaceSidebar({
|
||||
</SidebarHeader>
|
||||
)}
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
|
||||
{isSidebarOpen && <RecentChatList />}
|
||||
{isSidebarOpen && <RecentRoundtableList />}
|
||||
{isSidebarOpen && <RecentWritingList />}
|
||||
<WorkspaceNavChatList
|
||||
showCollapseTrigger={compactChrome}
|
||||
compact={compactChrome}
|
||||
/>
|
||||
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
|
||||
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
|
||||
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
|
||||
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
|
||||
{isSidebarOpen && !compactChrome && <RecentWritingList />}
|
||||
</SidebarContent>
|
||||
{!compactChrome && (
|
||||
<SidebarFooter>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useTheme as useNextTheme } from 'next-themes';
|
||||
import { useAppearanceSettings } from '@/core/system-settings/hooks';
|
||||
|
||||
// 主题类型
|
||||
export type ThemeType = 'dark' | 'light';
|
||||
export type ThemeType = 'dark' | 'light' | 'lightblue';
|
||||
|
||||
// 主题配置
|
||||
export interface ThemeConfig {
|
||||
@@ -82,6 +82,28 @@ const themes: Record<ThemeType, ThemeConfig> = {
|
||||
overlay: 'black/30',
|
||||
},
|
||||
},
|
||||
lightblue: {
|
||||
type: 'lightblue',
|
||||
name: '浅蓝主题',
|
||||
colors: {
|
||||
background: 'white',
|
||||
backgroundSecondary: 'blue-50',
|
||||
text: 'gray-800',
|
||||
textSecondary: 'gray-500',
|
||||
primary: 'blue-600',
|
||||
primaryHover: 'blue-500',
|
||||
border: 'blue-200',
|
||||
accent: 'blue-700',
|
||||
success: 'emerald-600',
|
||||
warning: 'amber-500',
|
||||
error: 'red-600',
|
||||
card: 'white',
|
||||
cardHover: 'blue-50',
|
||||
input: 'white',
|
||||
inputBorder: 'blue-200',
|
||||
overlay: 'black/30',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'strategy-theme';
|
||||
@@ -97,7 +119,9 @@ function readPersistedTheme(): ThemeType {
|
||||
return 'light';
|
||||
}
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return saved === 'dark' ? 'dark' : 'light';
|
||||
if (saved === 'dark') return 'dark';
|
||||
if (saved === 'lightblue') return 'lightblue';
|
||||
return 'light';
|
||||
}
|
||||
|
||||
function hasExplicitChoice(): boolean {
|
||||
@@ -151,7 +175,12 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const current = localStorage.getItem(STORAGE_KEY);
|
||||
const legacy = localStorage.getItem('theme');
|
||||
|
||||
if (current && current !== 'light' && current !== 'dark') {
|
||||
if (
|
||||
current &&
|
||||
current !== 'light' &&
|
||||
current !== 'dark' &&
|
||||
current !== 'lightblue'
|
||||
) {
|
||||
// Garbage value — wipe it so the boot-sync below can take over.
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
@@ -185,7 +214,9 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const theme: ThemeType = mounted
|
||||
? effectiveTheme === 'dark'
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: effectiveTheme === 'lightblue'
|
||||
? 'lightblue'
|
||||
: 'light'
|
||||
: readPersistedTheme();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -448,9 +448,11 @@ export const enUS: Translations = {
|
||||
system: "System",
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
lightblue: "Light Blue",
|
||||
systemDescription: "Match the operating system preference automatically.",
|
||||
lightDescription: "Bright palette with higher contrast for daytime.",
|
||||
darkDescription: "Dim palette that reduces glare for focus.",
|
||||
lightblueDescription: "A crisp light palette built around blue.",
|
||||
languageTitle: "Language",
|
||||
languageDescription: "Switch between languages.",
|
||||
},
|
||||
|
||||
@@ -383,9 +383,11 @@ export interface Translations {
|
||||
system: string;
|
||||
light: string;
|
||||
dark: string;
|
||||
lightblue: string;
|
||||
systemDescription: string;
|
||||
lightDescription: string;
|
||||
darkDescription: string;
|
||||
lightblueDescription: string;
|
||||
languageTitle: string;
|
||||
languageDescription: string;
|
||||
};
|
||||
|
||||
@@ -429,9 +429,11 @@ export const zhCN: Translations = {
|
||||
system: "系统",
|
||||
light: "浅色",
|
||||
dark: "深色",
|
||||
lightblue: "浅蓝",
|
||||
systemDescription: "自动跟随系统主题。",
|
||||
lightDescription: "更明亮的配色,适合日间使用。",
|
||||
darkDescription: "更暗的配色,减少眩光方便专注。",
|
||||
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
||||
languageTitle: "语言",
|
||||
languageDescription: "在不同语言之间切换。",
|
||||
},
|
||||
|
||||
@@ -135,7 +135,7 @@ export async function updatePublishSquares(
|
||||
return (await response.json()) as PublishSquaresSettings;
|
||||
}
|
||||
|
||||
export type ThemeMode = "light" | "dark";
|
||||
export type ThemeMode = "light" | "dark" | "lightblue";
|
||||
export type ViewMode = "card" | "list";
|
||||
|
||||
export interface AppearanceSettings {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
export const DESENSITIZE_MAP: Record<string, string> = {
|
||||
态势: "TS",
|
||||
你好: "nh",
|
||||
滨海作战体系: "第一张网",
|
||||
滨海作战部队: "第一张网",
|
||||
'Taiwan Air Force Flight Units': "第二张网",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -28,6 +28,12 @@ import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { CompactWelcome } from "@/components/workspace/compact-welcome";
|
||||
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
|
||||
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PanelLeftOpen } from "lucide-react";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
@@ -85,6 +91,9 @@ export default function ChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
|
||||
useSidebar();
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
@@ -402,6 +411,11 @@ export default function ChatPage() {
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
// 紧凑模式(正常 App,有外层页面侧栏)落地态:用 CM 参考图样式重画;
|
||||
// 判定与 PageRoutes 的 compactWorkspaceSidebar 一致,非紧凑(嵌入/standalone)保持原样。
|
||||
const compactChrome =
|
||||
readSidebarEnabled() && !isChatsIframeWorkspacePath(pathname);
|
||||
const compactLanding = isLandingState && compactChrome;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
@@ -419,6 +433,20 @@ export default function ChatPage() {
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
|
||||
{compactChrome && !historySidebarOpen && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={toggleHistorySidebar}
|
||||
title="打开历史记录"
|
||||
aria-label="打开历史记录"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<PanelLeftOpen className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
@@ -453,11 +481,25 @@ export default function ChatPage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
// 外层居中容器不吃指针事件:空白区域(尤其落地态铺满 inset-0 时盖住的
|
||||
// 右上角「展开历史」按钮)让点击穿透,仅真正的内容块(下方 pointer-events-auto)
|
||||
// 接收交互。否则该透明层会吞掉顶栏按钮的 hover/光标/点击。
|
||||
"pointer-events-none absolute z-30 flex justify-center px-4",
|
||||
// 紧凑落地态:整块垂直+水平居中于对话区
|
||||
compactLanding
|
||||
? "inset-0 items-center"
|
||||
: "right-0 bottom-0 left-0",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||
"pointer-events-auto relative w-full",
|
||||
// 非紧凑落地态沿用上移居中;紧凑态由外层 items-center 居中,无需位移
|
||||
isLandingState &&
|
||||
!compactLanding &&
|
||||
"-translate-y-[calc(50vh-96px)]",
|
||||
isLandingState
|
||||
? "max-w-(--container-width-md)"
|
||||
: "max-w-(--container-width-lg)",
|
||||
@@ -474,17 +516,21 @@ export default function ChatPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isLandingState && mounted && (
|
||||
<Welcome
|
||||
mode={settings.context.mode}
|
||||
className="mb-6 -translate-y-4"
|
||||
/>
|
||||
)}
|
||||
{isLandingState && mounted &&
|
||||
(compactLanding ? (
|
||||
<CompactWelcome className="mb-6 -translate-y-4" />
|
||||
) : (
|
||||
<Welcome
|
||||
mode={settings.context.mode}
|
||||
className="mb-6 -translate-y-4"
|
||||
/>
|
||||
))}
|
||||
{mounted ? (
|
||||
<div className="w-full -translate-y-4">
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={isLandingState}
|
||||
compact={compactLanding}
|
||||
threadId={threadId}
|
||||
autoFocus={isLandingState}
|
||||
status={
|
||||
@@ -525,6 +571,11 @@ export default function ChatPage() {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{compactLanding && (
|
||||
<div className="text-muted-foreground/70 w-full -translate-y-2 text-center text-[11px]">
|
||||
CM Assistant V1.2 · 深度推理可能会涉及数据同步 · 信息由AI自动生成,请审慎参考
|
||||
</div>
|
||||
)}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
|
||||
@@ -19,8 +19,9 @@ export default function LightAppIframeView() {
|
||||
|
||||
// 由 useTheme 的 theme 直接换算主题色并显式传入,切主题时 src 立即重建、
|
||||
// 把新主题色传给被嵌套应用(不依赖 <html> 的 dark class,避免时序读旧值)。
|
||||
// TODO: 后续接入 blue 主题后,这里区分 blue 与 dark-blue。
|
||||
const themeColor: AppThemeColor = isDark ? "dark-blue" : "red";
|
||||
// 深色=dark-blue,浅蓝=blue,其余(红白)=red。
|
||||
const themeColor: AppThemeColor =
|
||||
theme === "dark" ? "dark-blue" : theme === "lightblue" ? "blue" : "red";
|
||||
const src = useMemo(
|
||||
() => (app ? buildLightAppUrl(app, undefined, themeColor) : ""),
|
||||
[app, themeColor],
|
||||
|
||||
@@ -109,7 +109,8 @@ export function WorkspaceLayout({
|
||||
} as CSSProperties
|
||||
}
|
||||
data-page-sidebar-collapsed={pageSidebarCollapsed}
|
||||
defaultOpen
|
||||
// 紧凑模式下历史侧栏默认收起(聊天区更宽),由聊天页右上角图标钮展开
|
||||
defaultOpen={!compactWorkspaceSidebar}
|
||||
>
|
||||
{!workspaceSidebarHidden && (
|
||||
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
|
||||
|
||||
@@ -471,21 +471,21 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 text-slate-800">
|
||||
<div className="flex-1 flex flex-col min-h-0 text-slate-800 dark:text-foreground">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-gradient-to-r from-slate-50 to-white flex-wrap gap-3">
|
||||
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-gradient-to-r from-slate-50 to-white flex-wrap gap-3 dark:border-border dark:from-card dark:to-card">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-emerald-500" />
|
||||
<h3 className="text-sm font-black text-slate-900 tracking-tight">最终方案展示</h3>
|
||||
<h3 className="text-sm font-black text-slate-900 tracking-tight dark:text-foreground">最终方案展示</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{hasConsensus && (
|
||||
<span className="text-[10px] font-black text-emerald-700 bg-emerald-50 border border-emerald-200 px-2 py-0.5 rounded-full shadow-sm">
|
||||
<span className="text-[10px] font-black text-emerald-700 bg-emerald-50 border border-emerald-200 px-2 py-0.5 rounded-full shadow-sm dark:text-emerald-300 dark:bg-emerald-500/10 dark:border-emerald-500/30 dark:shadow-none">
|
||||
● 已达成共识
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-mono text-slate-400 font-semibold bg-slate-100/80 px-2 py-0.5 rounded-lg">
|
||||
<span className="text-[10px] font-mono text-slate-400 font-semibold bg-slate-100/80 px-2 py-0.5 rounded-lg dark:text-muted-foreground dark:bg-muted">
|
||||
生成时间:{generatedAt}
|
||||
</span>
|
||||
</div>
|
||||
@@ -521,7 +521,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
type="button"
|
||||
onClick={() => setSummaryMode(true)}
|
||||
title="返回「方案总结报告」(Markdown 总结 + 问答/修改)"
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer"
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer dark:bg-secondary dark:border-border dark:text-foreground dark:hover:bg-accent"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-slate-500" />
|
||||
总结报告
|
||||
@@ -538,7 +538,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReloadKey((k) => k + 1)}
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer"
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer dark:bg-secondary dark:border-border dark:text-foreground dark:hover:bg-accent"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
|
||||
刷新文件清单
|
||||
@@ -551,21 +551,21 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
{/* ── 左侧参与席位栏:可收起成头像列;有交付内容的席位头像点亮 ── */}
|
||||
<aside
|
||||
className={cn(
|
||||
"shrink-0 flex flex-col border-r border-slate-100 bg-slate-50/50 transition-all",
|
||||
"shrink-0 flex flex-col border-r border-slate-100 bg-slate-50/50 transition-all dark:border-border dark:bg-card",
|
||||
sidebarCollapsed ? "w-14" : "w-52",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center border-b border-slate-100 px-2 py-2",
|
||||
"flex shrink-0 items-center border-b border-slate-100 px-2 py-2 dark:border-border",
|
||||
sidebarCollapsed ? "justify-center" : "justify-between",
|
||||
)}
|
||||
>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-black uppercase tracking-widest text-slate-500">
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-black uppercase tracking-widest text-slate-500 dark:text-muted-foreground">
|
||||
<Users className="w-3.5 h-3.5 text-primary" />
|
||||
参与席位
|
||||
<span className="rounded bg-slate-100 px-1 py-px font-mono text-[9px] font-semibold text-slate-400">
|
||||
<span className="rounded bg-slate-100 px-1 py-px font-mono text-[9px] font-semibold text-slate-400 dark:bg-muted dark:text-muted-foreground">
|
||||
{seatOverviews.length}
|
||||
</span>
|
||||
</span>
|
||||
@@ -574,7 +574,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
type="button"
|
||||
onClick={() => setSidebarCollapsed((v) => !v)}
|
||||
title={sidebarCollapsed ? "展开席位栏" : "收起席位栏"}
|
||||
className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
|
||||
className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600 dark:text-muted-foreground dark:hover:bg-accent dark:hover:text-foreground"
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronsRight className="w-3.5 h-3.5" />
|
||||
@@ -595,7 +595,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex size-9 items-center justify-center rounded-full border-2 bg-white",
|
||||
"relative flex size-9 items-center justify-center rounded-full border-2 bg-white dark:bg-secondary",
|
||||
consensusText
|
||||
? "border-emerald-400/70"
|
||||
: "border-slate-200 opacity-45 grayscale",
|
||||
@@ -614,14 +614,14 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 rounded-xl border px-2 py-1.5 text-left transition",
|
||||
consensusText
|
||||
? "border-emerald-200/70 bg-emerald-50/40 hover:border-emerald-400/60 hover:bg-emerald-50"
|
||||
: "border-slate-150 bg-slate-50/60 hover:bg-slate-100",
|
||||
? "border-emerald-200/70 bg-emerald-50/40 hover:border-emerald-400/60 hover:bg-emerald-50 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:hover:border-emerald-500/50 dark:hover:bg-emerald-500/15"
|
||||
: "border-slate-150 bg-slate-50/60 hover:bg-slate-100 dark:border-border dark:bg-white/5 dark:hover:bg-accent",
|
||||
)}
|
||||
title={consensusText ? "点击查看总控最终结论" : "暂无总控结论"}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex size-9 shrink-0 items-center justify-center rounded-full border-2 bg-white",
|
||||
"relative flex size-9 shrink-0 items-center justify-center rounded-full border-2 bg-white dark:bg-secondary",
|
||||
consensusText
|
||||
? "border-emerald-400/70"
|
||||
: "border-slate-200 opacity-45 grayscale",
|
||||
@@ -636,12 +636,12 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[11.5px] font-bold",
|
||||
consensusText ? "text-slate-800" : "text-slate-400",
|
||||
consensusText ? "text-slate-800 dark:text-foreground" : "text-slate-400 dark:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
总控协调
|
||||
</span>
|
||||
<span className="block truncate text-[9.5px] font-mono text-slate-400">
|
||||
<span className="block truncate text-[9.5px] font-mono text-slate-400 dark:text-muted-foreground">
|
||||
{consensusText ? "最终结论" : "无结论"}
|
||||
</span>
|
||||
</span>
|
||||
@@ -661,7 +661,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex size-9 items-center justify-center rounded-full border-2 bg-white",
|
||||
"relative flex size-9 items-center justify-center rounded-full border-2 bg-white dark:bg-secondary",
|
||||
lit
|
||||
? "border-emerald-400/70"
|
||||
: "border-slate-200 opacity-45 grayscale",
|
||||
@@ -682,14 +682,14 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 rounded-xl border px-2 py-1.5 text-left transition",
|
||||
lit
|
||||
? "border-slate-200 bg-white hover:border-primary/40 hover:bg-primary/5"
|
||||
: "border-slate-150 bg-slate-50/60 hover:bg-slate-100",
|
||||
? "border-slate-200 bg-white hover:border-primary/40 hover:bg-primary/5 dark:border-border dark:bg-secondary dark:hover:border-primary/50 dark:hover:bg-accent"
|
||||
: "border-slate-150 bg-slate-50/60 hover:bg-slate-100 dark:border-border dark:bg-white/5 dark:hover:bg-accent",
|
||||
)}
|
||||
title={lit ? "点击查看该席位的交付与文件" : "该席位暂无交付"}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex size-9 shrink-0 items-center justify-center rounded-full border-2 bg-white",
|
||||
"relative flex size-9 shrink-0 items-center justify-center rounded-full border-2 bg-white dark:bg-secondary",
|
||||
lit
|
||||
? "border-emerald-400/70"
|
||||
: "border-slate-200 opacity-45 grayscale",
|
||||
@@ -704,12 +704,12 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[11.5px] font-bold",
|
||||
lit ? "text-slate-800" : "text-slate-400",
|
||||
lit ? "text-slate-800 dark:text-foreground" : "text-slate-400 dark:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{seat.displayName}
|
||||
</span>
|
||||
<span className="block truncate text-[9.5px] font-mono text-slate-400">
|
||||
<span className="block truncate text-[9.5px] font-mono text-slate-400 dark:text-muted-foreground">
|
||||
{lit ? `${seat.delivery!.length} 字` : "无交付"}
|
||||
{seat.files.length > 0 ? ` · ${seat.files.length} 文件` : ""}
|
||||
</span>
|
||||
@@ -718,7 +718,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
);
|
||||
})}
|
||||
{seatOverviews.length === 0 && !sidebarCollapsed && (
|
||||
<p className="px-1 pt-2 text-[10px] italic text-slate-400">
|
||||
<p className="px-1 pt-2 text-[10px] italic text-slate-400 dark:text-muted-foreground">
|
||||
暂无参与席位
|
||||
</p>
|
||||
)}
|
||||
@@ -731,13 +731,13 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
<section
|
||||
ref={consensusSectionRef}
|
||||
className={cn(
|
||||
"scroll-mt-4 bg-white border rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.02)] overflow-hidden transition-shadow",
|
||||
"scroll-mt-4 bg-white border rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.02)] overflow-hidden transition-shadow dark:bg-card dark:shadow-none",
|
||||
focusedSeat === CONSENSUS_KEY
|
||||
? "border-primary/50 ring-2 ring-primary/30"
|
||||
: "border-slate-200",
|
||||
: "border-slate-200 dark:border-border",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-5 py-3.5 bg-slate-50/40">
|
||||
<div className="flex items-center justify-between gap-2 px-5 py-3.5 bg-slate-50/40 dark:bg-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConsensusExpanded((v) => !v)}
|
||||
@@ -745,12 +745,12 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
title={consensusExpanded ? "收起结论" : "展开结论"}
|
||||
>
|
||||
{consensusExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0 dark:text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0 dark:text-muted-foreground" />
|
||||
)}
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500 shrink-0" />
|
||||
<span className="truncate text-xs font-black text-slate-900 uppercase tracking-widest">
|
||||
<span className="truncate text-xs font-black text-slate-900 uppercase tracking-widest dark:text-foreground">
|
||||
总控最终结论
|
||||
</span>
|
||||
</button>
|
||||
@@ -767,15 +767,15 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="border-t border-slate-100 px-5 py-4">
|
||||
<div className="border-t border-slate-100 px-5 py-4 dark:border-border">
|
||||
{consensusText ? (
|
||||
<div className="text-xs text-slate-700 leading-relaxed">
|
||||
<div className="text-xs text-slate-700 leading-relaxed dark:text-foreground/80">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{consensusText}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-slate-400 italic">
|
||||
<p className="text-[11px] text-slate-400 italic dark:text-muted-foreground">
|
||||
暂无总控结论。返回 Step 2 完成至少一轮研讨后再查看此页。
|
||||
</p>
|
||||
)}
|
||||
@@ -786,8 +786,8 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
|
||||
{/* Section 2: 每席位一个区块 —— 交付内容 + 该席位生成的报告文件(卡片,点击开沙箱) */}
|
||||
{seatOverviews.length === 0 ? (
|
||||
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
|
||||
<p className="text-[11px] text-slate-400 italic">
|
||||
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)] dark:bg-card dark:border-border dark:shadow-none">
|
||||
<p className="text-[11px] text-slate-400 italic dark:text-muted-foreground">
|
||||
本次研讨尚未产生子智能体交付。Leader 派活并收到子智能体回复后,这里会按席位展示交付与产出文件。
|
||||
</p>
|
||||
</section>
|
||||
@@ -803,37 +803,37 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
else seatSectionRefs.current.delete(seat.key);
|
||||
}}
|
||||
className={cn(
|
||||
"scroll-mt-4 bg-white border rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.02)] overflow-hidden transition-shadow",
|
||||
"scroll-mt-4 bg-white border rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.02)] overflow-hidden transition-shadow dark:bg-card dark:shadow-none",
|
||||
focusedSeat === seat.key
|
||||
? "border-primary/50 ring-2 ring-primary/30"
|
||||
: "border-slate-200",
|
||||
: "border-slate-200 dark:border-border",
|
||||
)}
|
||||
>
|
||||
{/* 区块头:头像 + 席位名 + 徽标,点击折叠/展开 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSeat(seat.key)}
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 bg-slate-50/70 hover:bg-slate-100/70 transition text-left"
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 bg-slate-50/70 hover:bg-slate-100/70 transition text-left dark:bg-white/5 dark:hover:bg-accent"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0 dark:text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0 dark:text-muted-foreground" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-full border-2 bg-white",
|
||||
lit ? "border-emerald-400/70" : "border-slate-200 opacity-50 grayscale",
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-full border-2 bg-white dark:bg-secondary",
|
||||
lit ? "border-emerald-400/70" : "border-slate-200 opacity-50 grayscale dark:border-border",
|
||||
)}
|
||||
>
|
||||
{getAvatar(seat.avatarType, "h-5.5 w-5.5")}
|
||||
</span>
|
||||
<span className="text-xs font-black text-slate-800 truncate">
|
||||
<span className="text-xs font-black text-slate-800 truncate dark:text-foreground">
|
||||
{seat.displayName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0 dark:text-muted-foreground">
|
||||
{lit ? `${seat.delivery!.length} 字` : "无交付"}
|
||||
{seat.loading
|
||||
? " · 文件加载中…"
|
||||
@@ -850,9 +850,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="border-t border-slate-100">
|
||||
<div className="border-t border-slate-100 dark:border-border">
|
||||
{/* 交付内容 */}
|
||||
<div className="px-5 py-4 text-xs text-slate-700 leading-relaxed">
|
||||
<div className="px-5 py-4 text-xs text-slate-700 leading-relaxed dark:text-foreground/80">
|
||||
{lit ? (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
@@ -861,19 +861,19 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
{seat.delivery!}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-[11px] text-slate-400 italic">该席位暂无交付内容。</p>
|
||||
<p className="text-[11px] text-slate-400 italic dark:text-muted-foreground">该席位暂无交付内容。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 该席位生成的报告文件 —— 卡片,点击在沙箱中打开 */}
|
||||
{(seat.loading || seat.error || seat.files.length > 0) && (
|
||||
<div className="border-t border-slate-100 bg-slate-50/40 px-5 py-3">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-slate-500">
|
||||
<div className="border-t border-slate-100 bg-slate-50/40 px-5 py-3 dark:border-border dark:bg-white/5">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-slate-500 dark:text-muted-foreground">
|
||||
<Download className="w-3 h-3 text-primary" />
|
||||
生成的报告文件
|
||||
</div>
|
||||
{seat.loading ? (
|
||||
<div className="flex items-center gap-2 text-[11px] text-slate-500">
|
||||
<div className="flex items-center gap-2 text-[11px] text-slate-500 dark:text-muted-foreground">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-primary" />
|
||||
<span>正在查询 outputs 目录…</span>
|
||||
</div>
|
||||
@@ -896,7 +896,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
}
|
||||
}}
|
||||
title="点击在沙箱中查看"
|
||||
className="group flex cursor-pointer items-center justify-between gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2.5 transition hover:border-primary/40 hover:bg-primary/5 hover:shadow-sm"
|
||||
className="group flex cursor-pointer items-center justify-between gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2.5 transition hover:border-primary/40 hover:bg-primary/5 hover:shadow-sm dark:border-border dark:bg-secondary dark:hover:border-primary/50 dark:hover:bg-accent dark:hover:shadow-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
@@ -904,25 +904,25 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span
|
||||
className="block truncate text-[11.5px] font-bold text-slate-800"
|
||||
className="block truncate text-[11.5px] font-bold text-slate-800 dark:text-foreground"
|
||||
title={f.name}
|
||||
>
|
||||
{f.name}
|
||||
</span>
|
||||
<span className="block text-[9.5px] font-mono text-slate-400">
|
||||
<span className="block text-[9.5px] font-mono text-slate-400 dark:text-muted-foreground">
|
||||
{formatBytes(f.sizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Eye className="w-3.5 h-3.5 text-slate-300 transition group-hover:text-primary" />
|
||||
<Eye className="w-3.5 h-3.5 text-slate-300 transition group-hover:text-primary dark:text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (seat.threadId) handleDownload(seat.threadId, f);
|
||||
}}
|
||||
className="rounded p-1 text-slate-400 transition hover:bg-slate-100 hover:text-primary"
|
||||
className="rounded p-1 text-slate-400 transition hover:bg-slate-100 hover:text-primary dark:text-muted-foreground dark:hover:bg-accent"
|
||||
title="下载"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
@@ -943,14 +943,14 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-2 border-t border-slate-100 flex items-center justify-between flex-wrap gap-4">
|
||||
<span className="text-xs text-slate-400 font-medium">
|
||||
<div className="pt-2 border-t border-slate-100 flex items-center justify-between flex-wrap gap-4 dark:border-border">
|
||||
<span className="text-xs text-slate-400 font-medium dark:text-muted-foreground">
|
||||
如需重新立项,可清空当前研讨,回到任务理解阶段重新起草。
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="border border-slate-700 text-slate-700 hover:text-slate-900 hover:bg-slate-50 text-[10.5px] font-bold px-4 py-3 rounded-2xl transition cursor-pointer select-none"
|
||||
className="border border-slate-700 text-slate-700 hover:text-slate-900 hover:bg-slate-50 text-[10.5px] font-bold px-4 py-3 rounded-2xl transition cursor-pointer select-none dark:border-border dark:text-foreground dark:hover:bg-accent dark:hover:text-foreground"
|
||||
>
|
||||
重新起草新规划
|
||||
</button>
|
||||
@@ -968,21 +968,21 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
{fileSandboxTarget && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full border-l border-slate-100 bg-[#fffdfa] p-3 transition-transform duration-300 ease-in-out",
|
||||
"h-full w-full border-l border-slate-100 bg-[#fffdfa] p-3 transition-transform duration-300 ease-in-out dark:border-border dark:bg-card",
|
||||
fileSandboxOpen ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
{!fileSandboxIsText ? (
|
||||
// 二进制(图片 / PDF 等):直接 iframe 下载路由(浏览器原生渲染)。
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-slate-200 bg-white">
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-slate-100 px-3 py-2">
|
||||
<span className="truncate text-xs font-bold text-slate-700">
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-slate-200 bg-white dark:border-border dark:bg-secondary">
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-slate-100 px-3 py-2 dark:border-border">
|
||||
<span className="truncate text-xs font-bold text-slate-700 dark:text-foreground">
|
||||
{fileSandboxTarget.file.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseFileSandbox}
|
||||
className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
|
||||
className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600 dark:text-muted-foreground dark:hover:bg-accent dark:hover:text-foreground"
|
||||
title="关闭预览"
|
||||
>
|
||||
×
|
||||
@@ -998,7 +998,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
/>
|
||||
</div>
|
||||
) : fileSandboxContent == null ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white text-xs text-slate-400">
|
||||
<div className="flex h-full items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white text-xs text-slate-400 dark:border-border dark:bg-secondary dark:text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
正在读取文件内容…
|
||||
</div>
|
||||
|
||||
@@ -21,11 +21,14 @@ import {
|
||||
} from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Boxes,
|
||||
CheckCircle,
|
||||
Code2,
|
||||
ExternalLink,
|
||||
LayoutPanelTop,
|
||||
Loader2,
|
||||
Pause,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
@@ -43,7 +46,7 @@ import { DASHBOARD_VIEW_STORAGE_PREFIX } from "@/roundtable-planning/pages/Dashb
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
||||
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
|
||||
import { parseReportJson } from "@/roundtable-planning/lib/report-template";
|
||||
import { parseReportJson, stripReportFence } from "@/roundtable-planning/lib/report-template";
|
||||
|
||||
interface Step3DashboardModeProps {
|
||||
selectedModel: string;
|
||||
@@ -65,6 +68,7 @@ function formatNow(): string {
|
||||
}
|
||||
|
||||
type DashView = "chat" | "page";
|
||||
type RenderMode = "app" | "html";
|
||||
|
||||
export function Step3DashboardMode({
|
||||
selectedModel,
|
||||
@@ -101,12 +105,13 @@ export function Step3DashboardMode({
|
||||
// 视图:有产物默认看「大屏页面」,否则进「交互区」看抽取过程。
|
||||
const [view, setView] = useState<DashView>(() => (hasResult ? "page" : "chat"));
|
||||
|
||||
// 完成即自动切到大屏页面(含问答修订后的再次完成)。
|
||||
const prevPhaseRef = useRef(phase);
|
||||
useEffect(() => {
|
||||
if (phase === "done" && prevPhaseRef.current !== "done") setView("page");
|
||||
prevPhaseRef.current = phase;
|
||||
}, [phase]);
|
||||
// 大屏页面的两种渲染:
|
||||
// - "app" 应用渲染:用应用内 React 组件(DashboardApp)渲染,交互更丰富(Tab/动画/钻取)。
|
||||
// - "html" 网页渲染:直接把自包含 HTML 大屏塞进 iframe,与「新窗口打开 / 分享 / 下载」一致。
|
||||
const [renderMode, setRenderMode] = useState<RenderMode>("app");
|
||||
|
||||
// 绘制完成**不再**自动切到大屏页面:用户正在交互区看抽取过程/继续问答时,强行跳走很打断。
|
||||
// 想看成品由用户手动点顶栏「查看大屏」。没有产物时初始即落交互区(见上 useState 初值)。
|
||||
|
||||
// 仅在交互区允许本流自动接管沙箱(并行流防抢);离开/卸载时让出。
|
||||
useEffect(() => {
|
||||
@@ -114,10 +119,22 @@ export function Step3DashboardMode({
|
||||
return () => setPreviewEnabled(false);
|
||||
}, [view, setPreviewEnabled]);
|
||||
|
||||
// 新窗口打开:把当前大屏数据写进 localStorage,在新标签页打开应用内 React 大屏
|
||||
// (独立全屏路由 /roundtable-dashboard,无侧边栏)。无真实数据时 standalone 回落 DEMO。
|
||||
// 新窗口打开:跟随当前渲染方式——
|
||||
// - 网页渲染:把自包含 HTML 大屏(blob)直接在新标签页打开(纯网页,无应用壳);
|
||||
// - 应用渲染:把当前大屏数据写进 localStorage,在新标签页打开应用内 React 大屏
|
||||
// (独立全屏路由 /roundtable-dashboard,无侧边栏)。无真实数据时 standalone 回落 DEMO。
|
||||
const handleOpenNewWindow = () => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
if (renderMode === "html" && html) {
|
||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
window.open(blobUrl, "_blank", "noopener,noreferrer");
|
||||
// 延迟回收,给新标签页足够时间加载(过早 revoke 会打不开)。
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${DASHBOARD_VIEW_STORAGE_PREFIX}${Date.now()}`;
|
||||
try {
|
||||
// 先清掉历史键,避免 localStorage 堆积;再写本次数据。
|
||||
@@ -135,9 +152,9 @@ export function Step3DashboardMode({
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const handleRedraw = () => {
|
||||
// 「编辑」:回到交互区接着上次的聊天记录继续问答修改(不丢弃上下文、不重画)。
|
||||
const handleEdit = () => {
|
||||
setView("chat");
|
||||
redraw();
|
||||
};
|
||||
|
||||
const isStreaming =
|
||||
@@ -160,19 +177,57 @@ export function Step3DashboardMode({
|
||||
<div className="flex items-center gap-2">
|
||||
{view === "page" ? (
|
||||
<>
|
||||
{/* 渲染方式切换:应用渲染(React 组件)/ 网页渲染(自包含 HTML iframe) */}
|
||||
<div className="inline-flex items-center gap-0.5 rounded-xl border border-slate-200 bg-slate-50 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenderMode("app")}
|
||||
title="用应用内 React 组件渲染(Tab 切换、动画、席位钻取)"
|
||||
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[10.5px] font-bold transition ${
|
||||
renderMode === "app"
|
||||
? "bg-white text-primary shadow-sm border border-slate-200"
|
||||
: "text-slate-500 hover:text-slate-700"
|
||||
}`}
|
||||
>
|
||||
<Boxes className="w-3.5 h-3.5" />
|
||||
应用渲染
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenderMode("html")}
|
||||
disabled={!html}
|
||||
title={
|
||||
html
|
||||
? "用自包含 HTML 大屏渲染(与新窗口打开 / 分享 / 下载一致)"
|
||||
: "尚无已生成的 HTML 大屏,先完成一次绘制"
|
||||
}
|
||||
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[10.5px] font-bold transition disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
renderMode === "html"
|
||||
? "bg-white text-primary shadow-sm border border-slate-200"
|
||||
: "text-slate-500 hover:text-slate-700"
|
||||
}`}
|
||||
>
|
||||
<Code2 className="w-3.5 h-3.5" />
|
||||
网页渲染
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRedraw}
|
||||
title="回到交互区,重新绘制(可在那里通过问答修改大屏)"
|
||||
onClick={handleEdit}
|
||||
title="回到交互区,加载上次聊天记录继续问答修改"
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
|
||||
重新绘制
|
||||
<Pencil className="w-3.5 h-3.5 text-slate-500" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenNewWindow}
|
||||
title="在新标签页打开 React 大屏页面(全屏,无侧边栏)"
|
||||
title={
|
||||
renderMode === "html"
|
||||
? "在新标签页打开自包含 HTML 大屏(纯网页,可分享 / 下载)"
|
||||
: "在新标签页打开应用内 React 大屏(全屏,无侧边栏)"
|
||||
}
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 text-slate-500" />
|
||||
@@ -214,9 +269,18 @@ export function Step3DashboardMode({
|
||||
|
||||
{/* 主体:交互区(聊天 + 抽取沙箱) / 大屏页面(React) */}
|
||||
{view === "page" ? (
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<DashboardApp data={dashboardData} />
|
||||
</div>
|
||||
renderMode === "html" && html ? (
|
||||
<iframe
|
||||
title="方案研讨大屏"
|
||||
srcDoc={html}
|
||||
className="flex-1 min-h-0 w-full border-0 bg-white"
|
||||
sandbox="allow-scripts allow-popups allow-downloads allow-same-origin"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<DashboardApp data={dashboardData} />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 p-3">
|
||||
<section className="h-full min-h-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
|
||||
@@ -287,6 +351,19 @@ function DashboardChatPanel({
|
||||
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
|
||||
const [text, setText] = useState("");
|
||||
|
||||
// 消息列表里不展示 report-json 结构化数据本体(它已在右侧沙箱实时渲染),
|
||||
// 把数据块替换成一句「看右侧沙箱」的提示,避免左右两边重复刷整段 JSON。
|
||||
const displayDialogues = useMemo(
|
||||
() =>
|
||||
dialogues.map((d) => {
|
||||
const content = d.content ?? "";
|
||||
if (!content) return d;
|
||||
const stripped = stripReportFence(content, d.streaming === true);
|
||||
return stripped === content ? d : { ...d, content: stripped };
|
||||
}),
|
||||
[dialogues],
|
||||
);
|
||||
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const t = text.trim();
|
||||
@@ -368,7 +445,7 @@ function DashboardChatPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialogues.map((msg) => (
|
||||
{displayDialogues.map((msg) => (
|
||||
<Step2Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
|
||||
@@ -5,16 +5,17 @@
|
||||
* 避免 SVG 文字溢出。
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useDashboardData, ACCENTS } from "../data-context";
|
||||
import { useDashboardData } from "../data-context";
|
||||
import { useDashPalette } from "../palette";
|
||||
import { DashChart } from "../types";
|
||||
import { DraftingCard } from "./DraftingCard";
|
||||
import { Activity, TrendingUp, PieChart, Award, BarChart3 } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
|
||||
const PRIMARY = ACCENTS[0];
|
||||
|
||||
// ── 折线(趋势):单序列,归一化到画布 ──────────────────────────────────────────────
|
||||
function LineChartSvg({ items }: { items: DashChart["items"] }) {
|
||||
const pal = useDashPalette();
|
||||
const PRIMARY = pal.primary;
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const W = 460;
|
||||
const H = 180;
|
||||
@@ -35,7 +36,7 @@ function LineChartSvg({ items }: { items: DashChart["items"] }) {
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-full">
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((k, i) => {
|
||||
const gy = padT + k * (H - padT - padB);
|
||||
return <line key={i} x1={padL} y1={gy} x2={W - padR} y2={gy} stroke="#F1F5F9" strokeWidth="1.2" strokeDasharray="2 2" />;
|
||||
return <line key={i} x1={padL} y1={gy} x2={W - padR} y2={gy} stroke={pal.gridFaint} strokeWidth="1.2" strokeDasharray="2 2" />;
|
||||
})}
|
||||
<polygon
|
||||
points={`${padL},${(H - padB).toFixed(1)} ${pts} ${(W - padR).toFixed(1)},${(H - padB).toFixed(1)}`}
|
||||
@@ -46,19 +47,19 @@ function LineChartSvg({ items }: { items: DashChart["items"] }) {
|
||||
const isH = hover === i;
|
||||
return (
|
||||
<g key={i}>
|
||||
{isH && <line x1={x(i)} y1={padT} x2={x(i)} y2={H - padB} stroke="#E2E8F0" strokeWidth="1" />}
|
||||
{isH && <line x1={x(i)} y1={padT} x2={x(i)} y2={H - padB} stroke={pal.grid} strokeWidth="1" />}
|
||||
<circle
|
||||
cx={x(i)}
|
||||
cy={y(it.value)}
|
||||
r={isH ? 5.5 : 3.5}
|
||||
fill="#fff"
|
||||
fill={pal.pointFill}
|
||||
stroke={PRIMARY}
|
||||
strokeWidth="2"
|
||||
className="cursor-pointer transition-all"
|
||||
onMouseEnter={() => setHover(i)}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
/>
|
||||
<text x={x(i)} y={H - 8} textAnchor="middle" className={`text-[8.5px] font-mono ${isH ? "fill-slate-900" : "fill-slate-400"}`}>
|
||||
<text x={x(i)} y={H - 8} textAnchor="middle" fill={isH ? pal.axisStrong : pal.axisText} className="text-[8.5px] font-mono">
|
||||
{it.label}
|
||||
</text>
|
||||
</g>
|
||||
@@ -84,6 +85,7 @@ function LineChartSvg({ items }: { items: DashChart["items"] }) {
|
||||
|
||||
// ── 环形(占比) ───────────────────────────────────────────────────────────────────
|
||||
function DonutChartSvg({ items }: { items: DashChart["items"] }) {
|
||||
const pal = useDashPalette();
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const data = items.slice(0, 6);
|
||||
const total = data.reduce((s, i) => s + Math.max(0, i.value), 0) || 1;
|
||||
@@ -93,7 +95,7 @@ function DonutChartSvg({ items }: { items: DashChart["items"] }) {
|
||||
let acc = 0;
|
||||
const segs = data.map((it, idx) => {
|
||||
const len = (Math.max(0, it.value) / total) * C;
|
||||
const seg = { len, offset: -acc, color: ACCENTS[idx % ACCENTS.length], idx };
|
||||
const seg = { len, offset: -acc, color: pal.accents[idx % pal.accents.length], idx };
|
||||
acc += len;
|
||||
return seg;
|
||||
});
|
||||
@@ -102,7 +104,7 @@ function DonutChartSvg({ items }: { items: DashChart["items"] }) {
|
||||
<div className="flex flex-row items-center justify-between gap-2.5 h-[180px]">
|
||||
<div className="relative w-36 h-36 flex items-center justify-center shrink-0">
|
||||
<svg viewBox="0 0 140 140" className="w-full h-full select-none">
|
||||
<circle cx="70" cy="70" r={R} fill="none" stroke="#E2E8F0" strokeWidth={SW} transform="rotate(-90 70 70)" />
|
||||
<circle cx="70" cy="70" r={R} fill="none" stroke={pal.grid} strokeWidth={SW} transform="rotate(-90 70 70)" />
|
||||
{segs.map((s) => (
|
||||
<circle
|
||||
key={s.idx}
|
||||
@@ -120,10 +122,10 @@ function DonutChartSvg({ items }: { items: DashChart["items"] }) {
|
||||
onMouseLeave={() => setHover(null)}
|
||||
/>
|
||||
))}
|
||||
<text x="70" y="68" textAnchor="middle" className="fill-slate-800 text-[22px] font-extrabold font-mono">
|
||||
<text x="70" y="68" textAnchor="middle" fill={pal.axisStrong} className="text-[22px] font-extrabold font-mono">
|
||||
{data.length}
|
||||
</text>
|
||||
<text x="70" y="82" textAnchor="middle" className="fill-slate-400 text-[9px]">
|
||||
<text x="70" y="82" textAnchor="middle" fill={pal.axisText} className="text-[9px]">
|
||||
类
|
||||
</text>
|
||||
</svg>
|
||||
@@ -136,12 +138,12 @@ function DonutChartSvg({ items }: { items: DashChart["items"] }) {
|
||||
className={`flex items-center justify-between p-1 rounded-sm cursor-pointer transition-all border-l-[3px] ${
|
||||
hover === idx ? "bg-slate-50 dark:bg-[var(--dash-elev)] font-bold" : "border-transparent"
|
||||
}`}
|
||||
style={{ borderLeftColor: hover === idx ? ACCENTS[idx % ACCENTS.length] : undefined }}
|
||||
style={{ borderLeftColor: hover === idx ? pal.accents[idx % pal.accents.length] : undefined }}
|
||||
onMouseEnter={() => setHover(idx)}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: ACCENTS[idx % ACCENTS.length] }} />
|
||||
<span className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: pal.accents[idx % pal.accents.length] }} />
|
||||
<span className="text-[10px] text-slate-700 dark:text-[var(--dash-text)] truncate">{item.label}</span>
|
||||
</div>
|
||||
<span className="text-[10.5px] font-bold font-mono text-slate-900 dark:text-[var(--dash-text-strong)]">{item.value}</span>
|
||||
@@ -154,6 +156,8 @@ function DonutChartSvg({ items }: { items: DashChart["items"] }) {
|
||||
|
||||
// ── 雷达(多维):标签 HTML 旁注 ─────────────────────────────────────────────────────
|
||||
function RadarChartSvg({ items }: { items: DashChart["items"] }) {
|
||||
const pal = useDashPalette();
|
||||
const PRIMARY = pal.primary;
|
||||
const data = items.slice(0, 8);
|
||||
const n = Math.max(data.length, 3);
|
||||
const cx = 100;
|
||||
@@ -175,13 +179,13 @@ function RadarChartSvg({ items }: { items: DashChart["items"] }) {
|
||||
key={i}
|
||||
points={Array.from({ length: n }, (_, idx) => ptS(idx, R * k)).join(" ")}
|
||||
fill="none"
|
||||
stroke="#E2E8F0"
|
||||
stroke={pal.grid}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: n }, (_, i) => {
|
||||
const [px, py] = pt(i, R);
|
||||
return <line key={i} x1={cx} y1={cy} x2={px.toFixed(1)} y2={py.toFixed(1)} stroke="#E2E8F0" strokeWidth="1" />;
|
||||
return <line key={i} x1={cx} y1={cy} x2={px.toFixed(1)} y2={py.toFixed(1)} stroke={pal.grid} strokeWidth="1" />;
|
||||
})}
|
||||
<polygon
|
||||
points={data.map((it, i) => ptS(i, (Math.max(0, it.value) / max) * R)).join(" ")}
|
||||
@@ -209,6 +213,7 @@ function RadarChartSvg({ items }: { items: DashChart["items"] }) {
|
||||
|
||||
// ── 水平条(排名) ──────────────────────────────────────────────────────────────────
|
||||
function BarChartSvg({ items }: { items: DashChart["items"] }) {
|
||||
const pal = useDashPalette();
|
||||
const data = items.slice(0, 8);
|
||||
const max = Math.max(...data.map((i) => i.value), 1);
|
||||
return (
|
||||
@@ -222,7 +227,7 @@ function BarChartSvg({ items }: { items: DashChart["items"] }) {
|
||||
<div className="w-full h-2 bg-slate-100 dark:bg-[var(--dash-elev)] rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full rounded-full"
|
||||
style={{ background: ACCENTS[i % ACCENTS.length] }}
|
||||
style={{ background: pal.accents[i % pal.accents.length] }}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(it.value / max) * 100}%` }}
|
||||
transition={{ duration: 0.7, ease: "easeOut" }}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useDashboardData } from "../data-context";
|
||||
import { useDashPalette } from "../palette";
|
||||
import { DraftingCard } from "./DraftingCard";
|
||||
import { BrandScore } from "../types";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
@@ -19,6 +20,7 @@ const shareNum = (s?: string) => (s ? parseFloat(s.replace("%", "")) || 0 : 0);
|
||||
|
||||
export function BrandScores({ onBrandSelect }: BrandScoresProps) {
|
||||
const { BRAND_SCORES } = useDashboardData();
|
||||
const pal = useDashPalette();
|
||||
const hasShare = BRAND_SCORES.some((b) => !!b.marketShare);
|
||||
const [hoveredBrand, setHoveredBrand] = useState<string | null>(null);
|
||||
const [selectedBrand, setSelectedBrand] = useState<BrandScore | null>(BRAND_SCORES[0] ?? null);
|
||||
@@ -104,6 +106,8 @@ export function BrandScores({ onBrandSelect }: BrandScoresProps) {
|
||||
<div className="space-y-4">
|
||||
{sortedBrands.map((brand, index) => {
|
||||
const isSelected = selectedBrand?.name === brand.name;
|
||||
// 深色按排名取《深蓝规范》§41 色序;浅色仍是玫瑰红系强调色(视觉不变)。
|
||||
const c = pal.accents[index % pal.accents.length];
|
||||
const currentPercent =
|
||||
effectiveView === "score" ? brand.score : (shareNum(brand.marketShare) / maxShare) * 100;
|
||||
return (
|
||||
@@ -125,7 +129,7 @@ export function BrandScores({ onBrandSelect }: BrandScoresProps) {
|
||||
|
||||
<div
|
||||
className="flex items-center justify-center w-8 h-8 rounded-sm font-extrabold text-xs select-none shrink-0 border"
|
||||
style={{ backgroundColor: `${brand.color}15`, color: brand.color, borderColor: `${brand.color}40` }}
|
||||
style={{ backgroundColor: `${c}15`, color: c, borderColor: `${c}40` }}
|
||||
>
|
||||
{brand.name.substring(0, 1)}
|
||||
</div>
|
||||
@@ -160,7 +164,7 @@ export function BrandScores({ onBrandSelect }: BrandScoresProps) {
|
||||
</div>
|
||||
<motion.div
|
||||
className="h-full rounded-xs relative"
|
||||
style={{ background: `linear-gradient(90deg, ${brand.color}b0 0%, ${brand.color} 100%)` }}
|
||||
style={{ background: `linear-gradient(90deg, ${c}b0 0%, ${c} 100%)` }}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${Math.max(0, Math.min(100, currentPercent))}%` }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
|
||||
@@ -11,7 +11,14 @@ interface ConsensusPanelProps {
|
||||
}
|
||||
|
||||
const POINT_ICONS = [Target, Activity, CheckCircle2, AlertCircle, HelpCircle];
|
||||
const POINT_COLORS = ["text-rose-600", "text-blue-600", "text-emerald-600", "text-amber-600", "text-sky-600"];
|
||||
// 浅色玫瑰红系;深色按《深蓝规范》§41 淡色系列适配,避免高饱和强调色直接落深蓝底。
|
||||
const POINT_COLORS = [
|
||||
"text-rose-600 dark:text-[#FA9196]",
|
||||
"text-blue-600 dark:text-[#80BCFF]",
|
||||
"text-emerald-600 dark:text-[#97EBCD]",
|
||||
"text-amber-600 dark:text-[#FFCC80]",
|
||||
"text-sky-600 dark:text-[#A2E1F9]",
|
||||
];
|
||||
|
||||
export function ConsensusPanel({ onExploreClick }: ConsensusPanelProps) {
|
||||
const { consensus } = useDashboardData();
|
||||
@@ -25,7 +32,7 @@ export function ConsensusPanel({ onExploreClick }: ConsensusPanelProps) {
|
||||
</div>
|
||||
|
||||
{consensus.headline && (
|
||||
<p className="mt-4 text-sm font-semibold text-slate-700 dark:text-[var(--dash-text)] leading-relaxed bg-rose-50/40 dark:bg-[var(--dash-primary-soft)] p-3.5 rounded-sm border-l-2 border-rose-500 dark:border-[var(--dash-primary)]">
|
||||
<p className="dash-accent-l mt-4 text-sm font-semibold text-slate-700 dark:text-[var(--dash-text)] leading-relaxed bg-rose-50/40 dark:bg-[var(--dash-primary-soft)] p-3.5 rounded-sm border-l-2">
|
||||
{consensus.headline}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* 反查谁承接本席位)+ 协同席位推荐。
|
||||
* 参考项目原本是每个固定 tab 写死一套交互沙盘,真实数据下改为通用结构化展示。
|
||||
*/
|
||||
import { useDashboardData, ACCENTS } from "../data-context";
|
||||
import { useDashboardData } from "../data-context";
|
||||
import { useDashPalette } from "../palette";
|
||||
import { TabType, SeatCard } from "../types";
|
||||
import { DraftingCard } from "./DraftingCard";
|
||||
import { SeatShowcase, SHOWCASE_VARIANTS, type ShowcaseVariant } from "./SeatShowcase";
|
||||
@@ -18,16 +19,19 @@ interface DetailViewProps {
|
||||
onNavigate?: (id: TabType) => void;
|
||||
}
|
||||
|
||||
// 浅色语义底;深色按《深蓝规范》语义色(高 #FF4D4F / 中 #FAAD14 / 低 #52C41A)低透叠底,
|
||||
// 文字取同色更亮的浅调保证深蓝底上可读。
|
||||
const RISK_STYLE = {
|
||||
high: "border-red-200 bg-red-50/30 text-red-800",
|
||||
medium: "border-amber-200 bg-amber-50/30 text-amber-800",
|
||||
low: "border-emerald-200 bg-emerald-50/30 text-emerald-800",
|
||||
high: "border-red-200 bg-red-50/30 text-red-800 dark:border-[#FF4D4F]/35 dark:bg-[#FF4D4F]/12 dark:text-[#FFA39E]",
|
||||
medium: "border-amber-200 bg-amber-50/30 text-amber-800 dark:border-[#FAAD14]/35 dark:bg-[#FAAD14]/12 dark:text-[#FFD666]",
|
||||
low: "border-emerald-200 bg-emerald-50/30 text-emerald-800 dark:border-[#52C41A]/35 dark:bg-[#52C41A]/12 dark:text-[#95DE64]",
|
||||
} as const;
|
||||
|
||||
const RISK_LABEL = { high: "高风险阻碍点", medium: "中风险偏离项", low: "低风险关注项" } as const;
|
||||
|
||||
export function DetailView({ activeTab, onBackToOverview, onNavigate }: DetailViewProps) {
|
||||
const { SEAT_CARDS } = useDashboardData();
|
||||
const pal = useDashPalette();
|
||||
const seat = SEAT_CARDS.find((s) => s.id === activeTab);
|
||||
const go = (id: TabType) => (onNavigate ? onNavigate(id) : onBackToOverview?.());
|
||||
|
||||
@@ -36,7 +40,7 @@ export function DetailView({ activeTab, onBackToOverview, onNavigate }: DetailVi
|
||||
}
|
||||
|
||||
const idx = SEAT_CARDS.findIndex((s) => s.id === seat.id);
|
||||
const color = ACCENTS[(idx >= 0 ? idx : 0) % ACCENTS.length];
|
||||
const color = pal.accents[(idx >= 0 ? idx : 0) % pal.accents.length];
|
||||
|
||||
// 主体展示样式:总结席位固定「发布声明」,其余按「非总结席位序号」轮转分配,
|
||||
// 让相邻席位专页换不同款式(框架矩阵 / 洞察卡阵 / 双栏对比 / 清单核对)。
|
||||
@@ -112,7 +116,7 @@ export function DetailView({ activeTab, onBackToOverview, onNavigate }: DetailVi
|
||||
<span className="text-[11px] text-slate-500 dark:text-[var(--dash-text-sec)]">{met.label}</span>
|
||||
<span className="text-2xl font-black font-mono tracking-tight" style={{ color }}>
|
||||
{met.value}
|
||||
<small className="text-xs font-normal text-slate-400 ml-0.5">分</small>
|
||||
<small className="text-xs font-normal text-slate-400 ml-0.5 dark:text-[var(--dash-text-dim)]">分</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-200/70 rounded-full overflow-hidden dark:bg-[var(--dash-border)]">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 改成按序号循环取图标 + 用十六进制色 inline 着色,适配任意数量真实 KPI)。
|
||||
*/
|
||||
import { useDashboardData } from "../data-context";
|
||||
import { useDashPalette } from "../palette";
|
||||
import { DraftingCard } from "./DraftingCard";
|
||||
import { TrendingUp, Users, AlertTriangle, Layers, Award, Calendar } from "lucide-react";
|
||||
|
||||
@@ -14,6 +15,7 @@ const ICONS = [Users, TrendingUp, AlertTriangle, Layers, Award, Calendar];
|
||||
|
||||
export function MetricsHeader({ onCardClick }: MetricsHeaderProps) {
|
||||
const { METRIC_DATA } = useDashboardData();
|
||||
const pal = useDashPalette();
|
||||
if (METRIC_DATA.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -30,7 +32,7 @@ export function MetricsHeader({ onCardClick }: MetricsHeaderProps) {
|
||||
<span className="text-xs text-slate-500 dark:text-[var(--dash-text-sec)] font-sans tracking-wide font-medium">
|
||||
{metric.label}
|
||||
</span>
|
||||
<Icon className="w-5 h-5 shrink-0" style={{ color: metric.color }} />
|
||||
<Icon className="w-5 h-5 shrink-0" style={{ color: pal.accents[idx % pal.accents.length] }} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-baseline gap-1">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useDashboardData } from "../data-context";
|
||||
import { useDashPalette, resolveRiskColor } from "../palette";
|
||||
import { DraftingCard } from "./DraftingCard";
|
||||
import { RiskCategory } from "../types";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
@@ -14,6 +15,7 @@ interface RiskDonutProps {
|
||||
|
||||
export function RiskDonut({ onCategoryHover }: RiskDonutProps) {
|
||||
const { RISK_CATEGORIES, riskTotal } = useDashboardData();
|
||||
const pal = useDashPalette();
|
||||
const [activeCategory, setActiveCategory] = useState<RiskCategory | null>(null);
|
||||
|
||||
const handleMouseEnter = (cat: RiskCategory) => {
|
||||
@@ -57,17 +59,18 @@ export function RiskDonut({ onCategoryHover }: RiskDonutProps) {
|
||||
<div className="space-y-3 flex-1 min-w-0">
|
||||
{RISK_CATEGORIES.map((cat) => {
|
||||
const isSelected = activeCategory?.name === cat.name;
|
||||
const catColor = resolveRiskColor(cat.color, pal);
|
||||
return (
|
||||
<div
|
||||
key={cat.name}
|
||||
className={`flex items-start gap-2.5 p-1.5 rounded-xs transition-colors cursor-pointer ${
|
||||
isSelected ? "bg-slate-50 dark:bg-[var(--dash-elev)] border-l-2" : "border-l-2 border-transparent"
|
||||
}`}
|
||||
style={{ borderLeftColor: isSelected ? cat.color : undefined }}
|
||||
style={{ borderLeftColor: isSelected ? catColor : undefined }}
|
||||
onMouseEnter={() => handleMouseEnter(cat)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="w-2.5 h-2.5 rounded-full mt-1 shrink-0" style={{ backgroundColor: cat.color }} />
|
||||
<div className="w-2.5 h-2.5 rounded-full mt-1 shrink-0" style={{ backgroundColor: catColor }} />
|
||||
<div className="text-left">
|
||||
<div className="flex justify-between items-baseline gap-2">
|
||||
<span className="text-xs font-semibold text-slate-800 dark:text-[var(--dash-text)]">{cat.name}</span>
|
||||
@@ -84,7 +87,7 @@ export function RiskDonut({ onCategoryHover }: RiskDonutProps) {
|
||||
<div className="mt-4 min-h-[90px] bg-slate-50 dark:bg-[var(--dash-elev)] border border-slate-100 dark:border-[var(--dash-border)] rounded-sm p-3 flex flex-col justify-center">
|
||||
{activeCategory ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider block" style={{ color: activeCategory.color }}>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider block" style={{ color: resolveRiskColor(activeCategory.color, pal) }}>
|
||||
{activeCategory.name}包含细项:
|
||||
</span>
|
||||
<ul className="text-[11px] text-slate-600 dark:text-[var(--dash-text-sec)] list-disc list-inside space-y-0.5">
|
||||
|
||||
@@ -36,7 +36,7 @@ export function Roadmap() {
|
||||
return (
|
||||
<DraftingCard
|
||||
key={phase.id}
|
||||
className={`flex flex-col justify-between transition-all duration-300 text-left overflow-hidden group min-h-[160px] border-l-[5px] border-l-rose-600 hover:border-l-rose-700 dark:border-l-[var(--dash-primary)] dark:hover:border-l-[var(--dash-primary)] ${
|
||||
className={`dash-accent-l flex flex-col justify-between transition-all duration-300 text-left overflow-hidden group min-h-[160px] border-l-[5px] ${
|
||||
hasHighlights ? "cursor-pointer" : ""
|
||||
} ${isExpanded ? "bg-slate-50 border-rose-400 shadow-md ring-1 ring-rose-300/40 dark:bg-[var(--dash-elev)] dark:border-[var(--dash-primary)] dark:ring-[var(--dash-primary)]/30" : ""}`}
|
||||
onClick={hasHighlights ? () => togglePhase(phase.id) : undefined}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* 席位交付总览 —— 读 context.SEAT_CARDS。参考按固定 id 配图标/颜色并写死“总结”全宽行;
|
||||
* 这里改成:按序号循环取强调色,isSummary 席位全宽置底(无则全部进网格)。
|
||||
*/
|
||||
import { useDashboardData, ACCENTS } from "../data-context";
|
||||
import { useDashboardData } from "../data-context";
|
||||
import { useDashPalette } from "../palette";
|
||||
import { autoGridColsClass } from "../grid";
|
||||
import { DraftingCard } from "./DraftingCard";
|
||||
import { TabType } from "../types";
|
||||
@@ -15,6 +16,7 @@ interface SeatDeliveryOverviewProps {
|
||||
|
||||
export function SeatDeliveryOverview({ onSeatSelect, activeSeatId }: SeatDeliveryOverviewProps) {
|
||||
const { SEAT_CARDS } = useDashboardData();
|
||||
const pal = useDashPalette();
|
||||
const analystSeats = SEAT_CARDS.filter((c) => !c.isSummary);
|
||||
const summarySeat = SEAT_CARDS.find((c) => c.isSummary);
|
||||
if (SEAT_CARDS.length === 0) return null;
|
||||
@@ -30,7 +32,7 @@ export function SeatDeliveryOverview({ onSeatSelect, activeSeatId }: SeatDeliver
|
||||
|
||||
<div className={`grid grid-cols-1 ${autoGridColsClass(analystSeats.length)} gap-4`}>
|
||||
{analystSeats.map((seat, idx) => {
|
||||
const color = ACCENTS[idx % ACCENTS.length];
|
||||
const color = pal.accents[idx % pal.accents.length];
|
||||
const isActive = activeSeatId === seat.id;
|
||||
return (
|
||||
<DraftingCard
|
||||
@@ -71,7 +73,7 @@ export function SeatDeliveryOverview({ onSeatSelect, activeSeatId }: SeatDeliver
|
||||
<div className="w-full h-1.5 bg-slate-100 dark:bg-[var(--dash-inset)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${met.value}%`, backgroundColor: ACCENTS[i % ACCENTS.length] }}
|
||||
style={{ width: `${met.value}%`, backgroundColor: pal.accents[i % pal.accents.length] }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@ function MatrixShowcase({ groups, color }: { groups: SeatSection[]; color: strin
|
||||
<div className="space-y-5">
|
||||
<DraftingCard hoverEffect={false}>
|
||||
<div className="mb-4 flex items-center gap-1.5 border-b border-rose-100 pb-3.5 text-xs font-bold uppercase tracking-widest text-rose-700 dark:border-[var(--dash-border)] dark:text-[var(--dash-primary)]">
|
||||
<Building2 className="h-4 w-4 text-emerald-600" />
|
||||
<Building2 className="h-4 w-4 text-emerald-600 dark:text-[#97EBCD]" />
|
||||
<span>结构化框架 · 维度拆解</span>
|
||||
</div>
|
||||
<div className={`grid grid-cols-1 gap-3 ${cols}`}>
|
||||
@@ -197,11 +197,13 @@ function CompareShowcase({ groups, color }: { groups: SeatSection[]; color: stri
|
||||
].filter((p) => p.bullets.length > 0);
|
||||
})();
|
||||
|
||||
// 浅色淡彩面板;深色按《深蓝规范》§41 淡色系列叠底(青/蓝/橙/天蓝),标题/圆点取同色,
|
||||
// 避免浅彩底直接搬到深蓝页(即 大屏-3 里发灰的对比面板)。
|
||||
const tints = [
|
||||
{ bg: "bg-emerald-50/40", border: "border-emerald-100", dot: "bg-emerald-500", title: "text-emerald-800" },
|
||||
{ bg: "bg-indigo-50/40", border: "border-indigo-100", dot: "bg-indigo-500", title: "text-indigo-800" },
|
||||
{ bg: "bg-amber-50/40", border: "border-amber-100", dot: "bg-amber-500", title: "text-amber-800" },
|
||||
{ bg: "bg-sky-50/40", border: "border-sky-100", dot: "bg-sky-500", title: "text-sky-800" },
|
||||
{ bg: "bg-emerald-50/40 dark:bg-[#97EBCD]/10", border: "border-emerald-100 dark:border-[#97EBCD]/25", dot: "bg-emerald-500 dark:bg-[#97EBCD]", title: "text-emerald-800 dark:text-[#97EBCD]" },
|
||||
{ bg: "bg-indigo-50/40 dark:bg-[#80BCFF]/10", border: "border-indigo-100 dark:border-[#80BCFF]/25", dot: "bg-indigo-500 dark:bg-[#80BCFF]", title: "text-indigo-800 dark:text-[#80BCFF]" },
|
||||
{ bg: "bg-amber-50/40 dark:bg-[#FFCC80]/10", border: "border-amber-100 dark:border-[#FFCC80]/25", dot: "bg-amber-500 dark:bg-[#FFCC80]", title: "text-amber-800 dark:text-[#FFCC80]" },
|
||||
{ bg: "bg-sky-50/40 dark:bg-[#A2E1F9]/10", border: "border-sky-100 dark:border-[#A2E1F9]/25", dot: "bg-sky-500 dark:bg-[#A2E1F9]", title: "text-sky-800 dark:text-[#A2E1F9]" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -297,7 +299,7 @@ function ReleaseShowcase({ seat, bullets }: { seat: SeatCard; bullets: string[]
|
||||
<div className="grid grid-cols-1 gap-2 text-[11px] text-slate-600 md:grid-cols-2 dark:text-[var(--dash-text-sec)]">
|
||||
{bullets.map((b, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5">
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600 dark:text-[#95DE64]" />
|
||||
<span className="leading-relaxed">{b}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -42,6 +42,22 @@
|
||||
border-color: var(--dash-primary);
|
||||
}
|
||||
|
||||
/* 卡片/段落左侧红色点缀边框:Tailwind v4 里单边色类 `border-l-rose-*` 会被
|
||||
* DraftingCard 的 `border border-slate-200/80`(border-color 简写)整体覆盖而失效
|
||||
* (同 .dash-corner 的成因)。改用未分层专用类直接给 border-left-color/style——
|
||||
* 未分层样式优先级高于 @layer utilities,稳定生效;宽度仍由组件的 border-l-* 控制。 */
|
||||
.dash-accent-l {
|
||||
border-left-style: solid;
|
||||
border-left-color: #e11d48; /* rose-600 玫瑰红点缀 */
|
||||
}
|
||||
.dash-accent-l:hover {
|
||||
border-left-color: #be123c; /* rose-700,悬停加深 */
|
||||
}
|
||||
.dark .dash-root .dash-accent-l,
|
||||
.dark .dash-root .dash-accent-l:hover {
|
||||
border-left-color: var(--dash-primary);
|
||||
}
|
||||
|
||||
/* DraftingCard 的极淡绘图网格 overlay(参考 `bg-grid-slate-100`)。 */
|
||||
.bg-grid-slate-100 {
|
||||
background-image:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 大屏(React 应用渲染)主题感知调色板。
|
||||
*
|
||||
* 浅色:沿用原玫瑰红 + slate 中性(与 HTML 模板 light palette 一致,视觉不变)。
|
||||
* 深色:按《深蓝色 UI 规范 · 04 图表》取色——
|
||||
* - 多系列填充(环形/水平条/排行):§41 淡色系列色序(深蓝底上柔和、区分度高);
|
||||
* - 单系列(折线/雷达):主色蓝 #4096FF;
|
||||
* - 网格/坐标轴/文字:§35 深蓝令牌(虚线网格 #2D4473、轴标签 #6E88B0…);
|
||||
* - 风险语义:高 #FF4D4F / 中 #FAAD14 / 低 #52C41A。
|
||||
*
|
||||
* 数据里 adaptReportData 烘焙的 color 字段只用于浅色;深色一律按本表按序/语义重取,
|
||||
* 解决「浅色强调色直接搬到深蓝底上」的不协调(排行榜、图表、KPI 图标)。
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { ACCENTS } from "./data-context";
|
||||
|
||||
export interface DashPalette {
|
||||
/** 多系列填充色序(环形/条形/排行/KPI 图标)。 */
|
||||
accents: string[];
|
||||
/** 单系列强调色(折线/雷达描边与填充)。 */
|
||||
primary: string;
|
||||
/** 风险语义色(按等级)。 */
|
||||
risk: { high: string; medium: string; low: string };
|
||||
/** 折线图淡虚线网格。 */
|
||||
gridFaint: string;
|
||||
/** 结构参考线:环形底圈 / 雷达环 / 悬停竖线。 */
|
||||
grid: string;
|
||||
/** 坐标轴 / 次要标签文字。 */
|
||||
axisText: string;
|
||||
/** 强调标签 / 中心数字文字。 */
|
||||
axisStrong: string;
|
||||
/** 折线拐点 / 镂空填充背景。 */
|
||||
pointFill: string;
|
||||
}
|
||||
|
||||
const LIGHT: DashPalette = {
|
||||
accents: ACCENTS,
|
||||
primary: ACCENTS[0], // #E11D48
|
||||
risk: { high: "#DC2626", medium: "#D97706", low: "#059669" },
|
||||
gridFaint: "#F1F5F9",
|
||||
grid: "#E2E8F0",
|
||||
axisText: "#94A3B8",
|
||||
axisStrong: "#1E293B",
|
||||
pointFill: "#FFFFFF",
|
||||
};
|
||||
|
||||
const DARK: DashPalette = {
|
||||
// §41 图表色值(六色序):红→橙→绿→青→天蓝→蓝,淡色系深蓝底友好。
|
||||
accents: ["#FA9196", "#FFCC80", "#BAE1B3", "#97EBCD", "#A2E1F9", "#80BCFF"],
|
||||
primary: "#4096FF", // ZS-002-05 单系列折线/雷达
|
||||
risk: { high: "#FF4D4F", medium: "#FAAD14", low: "#52C41A" },
|
||||
gridFaint: "#1A2F5A", // ZS-004-07 弱化网格
|
||||
grid: "#2D4473", // ZS-004-06 主虚线网格 / 结构线
|
||||
axisText: "#6E88B0", // ZS-004-04
|
||||
axisStrong: "#C5CFE0", // ZS-004-02
|
||||
pointFill: "#0C1836", // ZS-004-09 页面底
|
||||
};
|
||||
|
||||
/** adaptReportData 烘焙的浅色风险色(hex)→ 等级,供深色按语义重取。 */
|
||||
const RISK_HEX_TO_LEVEL: Record<string, "high" | "medium" | "low"> = {
|
||||
"#DC2626": "high",
|
||||
"#D97706": "medium",
|
||||
"#059669": "low",
|
||||
};
|
||||
|
||||
function readDark(): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
return document.documentElement.classList.contains("dark");
|
||||
}
|
||||
|
||||
/** 跟随 <html> 的 `.dark` 类(Tailwind dark 变体同源),主题切换实时响应。 */
|
||||
export function useIsDark(): boolean {
|
||||
const [dark, setDark] = useState(readDark);
|
||||
useEffect(() => {
|
||||
const el = document.documentElement;
|
||||
const sync = () => setDark(el.classList.contains("dark"));
|
||||
const obs = new MutationObserver(sync);
|
||||
obs.observe(el, { attributes: true, attributeFilter: ["class"] });
|
||||
sync();
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
return dark;
|
||||
}
|
||||
|
||||
export function useDashPalette(): DashPalette {
|
||||
return useIsDark() ? DARK : LIGHT;
|
||||
}
|
||||
|
||||
/** 风险分类色:浅色用烘焙值,深色按语义换深蓝规范色(无法识别等级则原样返回)。 */
|
||||
export function resolveRiskColor(bakedHex: string, pal: DashPalette): string {
|
||||
if (pal === LIGHT) return bakedHex;
|
||||
const level = RISK_HEX_TO_LEVEL[bakedHex.toUpperCase()];
|
||||
return level ? pal.risk[level] : bakedHex;
|
||||
}
|
||||
@@ -133,6 +133,9 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
const liveThreadRef = useRef<string | null>(null);
|
||||
const agentNameRef = useRef<string>(DASH_AGENT_FALLBACK);
|
||||
const htmlRef = useRef<string | null>(null);
|
||||
// 当前大屏的结构化数据(最近一次成功解析/恢复)——续轮修改时原样喂回模型,
|
||||
// 让它在真实当前状态上做**最小改动**(否则模型凭线程记忆臆测、改标题等局部要求常失效)。
|
||||
const dataRef = useRef<ReportData | null>(null);
|
||||
|
||||
// ── 气泡三段式(append → delta → finalize),对齐 useStep3Summary ──
|
||||
const appendStreamingDialogue = useCallback((threadId?: string): number => {
|
||||
@@ -329,6 +332,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
htmlRef.current = rendered;
|
||||
setHtml(rendered);
|
||||
setData(data); // 同步结构化数据 → 应用内 React 大屏可直接渲染
|
||||
dataRef.current = data; // 记下当前数据,供下一轮修改时喂回模型做最小改动
|
||||
const { messageId, toolCallId } = upsertDashStub(threadId, rendered);
|
||||
openDashboardArtifact(threadId, messageId, toolCallId);
|
||||
|
||||
@@ -402,9 +406,19 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
const bubbleId = appendStreamingDialogue(liveThreadRef.current ?? undefined);
|
||||
setPhase("streaming");
|
||||
try {
|
||||
// 把当前大屏的真实数据原样喂回模型,要求**只改用户点名的部分、其余字段一字不动**——
|
||||
// 否则模型只靠线程记忆臆测当前状态,局部修改(如改标题)经常被整段重写带跑偏。
|
||||
const current = dataRef.current;
|
||||
const currentBlock = current
|
||||
? "\n\n【当前大屏的完整数据(修改基线,未被点名的字段必须原样保留)】\n```report-json\n" +
|
||||
JSON.stringify(current) +
|
||||
"\n```"
|
||||
: "";
|
||||
const prompt =
|
||||
`${text}\n\n(系统指令:请基于上一版数据按用户要求修订,在回复最末尾输出**修订后的完整全量** ` +
|
||||
"```report-json 代码块(不是增量),正文只给一两句修改说明;不要调用任何工具、不要写文件、不要输出 HTML。)";
|
||||
`用户修改要求:${text}${currentBlock}\n\n(系统指令:在上面给出的“当前大屏完整数据”基础上,` +
|
||||
"**只修改用户点名要改的字段**,其余字段(标题/各席位/图表/共识/时间线等)必须逐字保留、不得改写或删减;" +
|
||||
"在回复最末尾输出**修订后的完整全量** ```report-json 代码块(不是增量、必须包含全部字段)," +
|
||||
"正文只给一两句修改说明;不要调用任何工具、不要写文件、不要输出 HTML。)";
|
||||
await streamOnce({ bubbleId, message: prompt, aborter });
|
||||
} catch (err) {
|
||||
if (aborter.signal.aborted) return;
|
||||
@@ -429,6 +443,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
// 否则应用内 React 大屏的 dashboardData memo 会优先用旧 data、不随切换刷新。
|
||||
// (hydrateFromJson 会在调用本函数后再 setData(parsed) 覆盖回正确数据。)
|
||||
setData(null);
|
||||
dataRef.current = null; // 纯 HTML 恢复无结构化基线;hydrateFromJson 随后会写回
|
||||
const synthThreadId = "roundtable-dashboard-restored";
|
||||
liveThreadRef.current = null; // 合成线程非真线程:续问会新建线程重新生成
|
||||
setDashThreadId(synthThreadId);
|
||||
@@ -466,6 +481,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
const rendered = renderDashboardHtml(parsed, { generatedAt: formatNow(), theme: themeMode });
|
||||
hydrateSaved(rendered); // 先恢复 HTML 沙箱路径(内部会 setData(null) 清残留)
|
||||
setData(parsed); // 再写回本草稿结构化数据 → 应用内 React 大屏直接渲染(批量更新,最终生效)
|
||||
dataRef.current = parsed; // 续轮修改的基线 → 从历史恢复后也能精准局部改
|
||||
},
|
||||
[themeMode, hydrateSaved],
|
||||
);
|
||||
|
||||
@@ -750,36 +750,29 @@ export function renderDashboardHtml(
|
||||
.join("")}</ul></div>`,
|
||||
)
|
||||
.join("");
|
||||
// 右栏:席位评定量化得分(大数字"X分",对齐设计图右侧栏)。
|
||||
const scores = s.metrics.length
|
||||
? `<div class="card"><h3>席位评定量化得分</h3><div class="scores">${s.metrics
|
||||
.map(
|
||||
(m) =>
|
||||
`<div class="score"><span class="slbl">${esc(m.label)}</span><div class="sbig" style="color:${color}">${m.value}<small>分</small></div><div class="track"><div class="fill" style="width:${m.value}%;background:${color}"></div></div></div>`,
|
||||
)
|
||||
.join("")}</div></div>`
|
||||
: "";
|
||||
// 风险提示 + 席位依赖关系:移到主内容下方整行展示(去掉原右栏「席位评定量化得分」)。
|
||||
// 两者都有 → 下方两列并排;只剩依赖关系(无风险)→ 依赖关系占满整行,主内容已自适应满宽。
|
||||
const risksCard = s.risks.length
|
||||
? `<div class="card"><h3>风险提示 / 关注点</h3>${riskChips(s.risks)}</div>`
|
||||
: "";
|
||||
const depCard = `<div class="card"><h3>席位依赖关系</h3>${depChips(s, seats) || '<p class="empty">无显式依赖</p>'}</div>`;
|
||||
const bottomCards = [risksCard, depCard].filter(Boolean);
|
||||
const bottom = bottomCards.length
|
||||
? `<div class="dbottom" style="grid-template-columns:${
|
||||
bottomCards.length === 2 ? "1fr 1fr" : "1fr"
|
||||
}">${bottomCards.join("")}</div>`
|
||||
: "";
|
||||
return `<section class="page" id="${esc(s.id)}" style="--ac:${color}">
|
||||
<div class="pbar">
|
||||
<a class="back" href="#overview">← 返回总览</a>
|
||||
<div class="pnav"><a href="#${esc(prev.id)}">‹ ${esc(prev.name)}</a><a href="#${esc(next.id)}">${esc(next.name)} ›</a></div>
|
||||
</div>
|
||||
<header class="phead"><i></i><div><h2>${esc(s.name)} <span class="ptag">SEAT · ${esc(s.id.toUpperCase())}</span></h2><p>${esc(s.summary)}</p></div></header>
|
||||
<div class="dgrid">
|
||||
<div class="dmain">
|
||||
<div class="card"><h3>席位核心交付 · 使命概述</h3><p class="lead">${esc(s.summary || "(无摘要)")}</p></div>
|
||||
<div class="psections">${sections || '<div class="card sect"><p class="empty">(该席位无结构化小节)</p></div>'}</div>
|
||||
</div>
|
||||
<div class="dside">
|
||||
${scores}
|
||||
${risksCard}
|
||||
${depCard}
|
||||
</div>
|
||||
<div class="dmain">
|
||||
<div class="card"><h3>席位核心交付 · 使命概述</h3><p class="lead">${esc(s.summary || "(无摘要)")}</p></div>
|
||||
<div class="psections">${sections || '<div class="card sect"><p class="empty">(该席位无结构化小节)</p></div>'}</div>
|
||||
</div>
|
||||
${bottom}
|
||||
</section>`;
|
||||
})
|
||||
.join("");
|
||||
@@ -948,16 +941,11 @@ ${navActiveCss}
|
||||
.cli .cdot{width:9px;height:9px;border-radius:2px;flex:none}
|
||||
.cli .cnm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.cli b{color:var(--strong);font-family:"JetBrains Mono",Consolas,monospace;font-weight:700}
|
||||
/* ── 席位专页:左右网格 + 卡片纵向间距(修上下无间距) ── */
|
||||
.dgrid{display:grid;grid-template-columns:2fr 1fr;gap:14px;align-items:start}
|
||||
@media(max-width:980px){.dgrid{grid-template-columns:1fr}}
|
||||
.dmain{display:grid;gap:14px;align-content:start}
|
||||
.dside{display:grid;gap:14px;align-content:start}
|
||||
/* ── 席位专页:主内容满宽自适应 + 下方风险/依赖整行(两列;只剩依赖则占满) ── */
|
||||
.dmain{display:grid;gap:14px;align-content:start;margin-bottom:14px}
|
||||
.dbottom{display:grid;gap:14px;align-items:stretch}
|
||||
@media(max-width:980px){.dbottom{grid-template-columns:1fr !important}}
|
||||
.lead{font-size:12.5px;color:var(--txt);line-height:1.7;margin:0}
|
||||
.scores{display:grid;gap:16px}
|
||||
.score .slbl{font-size:11px;color:var(--dim);display:block;margin-bottom:6px}
|
||||
.sbig{font-size:30px;font-weight:800;font-family:"JetBrains Mono",Consolas,monospace;line-height:1;margin-bottom:8px}
|
||||
.sbig small{font-size:12px;font-weight:500;color:var(--dim);margin-left:2px}
|
||||
.empty{font-size:12px;color:var(--dim)}
|
||||
</style></head>
|
||||
<body>
|
||||
|
||||
@@ -977,8 +977,12 @@ export default function RoundtablePlanningPage() {
|
||||
// 大屏/报告就留着上一条记录的数据不刷新 —— 即本次修复的 bug 根因)。
|
||||
const prevDraftIdRef = useRef(drafts.currentDraftId);
|
||||
if (prevDraftIdRef.current !== drafts.currentDraftId) {
|
||||
const wasRealDraft = prevDraftIdRef.current != null;
|
||||
prevDraftIdRef.current = drafts.currentDraftId;
|
||||
step3EnteredRef.current = false;
|
||||
// 仅「真正切到另一份草稿」才复位入口闸门重新 hydrate。草稿**首次创建**(null→id,
|
||||
// 多由本会话第一次自动保存触发)不算切换——若这时复位,正在并行跑的 Step3 三流(尤其
|
||||
// 大屏)会被下方 hydrate 当成「未启动」重新 start(),既丢了在途产物又反复重画。
|
||||
if (wasRealDraft) step3EnteredRef.current = false;
|
||||
}
|
||||
|
||||
// 三流 hydrate/start(按当前草稿快照恢复或新建)。进第三步、切草稿后停留第三步都复用它,
|
||||
@@ -1013,10 +1017,17 @@ export default function RoundtablePlanningPage() {
|
||||
|
||||
// 切换草稿:先停掉旧草稿的在途 Step3 流(防串台)。声明在 hydrate effect **之前**,
|
||||
// 保证同一提交内先 abort 旧流、再由下方 effect hydrate 新草稿,顺序不颠倒。
|
||||
// **仅在真正切到另一份草稿时中断**——草稿首次创建(null→id)不是切换,那时三流可能正
|
||||
// 跑着(尤其大屏绘制),误 abort 会让产物没存上、随后还被重新 hydrate 重画。
|
||||
const abortPrevDraftIdRef = useRef(drafts.currentDraftId);
|
||||
useEffect(() => {
|
||||
summaryFlow.abort();
|
||||
reportFlow.abort();
|
||||
dashboardFlow.abort();
|
||||
const prev = abortPrevDraftIdRef.current;
|
||||
abortPrevDraftIdRef.current = drafts.currentDraftId;
|
||||
if (prev && drafts.currentDraftId && prev !== drafts.currentDraftId) {
|
||||
summaryFlow.abort();
|
||||
reportFlow.abort();
|
||||
dashboardFlow.abort();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [drafts.currentDraftId]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Bell, ChevronDown, FileText, Moon, Palette, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Bell, ChevronDown, FileText, Shirt, User } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -12,7 +12,7 @@ import type { MenuItem } from "@/core/page-layout/sidebar-menu";
|
||||
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
import { useTheme, type ThemeType } from "../contexts/ThemeContext";
|
||||
|
||||
const iconButtonClass = (isDark: boolean) =>
|
||||
cn(
|
||||
@@ -22,12 +22,99 @@ const iconButtonClass = (isDark: boolean) =>
|
||||
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
|
||||
);
|
||||
|
||||
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark)
|
||||
const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
|
||||
{ id: "light", label: "红色", color: "#cf1322" },
|
||||
{ id: "lightblue", label: "蓝色", color: "#0062d9" },
|
||||
{ id: "dark", label: "深蓝色", color: "#002466" },
|
||||
];
|
||||
|
||||
function ThemeSwitcher({ isDark }: { isDark: boolean }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelClose = () => {
|
||||
if (closeTimer.current) {
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
};
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
closeTimer.current = setTimeout(() => setOpen(false), 120);
|
||||
};
|
||||
|
||||
useEffect(() => () => cancelClose(), []);
|
||||
|
||||
// 切换按钮:悬浮只改背景,不改图标颜色(显式锁定 hover 文字色等于常态色)
|
||||
const triggerClass = cn(
|
||||
"size-8 shrink-0 rounded-sm",
|
||||
isDark
|
||||
? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400"
|
||||
: "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
}}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={triggerClass}
|
||||
title="换肤"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Shirt className="size-4" />
|
||||
</Button>
|
||||
{open ? (
|
||||
<div className="absolute top-full left-1/2 z-50 -translate-x-1/2 pt-2">
|
||||
<div className="relative flex items-center gap-2 rounded-lg border border-black/5 bg-white px-2.5 py-1.5 shadow-lg">
|
||||
<div className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 rounded-[1px] border-t border-l border-black/5 bg-white" />
|
||||
{THEME_SWATCHES.map((swatch) => {
|
||||
const active = theme === swatch.id;
|
||||
return (
|
||||
<button
|
||||
key={swatch.id}
|
||||
type="button"
|
||||
title={swatch.label}
|
||||
aria-label={swatch.label}
|
||||
aria-pressed={active}
|
||||
onClick={() => {
|
||||
setTheme(swatch.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="size-5 rounded-full transition-transform hover:scale-110"
|
||||
style={{
|
||||
backgroundColor: swatch.color,
|
||||
boxShadow: active
|
||||
? `0 0 0 1.5px #ffffff, 0 0 0 3px ${swatch.color}`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HeaderProps = {
|
||||
collapsed?: boolean;
|
||||
};
|
||||
|
||||
const Header = ({ collapsed }: HeaderProps) => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
@@ -125,20 +212,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={toggleTheme}
|
||||
className={iconButtonClass(isDark)}
|
||||
title={isDark ? "切换到红白主题" : "切换到深色主题"}
|
||||
>
|
||||
{isDark ? (
|
||||
<Palette className="size-4" />
|
||||
) : (
|
||||
<Moon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<ThemeSwitcher isDark={isDark} />
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -51,13 +51,14 @@ const THEME_KEY_ALIASES: Record<AppThemeColor, string[]> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取我方当前主题色,以 ThemeContext 写在 <html> 上的 `dark` class 为准
|
||||
* (含嵌入场景的强制主题)。当前:深色=dark-blue,浅色=red。
|
||||
* TODO: 后续新增 blue 主题后,在此区分 blue 与 dark-blue。
|
||||
* 读取我方当前主题色,以 ThemeContext / next-themes 写在 <html> 上的 class 为准
|
||||
* (含嵌入场景的强制主题)。深色=dark-blue,浅蓝=blue,其余(浅色/红白)=red。
|
||||
*/
|
||||
export function getCurrentTheme(): AppThemeColor {
|
||||
if (typeof document !== "undefined" && document.documentElement.classList.contains("dark")) {
|
||||
return "dark-blue";
|
||||
if (typeof document !== "undefined") {
|
||||
const classList = document.documentElement.classList;
|
||||
if (classList.contains("dark")) return "dark-blue";
|
||||
if (classList.contains("lightblue")) return "blue";
|
||||
}
|
||||
return "red";
|
||||
}
|
||||
|
||||
@@ -317,6 +317,49 @@
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* 浅蓝色主题 —— 详见 docs/ui规范-浅蓝/浅蓝色ui规范.md。
|
||||
第三套主题,由 next-themes 在 <html> 加 `.lightblue` 类触发。
|
||||
只覆盖颜色 token(圆角/间距沿用 :root),实现整站换肤;
|
||||
主色 #0062D9(ZS-002 深色基准),浅底 #EAF4FF,正文 #3D3D3D。 */
|
||||
.lightblue {
|
||||
--workspace-brand: #0062d9;
|
||||
--workspace-brand-foreground: #ffffff;
|
||||
--brand-200: #eaf4ff;
|
||||
--brand-900: #0062d9;
|
||||
--brand-950: #004ab3;
|
||||
--background: #ffffff;
|
||||
--foreground: #3d3d3d;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #3d3d3d;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #3d3d3d;
|
||||
--primary: #0062d9;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #f4f9ff;
|
||||
--secondary-foreground: #0056c6;
|
||||
--muted: #f3f5f8;
|
||||
--muted-foreground: #7f7f7f;
|
||||
--accent: #eaf4ff;
|
||||
--accent-foreground: #0062d9;
|
||||
--destructive: #f5222d;
|
||||
--border: #dbdbdb;
|
||||
--input: #dbdbdb;
|
||||
--ring: rgb(0 98 217 / 0.35);
|
||||
--chart-1: #0062d9;
|
||||
--chart-2: #1eb07f;
|
||||
--chart-3: #ff9900;
|
||||
--chart-4: #2f9acc;
|
||||
--chart-5: #f5222d;
|
||||
--sidebar: #f9fcff;
|
||||
--sidebar-foreground: #3d3d3d;
|
||||
--sidebar-primary: #0062d9;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #eaf4ff;
|
||||
--sidebar-accent-foreground: #0056c6;
|
||||
--sidebar-border: #e6ebf2;
|
||||
--sidebar-ring: rgb(0 98 217 / 0.4);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@@ -883,3 +926,40 @@ body {
|
||||
html.dark .strategy-app-header {
|
||||
background-image: url("../assets/img/dark-blue-header.svg");
|
||||
}
|
||||
|
||||
/* ── 浅蓝主题:覆盖散落在各处的红色硬编码(CSS 变量管不到的地方)────────── */
|
||||
html.lightblue .strategy-app-header {
|
||||
background-image: url("../assets/img/blue-header.png");
|
||||
}
|
||||
|
||||
html.lightblue *::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgb(85 166 254 / 0.82), rgb(0 98 217 / 0.9))
|
||||
padding-box;
|
||||
}
|
||||
|
||||
html.lightblue .workspace-panel {
|
||||
border: 1px solid #d6e4f5;
|
||||
box-shadow:
|
||||
0 32px 72px -46px rgb(42 70 110 / 0.35),
|
||||
0 12px 28px -22px rgb(75 120 180 / 0.24);
|
||||
}
|
||||
|
||||
html.lightblue .workspace-user-bubble {
|
||||
border: 1px solid rgb(255 255 255 / 0.14);
|
||||
background: linear-gradient(135deg, #55a6fe 0%, #0079fe 58%, #0062d9 100%);
|
||||
box-shadow:
|
||||
0 22px 40px -28px rgb(0 98 217 / 0.55),
|
||||
0 10px 20px -16px rgb(0 54 140 / 0.42);
|
||||
}
|
||||
|
||||
html.lightblue .workspace-assistant-card {
|
||||
border: 1px solid #d9e6f5;
|
||||
}
|
||||
|
||||
html.lightblue .workspace-assistant-card::before {
|
||||
background: linear-gradient(180deg, rgb(85 166 254 / 0.92), rgb(0 121 254 / 0.15));
|
||||
}
|
||||
|
||||
html.lightblue .tiptap-writing-editor .ProseMirror ::selection {
|
||||
background: color-mix(in srgb, #55a6fe 35%, transparent);
|
||||
}
|
||||
@@ -308,13 +308,13 @@ async def update_publish_squares(body: PublishSquaresUpdateRequest, request: Req
|
||||
class AppearanceResponse(BaseModel):
|
||||
"""Effective system-wide appearance defaults."""
|
||||
|
||||
default_theme: Literal["light", "dark"] = Field(..., description="System default theme — light or dark")
|
||||
default_theme: Literal["light", "dark", "lightblue"] = Field(..., description="System default theme — light, dark or lightblue")
|
||||
default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list")
|
||||
default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list")
|
||||
|
||||
|
||||
class AppearanceUpdateRequest(BaseModel):
|
||||
default_theme: Literal["light", "dark"] | None = Field(default=None)
|
||||
default_theme: Literal["light", "dark", "lightblue"] | None = Field(default=None)
|
||||
default_skill_view: Literal["card", "list"] | None = Field(default=None)
|
||||
default_agent_view: Literal["card", "list"] | None = Field(default=None)
|
||||
|
||||
|
||||
+2
-2
@@ -47,9 +47,9 @@ class AppearanceSettings(BaseModel):
|
||||
toggle, and any future first-time loads.
|
||||
"""
|
||||
|
||||
default_theme: Literal["light", "dark"] = Field(
|
||||
default_theme: Literal["light", "dark", "lightblue"] = Field(
|
||||
default="light",
|
||||
description="Theme applied for users who haven't picked their own (light / dark)",
|
||||
description="Theme applied for users who haven't picked their own (light / dark / lightblue)",
|
||||
)
|
||||
default_skill_view: Literal["card", "list"] = Field(
|
||||
default="list",
|
||||
|
||||
Reference in new issue
Block a user