Merge branch 'dhk-1' into 'master'

feat(圆桌): 业务链条配置页流程图点阵放大 + 侧边栏菜单项按设计稿还原

See merge request knowledge-base-bj/deerflow-server!119
This commit is contained in:
董宏凯 committed 2026-06-14 11:31:50 +00:00
commit 7785091bae
76 files changed
+4452 -2059

No files matched your search

@@ -0,0 +1,198 @@
# 后台挂起「在当前进度继续」实现方案
> 状态:**待实现**(仅方案,未改代码)
> 关联:[[multi-agent-background-run-dev.md]]、[[multi-agent-frontend-dev.md]]、memory `project-roundtable-background`
> 目标:点「后台挂起」后,后端作业**在前端当前研讨进度上接着跑**,而不是新建空线程从头重跑。
---
## 1. 现象与根因
### 现象
在第二步研讨进行中点「后台挂起」,转入后台的作业是**从头重新开始执行**(重新初始化席位、从原始 intent 重新派活),而不是接着前端已经讨论到的进度往下走。
### 根因
后端「起作业」路由 `start_job` **从不设 `is_resume`**(默认 `False`),而执行器据此**新建一套全新线程**:
```python
# offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py (_run, 约 215 行)
init = getattr(gateway, "init_threads", None)
if init is not None and not params.is_resume:
thread_ids, coordinator_name = await init() # ← 建全新空线程
```
加上前端 `startBackgroundJob` **故意不传 `threadIds`**、用 `buildInitialIntent()` 作第一轮 leader prompt,于是后台作业 = 空线程 + 原始意图 = 从头重跑。
> 这是**原设计的有意取舍**。`startBackgroundJob` 注释写明:不复用前端线程,因为「那些线程可能有活跃 run,复用会撞 409」。本方案就是要在可控前提下打开「复用前端线程续跑」这条路。
### 后端其实已支持续跑
澄清续跑路由 `resume_job`(`roundtable_jobs.py` 约 293 行)已经在做「在原线程上接着派活」:
```python
executor.start_job(StartParams(
...,
seed_message=_RESUME_PREFIX + answer, # 续跑式提示,而非原始 intent
thread_ids=row.get("thread_ids"), # 复用作业自己的线程
coordinator_name=row.get("coordinator_name") or "roundtable-coordinator",
is_resume=True, # ← 关键:跳过 init_threads,直接复用
...
))
```
执行器在 `is_resume=True` 时直接用 `params.thread_ids` + `params.coordinator_name`(不调 `init_threads`),交给 `run_orchestration` 续跑。**所以能力已存在,`start_job` 只是没走这条路。**
---
## 2. 设计目标与关键事实
- 「同进度续跑」= 后台作业复用**前端当前会话的真实线程**(总控 + 各席位),这些线程的 checkpoint 里已存着到目前为止的完整研讨上下文,续跑 = 在原线程上 post 一条「继续」消息让总控接着派活。
- 前端编排是**逐轮**的:每轮一个 `POST /api/multi-agent/run/stream`(leader 或某席位),**轮与轮之间线程空闲**。只有「某一轮在飞时」线程才被占用。
- 后端复用现成线程是**已验证路径**(`resume_job` 天天在用),唯一新增的不同点是:这次复用的是「前端会话线程」而非「上一个后台作业的线程」——但线程同属一个后端线程库,复用方式完全一致。
---
## 3. 改造点(最小集)
### 3.1 后端:让 `start_job` 支持复用线程
文件:`offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py`
1. `StartJobRequest` 增加字段:
```python
resume: bool = False # true + 携带 threadIds ⇒ 复用这些线程续跑,不新建
```
2. `start_job` 里把它透传给 `StartParams`:
```python
is_resume = bool(body.resume and body.threadIds)
executor.start_job(StartParams(
...,
thread_ids=body.threadIds,
coordinator_name=body.coordinatorName,
is_resume=is_resume, # ← 新增
...
))
```
其余执行器逻辑**不动**(它在 `is_resume=True` 时已能复用 `params.thread_ids`)。
> 幂等闸门(同草稿未终态作业直接返回)保持不变;续跑挂起前前端应保证该草稿没有在跑的旧作业。
### 3.2 前端 API:补 `resume` 入参
文件:`frontend-web/src/roundtable-planning/lib/job-types.ts`
- `StartJobPayload` 增加 `resume?: boolean`。
文件:`frontend-web/src/roundtable-planning/api/roundtable-jobs.ts`
- `toStartBody` 增加 `resume: p.resume ?? false`。
### 3.3 前端:`startBackgroundJob` 改为复用线程续跑
文件:`frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(`startBackgroundJob` 约 1119 行)
当前端已有现成会话(`step2.threadIds` 且 `step2.coordinatorName` 非空,说明研讨已 init)时,改传:
```ts
const canContinue = !!(step2.threadIds && step2.coordinatorName);
await startJob({
draftId: draftId ?? undefined,
// 续跑:用「继续」式 seed,而不是 buildInitialIntent()(后者会让总控从头理解任务)
intent: buildInitialIntent(), // 仍传,作为无线程回退时的首轮 prompt
seedMessage: canContinue
? "用户已将本次研讨转入后台,请基于现有进展继续上一轮的协调与派活;若已可收口则直接综合输出结论。"
: undefined,
agents,
model: selectedModel || undefined,
orchestrationMode,
chain: chainMeta,
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
// 关键三件套:复用前端真实线程 + 协调名 + resume 标志
threadIds: canContinue ? step2.threadIds ?? undefined : undefined,
coordinatorName: canContinue ? step2.coordinatorName ?? undefined : undefined,
resume: canContinue,
});
```
> 没有现成会话(极早期、线程未建)时 `canContinue=false`,自动回退到「新建线程 + 原 intent」的旧行为,安全。
### 3.4 前端:挂起前先停本地编排(避免交出去时线程还在飞)
文件:同上,`handleBackgroundSuspend`(约 1161 行)
当前顺序是 `startBackgroundJob()` → `resetToStep1()`(reset 里才 `stopOrchestration`)。复用线程时必须**先停再交**:
```ts
const handleBackgroundSuspend = useCallback(async () => {
if (step2.selectedAgents.length === 0) { /* toast 提示后 return */ }
// 1. 先彻底停掉前端在飞的那一轮,尽量让线程在交给后台前变空闲
step2.stopOrchestration();
step2.setIsRoundTableRunning(false);
try {
// 2. 再起后台作业(复用线程续跑)
await startBackgroundJob();
} catch (err) { /* toast 失败后 return */ }
// 3. 最后重置回第一步(后台作业继续在历史记录里跑)
resetToStep1();
triggerToast("🚀 已转入后台,将基于当前进度继续研讨…");
void drafts.refresh();
}, [...]);
```
---
## 4. 关键风险:线程占用(409)与三种处理
复用前端线程的唯一风险:**在某一轮在飞时挂起**,`stopOrchestration()` 只断了客户端 SSE,服务端那次 run 可能还在跑一小会儿;后台作业首轮 leader 向同一总控线程 post → 撞 409 / rollback。
| 方案 | 做法 | 取舍 |
|---|---|---|
| **A(推荐起步)** | 仅前端 stop + 后端 `is_resume` 复用,接受小概率 409 | 实现最简,真正同进度续跑;轮间挂起 100% 安全,仅「某轮在飞时挂起」有小概率 409 —— 走编排循环**现有 rollback/重试容错**兜底 |
| **B(最稳)** | 同 A,但后端在复用线程续跑前,**先 cancel 这些线程上可能残留的 run**(参考 takeover 里 `cancelJob` 的 `task.cancel()` 思路,落到 thread-run 级别)再 post | 几乎无 409;后端要多写「按 thread_id 取消活跃 run」的逻辑 |
| **C(不推荐)** | 不复用线程,新建空线程,但把「目前为止的研讨」塞进 seed_message 让总控续写 | 零 409,但**席位子线程上下文全丢**、不是真正同进度、leader prompt 臃肿 |
> 建议:先上 **A**,真机验证 409 命中率;若频繁再加 **B** 的后端线程级 cancel。
---
## 5. 适用范围(哪些挂起入口改续跑)
三个触发源(见 `autoSuspendRef`):
1. **手动「后台挂起」按钮**(`handleBackgroundSuspend`)——**改续跑**。能在 start 前干净 stop,最适合复用线程。
2. **路由卸载自动挂起**(切到其它页面,`fire`)——可改续跑(能走完整异步链);卸载瞬间 stop 不一定彻底,按 A 方案的容错兜底。
3. **刷新/关标签页 keepalive**(`beforeunload` 的 `beacon` → `startJobKeepalive`)——**建议保持新建线程**。关页时无法保证前端 run 已停,复用线程 409 风险最高;这里走「新建线程 + 原 intent」的安全回退即可(用户可在历史记录里点「查看」再用我们已实现的 takeover 接管回前端续跑)。
> 落地顺序建议:先只改 #1,验证稳定后再决定是否推广到 #2。
---
## 6. 与已实现「查看=接管回前端」的关系
二者是一对对称操作,复用同一套「线程同属一个后端库、可跨前后端续跑」的事实:
- **后台挂起(本方案)**:前端 → 后端。前端 stop,把前端线程交给后台 `is_resume` 续跑。
- **查看接管(已实现,见 memory)**:后端 → 前端。`cancelJob` 停后台,`hydrateFromDraft` 恢复作业真实线程,`resumeLiveTakeover()` 由前端在那些线程上续跑。
实现本方案时可直接参照 takeover 的线程复用与 `is_resume` 经验。
---
## 7. 测试
后端(`make test`):
- 新增/扩展 `tests/test_roundtable_jobs.py`:`start_job` 带 `resume=true + threadIds` ⇒ `StartParams.is_resume=True` 且**不调** `init_threads`、直接用传入线程;`resume=false` 或无 `threadIds` ⇒ 走 `init_threads` 新建(回归旧行为)。
- 回归:`tests/test_roundtable_inprocess_request.py`、`tests/test_roundtable_step3_pipeline.py`(确认续跑不破坏 Step3 双产物)。
前端(`pnpm typecheck`)+ 真机:
- 研讨进行到几轮后点「后台挂起」→ 历史记录里点「查看」→ 确认后台是**接着已有对话**往下派活,而非从第 1 轮重来。
- 轮间挂起 / 某轮在飞时挂起两种时机各验一次,观察是否出现 409(A 方案下应被 rollback 容错吸收)。
---
## 8. 落地清单(TL;DR)
- [ ] 后端 `StartJobRequest.resume` 字段 + `start_job` 透传 `is_resume`
- [ ] 前端 `StartJobPayload.resume` + `toStartBody`
- [ ] 前端 `startBackgroundJob`:有会话时传 `threadIds/coordinatorName/seedMessage/resume`
- [ ] 前端 `handleBackgroundSuspend`:调整为 **先 stop 再 start 再 reset**
- [ ] 后端测试(resume 复用 vs 新建分支)
- [ ] 真机验证「接着进度跑」+ 409 命中率
- [ ](可选)方案 B:后端复用线程前按 thread_id 取消残留 run
@@ -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>
```
+1
View File
@@ -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}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="1080" viewBox="0 0 1920 1080"><image width="1920" height="1080" xlink:href="data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAA8AAD/4QMwaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA5LjEtYzAwMSA3OS4xNDYyODk5Nzc3LCAyMDIzLzA2LzI1LTIzOjU3OjE0ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgMjUuMCAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OUVERkMwNjI0Q0RCMTFGMDg5MTZCMkY2RDg1RkVEQkUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6OUVERkMwNjM0Q0RCMTFGMDg5MTZCMkY2RDg1RkVEQkUiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo5RURGQzA2MDRDREIxMUYwODkxNkIyRjZEODVGRURCRSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo5RURGQzA2MTRDREIxMUYwODkxNkIyRjZEODVGRURCRSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pv/uAA5BZG9iZQBkwAAAAAH/2wCEAAYEBAQFBAYFBQYJBgUG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 truncated

After

Width:  |  Height:  |  Size: 431 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="152" height="42.359" viewBox="0 0 152 42.359"><defs><style>.a{fill:url(#a);}</style><pattern id="a" preserveAspectRatio="none" width="100%" height="100%" viewBox="0 0 536 150"><image width="536" height="150" xlink:href="data:image/png;base64,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 truncated

After

Width:  |  Height:  |  Size: 33 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="64" viewBox="0 0 1920 64"><defs><style>.a{fill:url(#a);}.b{fill:url(#b);}.c{clip-path:url(#c);}.d{fill:url(#d);}</style><linearGradient id="a" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#131f3d"/><stop offset="1" stop-color="#094593"/></linearGradient><linearGradient id="b" x1="0.473" x2="0.473" y2="0.95" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#131f3d"/><stop offset="1" stop-color="#094593" stop-opacity="0"/></linearGradient><clipPath id="c"><rect class="a" width="1920" height="64" transform="translate(-2 5)"/></clipPath><pattern id="d" preserveAspectRatio="xMidYMid slice" width="100%" height="100%" viewBox="0 0 1917 104"><image width="1917" height="104" xlink:href="data:image/png;base64,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 truncated

After

Width:  |  Height:  |  Size: 98 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="30" viewBox="0 0 32 30"><defs><style>.a{fill:rgba(255,255,255,0.05);}.b{fill:url(#a);}</style><linearGradient id="a" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#a2e1f9"/><stop offset="1" stop-color="#45c3f3"/></linearGradient></defs><g transform="translate(1.411 -9.285)"><rect class="a" width="32" height="30" rx="4" transform="translate(-1.411 9.285)"/><path class="b" d="M4618.588,4844.6a1.257,1.257,0,1,1,1.258,1.256A1.258,1.258,0,0,1,4618.588,4844.6Zm4.609.838v-1.676h12.569v1.676Zm-4.609-5.867a1.257,1.257,0,1,1,1.258,1.257A1.257,1.257,0,0,1,4618.588,4839.569Zm4.609.838v-1.676h12.569v1.676Zm-4.609-5.865a1.257,1.257,0,1,1,1.258,1.257A1.257,1.257,0,0,1,4618.588,4834.542Zm4.609.838V4833.7h12.569v1.676Z" transform="translate(-4613 -4815)"/></g></svg>

After

Width:  |  Height:  |  Size: 892 B

+23 -33
View File
@@ -379,6 +379,13 @@
overflow-y: hidden;
}
/* 左右分组:不撑满、不裁切选中高光 */
.top-submenu-bar--group {
flex: 0 0 auto;
margin-left: 0;
overflow: visible;
}
.top-submenu-list {
min-width: max-content;
height: 64px;
@@ -398,48 +405,31 @@
border: 0;
padding: 0 18px;
background: transparent;
color: rgba(224, 242, 255, 0.9);
color: rgba(255, 255, 255, 0.8);
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif;
font-size: 16px;
font-size: 18px;
line-height: 1;
font-weight: 700;
letter-spacing: 0;
white-space: nowrap;
cursor: pointer;
overflow: hidden;
transition: color 0.18s ease, text-shadow 0.18s ease, background 0.18s ease;
overflow: visible;
transition: color 0.18s ease, text-shadow 0.18s ease;
}
.top-submenu-item:hover,
.top-submenu-item:hover {
color: #9fe3ff;
text-shadow: 0 0 10px rgba(91, 210, 255, 0.4);
}
/* 选中态:设计稿提供的 nav-active.svg 发光高光 + 青色文字 */
.top-submenu-item--active {
color: #ffffff;
text-shadow: 0 0 10px rgba(91, 210, 255, 0.45);
background:
linear-gradient(135deg, rgba(15, 84, 169, 0.52) 0%, rgba(5, 43, 103, 0.34) 76%),
linear-gradient(180deg, rgba(39, 126, 230, 0.2) 0%, rgba(0, 50, 122, 0.26) 100%);
box-shadow: inset 0 1px 0 rgba(98, 180, 255, 0.16), inset 0 -18px 22px rgba(0, 31, 79, 0.24);
}
.top-submenu-item--active::before {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: linear-gradient(90deg, rgba(59, 162, 255, 0) 0%, #35d7ff 50%, rgba(59, 162, 255, 0) 100%);
box-shadow: 0 0 12px rgba(53, 215, 255, 0.9), 0 -6px 18px rgba(33, 143, 255, 0.36);
}
.top-submenu-item--active::after {
content: '';
position: absolute;
left: 16px;
right: 16px;
bottom: 0;
height: 16px;
background: radial-gradient(ellipse at bottom, rgba(37, 189, 255, 0.34) 0%, rgba(37, 189, 255, 0) 68%);
pointer-events: none;
color: #64cdf5;
text-shadow: 0 0 12px rgba(100, 205, 245, 0.5);
background-image: url("../assets/img/nav-active.svg");
background-repeat: no-repeat;
background-position: center calc(50% + 7px);
background-size: 152px 42px;
}
.third-submenu-bar {
+11 -4
View File
@@ -77,8 +77,8 @@ export function PageSidebar({
compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100"
} ${small ? "text-xs" : "text-sm"} flex-1 font-medium`;
const menuButtonClass = (isActive: boolean, depth = 0) =>
`flex w-full items-center rounded-lg py-2 text-left transition-all duration-300 ease-out hover:-translate-y-0.5 theme-transition ${
collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3"
`group relative flex w-full items-center rounded-[2px] py-2 text-left transition-all duration-300 ease-out theme-transition ${
collapsed && depth === 0 ? "justify-center px-2" : "gap-3 px-3"
} ${
isActive
? isDark
@@ -114,7 +114,14 @@ export function PageSidebar({
onClick={() => item.path && navigate(item.path)}
className={menuButtonClass(isActive, depth)}
>
<Icon className={`${depth === 0 ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out`} />
{isActive && (
<span
className={`absolute left-0 inset-y-0 w-[3px] rounded-r ${
isDark ? "bg-cyan-300" : "bg-red-600"
}`}
/>
)}
<Icon className={`${depth === 0 ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105`} />
{(!collapsed || depth > 0) && (
<span className={labelClass(false, depth > 0)}>
{item.label}
@@ -144,7 +151,7 @@ export function PageSidebar({
className={menuButtonClass(false, depth)}
>
<Icon
className={`${isTopLevel ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out ${
className={`${isTopLevel ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105 ${
expanded && depth > 0 ? "scale-105" : ""
}`}
/>
@@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import { getStoredAuth } from "@/core/auth";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
@@ -29,6 +30,8 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
export function AgentGallery() {
const { t } = useI18n();
const router = useRouter();
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
const isAdmin = getStoredAuth()?.system_role === "admin";
const [viewMode, setViewMode] = useViewMode("agent");
const [activeTab, setActiveTab] = useState<string>("mine");
const [search, setSearch] = useState("");
@@ -58,15 +61,21 @@ export function AgentGallery() {
// If the active square tab disappears (admin removed that square), fall back
// to "我的" so the gallery never points at a dead tab.
useEffect(() => {
// 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。
if (activeTab === "builtin" && !isAdmin) {
setActiveTab("mine");
return;
}
if (
activeTab !== "mine" &&
activeTab !== "private" &&
activeTab !== "builtin" &&
squareTabs.length > 0 &&
!squareTabs.some((sq) => sq.id === activeTab)
) {
setActiveTab("mine");
}
}, [activeTab, squareTabs]);
}, [activeTab, squareTabs, isAdmin]);
const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled;
@@ -80,9 +89,16 @@ export function AgentGallery() {
const [passwordInput, setPasswordInput] = useState("");
// Only the active tab fetches. "广场" maps to the server "square" scope
// (built-in/published, never private), "我的" to "mine" (owned + favorited).
const isSquareTab = activeTab !== "mine" && activeTab !== "private";
const mainScope = activeTab === "mine" ? "mine" : "square";
// (built-in/published, never private), "我的" to "mine" (owned + favorited),
// "内置" to "builtin" (admin-only, user_id IS NULL).
const isSquareTab =
activeTab !== "mine" && activeTab !== "private" && activeTab !== "builtin";
const mainScope =
activeTab === "mine"
? "mine"
: activeTab === "builtin"
? "builtin"
: "square";
const mainPage = pages[activeTab] ?? 1;
const {
agents: mainAgents,
@@ -393,6 +409,12 @@ export function AgentGallery() {
私密广场
</TabsTrigger>
)}
{isAdmin && (
<TabsTrigger value="builtin" title="内置/功能型智能体(仅管理员可见)">
<BotIcon className="mr-1 h-3.5 w-3.5" />
内置智能体
</TabsTrigger>
)}
</TabsList>
<div className="flex items-center gap-3">
<span className="text-muted-foreground text-sm whitespace-nowrap">
@@ -490,6 +512,19 @@ export function AgentGallery() {
})}
</TabsContent>
{isAdmin && (
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
tab: "builtin",
emptyDescription: "暂时没有内置智能体。",
})}
</TabsContent>
)}
{privateFeatureAvailable && (
<TabsContent
value="private"
@@ -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,7 +1,8 @@
"use client";
import { LayoutGridIcon, ListIcon, MoonIcon, SunIcon } from "lucide-react";
import { DropletIcon, LayoutGridIcon, LayoutIcon, ListIcon, MoonIcon, PanelLeftIcon, SunIcon } from "lucide-react";
import { useMemo, type ComponentType, type SVGProps } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
@@ -42,9 +43,10 @@ const languageOptions: { value: Locale; label: string }[] = [
{ value: "zh-CN", label: zhCN.locale.localName },
];
export function AppearanceSettingsPage() {
export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const { t, locale, changeLocale } = useI18n();
const { theme, setTheme } = useTheme();
const navigate = useNavigate();
const currentTheme = theme;
const [settings, setSettings] = useLocalSettings();
const isAdmin = getStoredAuth()?.system_role === "admin";
@@ -58,15 +60,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 +109,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,13 +140,49 @@ 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)}
/>
))}
</div>
</SettingsSection>
<Separator />
<SettingsSection
title="界面模式"
description="全量模式展示侧边栏与顶栏导航;简洁模式隐藏侧边栏与顶栏,获得更大的工作区。"
>
<div className="flex gap-2">
<Button
type="button"
variant={settings.display.layoutMode !== "compact" ? "default" : "outline"}
size="sm"
onClick={() => {
setSettings("display", { layoutMode: "full" });
onClose?.();
navigate("/page/workspace/chats/new");
}}
>
<PanelLeftIcon className="mr-1.5 size-4" />
全量模式
</Button>
<Button
type="button"
variant={settings.display.layoutMode === "compact" ? "default" : "outline"}
size="sm"
onClick={() => {
setSettings("display", { layoutMode: "compact" });
onClose?.();
navigate("/page/workspace/chats/new");
}}
>
<LayoutIcon className="mr-1.5 size-4" />
简洁模式
</Button>
</div>
</SettingsSection>
{isAdmin && (
<>
<Separator />
@@ -165,9 +211,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 +430,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 +461,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" />
@@ -121,7 +121,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
<div className="min-w-0 space-y-8 p-6">
{activeSection === "account" && <AccountSettingsPage />}
{activeSection === "appearance" && <AppearanceSettingsPage />}
{activeSection === "appearance" && (
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
)}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "about" && <AboutSettingsPage />}
@@ -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>
+35 -4
View File
@@ -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(() => {
+3 -2
View File
@@ -25,8 +25,9 @@ export class AgentNameCheckError extends Error {
export interface ListAgentsParams {
search?: string;
tagIds?: string[];
/** Pagination scope. Omit for the legacy combined-visibility list. */
scope?: "mine" | "square";
/** Pagination scope. Omit for the legacy combined-visibility list.
* "builtin" = 内置智能体 (user_id IS NULL), admin only — backend 403s non-admins. */
scope?: "mine" | "square" | "builtin";
/** 1-based page index. Providing this (or scope) enables server-side pagination. */
page?: number;
pageSize?: number;
+1 -1
View File
@@ -79,7 +79,7 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
/** Server-side paginated agent listing for one gallery tab (scope). Returns
* the page of agents plus the total count for the "共 N 个" header. */
export function useAgentsPaginated(params: {
scope: "mine" | "square";
scope: "mine" | "square" | "builtin";
search?: string;
tagIds?: string[];
sort?: AgentSort;
+31
View File
@@ -44,6 +44,37 @@ export function clearStoredAuth() {
window.localStorage.removeItem(AUTH_STORAGE_KEY);
}
/**
* 把登录用户名写入 localStorage.userInfo.userName(顶栏用户区据此显示)。
* 仅在尚未设置时写入,避免覆盖外部登录(yUserId)从 consumer 拿到的用户名。
*/
export function setStoredUserName(name: string | null | undefined) {
if (!isBrowser()) {
return;
}
const trimmed = name?.trim();
if (!trimmed) {
return;
}
let info: Record<string, unknown> = {};
try {
const raw = window.localStorage.getItem("userInfo");
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
info = parsed as Record<string, unknown>;
}
}
} catch {
info = {};
}
if (typeof info.userName === "string" && info.userName.trim()) {
return;
}
info.userName = trimmed;
window.localStorage.setItem("userInfo", JSON.stringify(info));
}
export function getAccessToken(): string | undefined {
const auth = getStoredAuth();
const token = auth?.access_token?.trim();
@@ -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: "在不同语言之间切换。",
},
+5
View File
@@ -62,6 +62,11 @@ export interface LocalSettings {
*/
skillViewMode?: "card" | "list";
agentViewMode?: "card" | "list";
/**
* Layout mode: "full" shows the page sidebar + header; "compact" hides them
* for a cleaner work area. Defaults to "full" (undefined treated as "full").
*/
layoutMode?: "full" | "compact";
};
context: Omit<
AgentThreadContext,
+1 -1
View File
@@ -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 {
-1
View File
@@ -5,7 +5,6 @@
*/
export const DESENSITIZE_MAP: Record<string, string> = {
态势: "TS",
你好: "nh",
滨海作战体系: "第一张网",
滨海作战部队: "第一张网",
'Taiwan Air Force Flight Units': "第二张网",
@@ -47,7 +47,7 @@ const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; des
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
];
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]';
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222] dark:text-[#AACEFF]';
const FIELD_TRIGGER_CLASS =
'h-12 w-full rounded-xl border-[#e8dedd] bg-white text-sm shadow-sm';
@@ -254,7 +254,7 @@ export function AIWritingPanel({
const showNewButton = isHistoryMode || state.status !== 'idle';
return (
<div className="flex flex-col h-full overflow-hidden bg-background">
<div className="flex flex-col h-full overflow-hidden bg-background dark:bg-transparent">
{!isHistoryMode && (
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
)}
@@ -114,7 +114,7 @@ export function WritingSetupChat() {
return (
<ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider>
<div className="flex size-full flex-col bg-background">
<div className="flex size-full flex-col bg-background dark:bg-transparent">
{/* 空状态隐藏对话头部,直接展示欢迎屏(与原型图一致);有消息后再显示头部 */}
{!showWelcome && (
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
@@ -139,7 +139,7 @@ export function WritingSetupChat() {
)}
</div>
<div className="bg-background shrink-0 border-t px-4 py-4">
<div className="bg-background dark:bg-transparent shrink-0 border-t px-4 py-4">
{/* 与原型图一致:白色圆角卡片包裹「模型选择行 + 输入框」 */}
<div className="space-y-3 rounded-2xl border border-[#ece5e4] bg-card p-3 shadow-sm dark:border-[#1B477E] dark:bg-[#022248]/40">
<div className="flex items-center justify-between gap-3">
@@ -59,7 +59,7 @@ export function useWritingFormOptional(): WritingFormValue | null {
export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'general' | 'notebook' }) {
const [intent, setIntent] = useState('');
const [formExpanded, setFormExpanded] = useState(false);
const [showOutline, setShowOutline] = useState(false);
const [showOutline, setShowOutline] = useState(true);
const [outlineText, setOutlineText] = useState('');
const [articleType, setArticleType] = useState('analysis');
const [wordCountInput, setWordCountInput] = useState('800');
@@ -229,7 +229,7 @@ export default function AIWritingPage({
<AIWritingProvider>
<WritingFormProvider initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background">
<div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg">
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
</div>
+1 -1
View File
@@ -180,7 +180,7 @@ export default function AgentChatPage() {
separate flex sibling OUTSIDE ChatBox, so the artifact preview
always opens between the chat and the detail panel — the detail
panel stays rightmost. */}
<div className="flex size-full min-h-0 overflow-hidden">
<div className="flex size-full min-h-0 overflow-hidden workspace-chat-bg">
<div className="min-h-0 min-w-0 flex-1">
<ChatBox threadId={threadId}>
{/* Main conversation column. `relative` hosts the floating
@@ -1,14 +1,23 @@
/**
* 配置管理页 —— 把固定业务码(3Q/6BF/7BF/8BF)绑定到「智能体」+「业务链」。
* 配置管理页 —— 把固定业务码绑定到「智能体」+「业务链」。
*
* 表格三列:业务(只读)/ 智能体 / 业务链。编辑模式下后两列为可搜索下拉:
* - 智能体来自 `/api/agents`
* - 业务链来自 `/api/roundtable-chains?scope=public`(只列公共链,保证全局共享时各账号都能解析)
* 智能体来自 `/api/agents`,业务链来自 `/api/roundtable-chains?scope=public`。
* 保存调 `PUT /api/business-mapping/{code}`。映射全局共享、无用户隔离。
*/
import { useEffect, useMemo, useState } from "react";
import { Loading, Select } from "tdesign-react";
import { Link2, Pencil, Save, Users, X } from "lucide-react";
import { motion } from "motion/react";
import { Select } from "tdesign-react";
import {
AlertCircle,
HelpCircle,
Info,
Link2,
Pencil,
Save,
Search,
Users,
X,
} from "lucide-react";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
@@ -21,11 +30,7 @@ import {
updateBusinessMapping,
type BusinessMapping,
} from "@/strategy-components/api/business-mapping";
interface DraftSelection {
agentId: string | null;
chainId: string | null;
}
import { cn } from "@/lib/utils";
export default function BusinessChainMappingPage() {
const { theme } = useTheme();
@@ -36,8 +41,13 @@ export default function BusinessChainMappingPage() {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [loading, setLoading] = useState(true);
const [editingCode, setEditingCode] = useState<string | null>(null);
const [draft, setDraft] = useState<DraftSelection>({ agentId: null, chainId: null });
const [draft, setDraft] = useState<{ agentId: string | null; chainId: string | null }>({
agentId: null,
chainId: null,
});
const [saving, setSaving] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "configured" | "pending">("all");
useEffect(() => {
let cancelled = false;
@@ -58,9 +68,7 @@ export default function BusinessChainMappingPage() {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
return () => { cancelled = true; };
}, []);
const agentOptions = useMemo(
@@ -72,6 +80,24 @@ export default function BusinessChainMappingPage() {
[chains],
);
const stats = useMemo(() => {
const total = rows.length;
const boundAgents = rows.filter((r) => r.agentId !== null).length;
return { total, boundAgents };
}, [rows]);
const filteredRows = useMemo(() => {
return rows.filter((r) => {
const q = searchQuery.toLowerCase();
const matchesSearch =
r.label.toLowerCase().includes(q) || r.businessCode.toLowerCase().includes(q);
const isConfigured = r.agentId !== null && r.chainId !== null;
if (statusFilter === "configured") return matchesSearch && isConfigured;
if (statusFilter === "pending") return matchesSearch && !isConfigured;
return matchesSearch;
});
}, [rows, searchQuery, statusFilter]);
const beginEdit = (row: BusinessMapping) => {
setEditingCode(row.businessCode);
setDraft({ agentId: row.agentId, chainId: row.chainId });
@@ -86,10 +112,10 @@ export default function BusinessChainMappingPage() {
setSaving(true);
try {
const agentName = draft.agentId
? agents.find((a) => a.id === draft.agentId)?.name ?? row.agentName ?? null
? (agents.find((a) => a.id === draft.agentId)?.name ?? row.agentName ?? null)
: null;
const chainTitle = draft.chainId
? chains.find((c) => c.id === draft.chainId)?.title ?? row.chainTitle ?? null
? (chains.find((c) => c.id === draft.chainId)?.title ?? row.chainTitle ?? null)
: null;
const updated = await updateBusinessMapping(row.businessCode, {
agentId: draft.agentId,
@@ -111,162 +137,416 @@ export default function BusinessChainMappingPage() {
}
};
/* ── 样式 ── */
const shellClass = isDark
? "h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100"
: "h-full overflow-y-auto bg-[linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]";
? "h-full overflow-y-auto text-slate-100 workspace-chat-bg"
: "h-full overflow-y-auto bg-white text-[#222]";
// 卡片:无边框,纯阴影
const cardClass = isDark
? "border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)] shadow-[0_16px_36px_rgba(3,10,22,0.2)]"
: "border border-[#E8E8E8] bg-white shadow-[0_14px_30px_rgba(245,34,45,0.06)]";
const headRowClass = isDark ? "text-slate-400" : "text-[#8C8C8C]";
const rowBorder = isDark ? "border-[rgba(67,89,124,0.32)]" : "border-[#F0F0F0]";
? "bg-[rgba(12,29,54,0.88)] shadow-[0_8px_32px_rgba(3,10,22,0.28)] backdrop-blur-sm"
: "bg-white shadow-[0_8px_32px_rgba(0,0,0,0.07)]";
// 行分隔:极淡,不带色
const rowDivider = isDark ? "border-white/[0.04]" : "border-black/[0.04]";
const mutedText = isDark ? "text-slate-500" : "text-[#A6A6A6]";
const codeBadge = isDark
? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]"
: "bg-[#FFF1F0] text-[#CF1322]";
const subText = isDark ? "text-slate-400" : "text-slate-500";
const filterTabs: { id: "all" | "configured" | "pending"; label: string }[] = [
{ id: "all", label: "全部业务" },
{ id: "configured", label: "已绑定" },
{ id: "pending", label: "待配置" },
];
return (
<div className={shellClass}>
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col gap-5 px-6 py-6 lg:px-8">
<div className="space-y-1">
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? "text-slate-50" : "text-[#222222]"}`}>
配置管理
</h1>
<p className={`text-sm ${isDark ? "text-slate-300/80" : "text-slate-500"}`}>
为每个业务绑定智能体与业务链。多智能体会商从对应业务自动取用此处配置的业务链。业务链仅可选择「公共」链条。
</p>
</div>
<div className={cn(shellClass, "[&_.t-input]:rounded-[5px] [&_.t-input__wrap]:rounded-[5px]")}>
<div className="mx-auto flex min-h-full max-w-7xl flex-col gap-6 px-6 py-7 lg:px-8">
<Loading loading={loading} showOverlay={false}>
<div className={`overflow-hidden rounded-3xl ${cardClass}`}>
<table className="w-full border-collapse text-sm">
<thead>
<tr className={`text-left text-xs uppercase tracking-widest ${headRowClass}`}>
<th className="px-6 py-4 font-semibold">业务</th>
<th className="px-6 py-4 font-semibold">智能体</th>
<th className="px-6 py-4 font-semibold">业务链</th>
<th className="px-6 py-4 text-right font-semibold">操作</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && !loading ? (
<tr>
<td colSpan={4} className={`px-6 py-12 text-center ${mutedText}`}>
暂无业务数据
</td>
</tr>
) : (
rows.map((row) => {
const editing = editingCode === row.businessCode;
return (
<tr key={row.businessCode} className={`border-t ${rowBorder}`}>
{/* 业务(只读) */}
<td className="px-6 py-4 align-middle">
<div className="flex items-center gap-2">
<span className={`text-[15px] font-semibold ${isDark ? "text-white" : "text-[#222222]"}`}>
{row.label}
</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-mono ${codeBadge}`}>
{row.businessCode}
</span>
</div>
</td>
{/* ── 页头 banner ── */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
className={cn("rounded-[5px] p-6 lg:p-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-6 relative overflow-hidden", cardClass)}
>
<div
className="absolute right-0 top-0 w-72 h-32 pointer-events-none"
style={{
background: isDark
? "radial-gradient(ellipse at top right, rgba(77,141,255,0.07), transparent)"
: "radial-gradient(ellipse at top right, rgba(245,34,45,0.04), transparent)",
}}
/>
{/* 智能体 */}
<td className="px-6 py-4 align-middle">
{editing ? (
<Select
className="min-w-[200px]"
value={draft.agentId ?? undefined}
options={agentOptions}
filterable
clearable
placeholder="搜索并选择智能体"
onChange={(v) => setDraft((d) => ({ ...d, agentId: (v as string) ?? null }))}
/>
) : (
<span className="inline-flex items-center gap-1.5">
<Users className={`h-3.5 w-3.5 ${mutedText}`} />
{row.agentName || row.agentId ? (
<span>{row.agentName || row.agentId}</span>
) : (
<span className={mutedText}>未配置</span>
)}
</span>
)}
</td>
{/* 业务链 */}
<td className="px-6 py-4 align-middle">
{editing ? (
<Select
className="min-w-[220px]"
value={draft.chainId ?? undefined}
options={chainOptions}
filterable
clearable
placeholder="搜索并选择业务链(公共)"
onChange={(v) => setDraft((d) => ({ ...d, chainId: (v as string) ?? null }))}
/>
) : (
<span className="inline-flex items-center gap-1.5">
<Link2 className={`h-3.5 w-3.5 ${mutedText}`} />
{row.chainTitle || row.chainId ? (
<span>{row.chainTitle || row.chainId}</span>
) : (
<span className={mutedText}>未配置</span>
)}
</span>
)}
</td>
{/* 操作 */}
<td className="px-6 py-4 text-right align-middle">
{editing ? (
<div className="inline-flex items-center gap-2">
<button
type="button"
disabled={saving}
onClick={() => handleSave(row)}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium disabled:opacity-50 ${
isDark ? "bg-[rgba(34,197,94,0.18)] text-[#86EFAC]" : "bg-[#EEFCF7] text-[#0F9D6C]"
}`}
>
<Save className="h-3.5 w-3.5" />
保存
</button>
<button
type="button"
disabled={saving}
onClick={cancelEdit}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium disabled:opacity-50 ${
isDark ? "bg-[rgba(110,136,176,0.18)] text-slate-300" : "bg-[#F5F5F5] text-[#575757]"
}`}
>
<X className="h-3.5 w-3.5" />
取消
</button>
</div>
) : (
<button
type="button"
onClick={() => beginEdit(row)}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium ${
isDark ? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]" : "bg-[#FFF1F0] text-[#CF1322]"
}`}
>
<Pencil className="h-3.5 w-3.5" />
编辑
</button>
)}
</td>
</tr>
);
})
<div className="space-y-3 relative z-10">
<div className="flex flex-wrap items-center gap-2.5">
<h2 className={cn("text-xl font-bold tracking-tight", isDark ? "text-slate-50" : "text-slate-900")}>
配置管理
</h2>
<span
className={cn(
"text-[10px] uppercase font-mono tracking-wider font-bold px-2.5 py-1 rounded-[5px] flex items-center gap-1.5",
isDark
? "bg-[rgba(77,141,255,0.12)] text-[#9CCBFF]"
: "bg-red-50/80 text-red-500",
)}
</tbody>
</table>
>
<span className={cn("w-1.5 h-1.5 rounded-full animate-pulse", isDark ? "bg-[#55A6FE]" : "bg-red-500")} />
智能会商神经中枢
</span>
<span
className={cn(
"text-[10px] font-mono tracking-wider px-2 py-0.5 rounded-[5px]",
isDark ? "text-slate-500 bg-white/5" : "text-slate-400 bg-slate-50",
)}
>
v1.2.5
</span>
</div>
<p className={cn("text-xs max-w-2xl leading-relaxed", subText)}>
业务流转与交叉验证的高级控制中心。在此注册高频业务模块并绑定专职执行智能体(Agent)及公共流转决策链(Chain),决策信息将基于该拓扑自动触发多智能体自诊断及会商组织。
</p>
<div className={cn("flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-1 text-[11px] font-medium", mutedText)}>
<span className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-green-500" />
会商引擎: 正常运行中
</span>
<span className="opacity-30">•</span>
<span>
全局配置项:{" "}
<strong className={isDark ? "text-slate-200 font-semibold" : "text-slate-700 font-semibold"}>
{stats.total} 项注册
</strong>
</span>
<span className="opacity-30">•</span>
<span>
指定专家节点:{" "}
<strong className={isDark ? "text-[#9CCBFF] font-semibold" : "text-red-500 font-semibold"}>
{stats.boundAgents} / {stats.total}
</strong>
</span>
</div>
</div>
</Loading>
</motion.div>
{/* ── 表格区 ── */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
className={cn("rounded-[5px] overflow-hidden", cardClass)}
>
{/* 搜索 + 筛选 */}
<div className={cn("px-6 py-5 border-b flex flex-col sm:flex-row sm:items-center justify-between gap-4", rowDivider)}>
<div className="relative w-full max-w-sm">
<Search className={cn("absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4", mutedText)} />
<input
type="text"
placeholder="搜索业务名称、ID..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
"w-full pl-10 pr-4 py-2.5 rounded-[5px] text-xs transition-all placeholder:text-slate-400 focus:outline-none",
isDark
? "text-slate-200 bg-white/5 focus:bg-white/8 focus:ring-1 focus:ring-[#4D8DFF]/30"
: "text-slate-700 bg-slate-50/60 hover:bg-slate-50 focus:bg-white focus:ring-1 focus:ring-red-400/30",
)}
/>
</div>
<div className={cn("flex items-center gap-1 p-1 rounded-[5px] self-end sm:self-auto shrink-0", isDark ? "bg-white/5" : "bg-slate-100/70")}>
{filterTabs.map((tab) => (
<button
key={tab.id}
onClick={() => setStatusFilter(tab.id)}
className={cn(
"px-3.5 py-1.5 rounded-[5px] text-xs font-semibold transition-all cursor-pointer",
statusFilter === tab.id
? isDark
? "bg-[rgba(77,141,255,0.18)] text-[#9CCBFF] shadow-sm"
: "bg-white text-gray-900 shadow-sm"
: isDark
? "text-slate-500 hover:text-slate-300"
: "text-gray-400 hover:text-gray-700",
)}
>
{tab.label}
</button>
))}
</div>
</div>
{/* 表格 */}
<div className="overflow-x-auto">
{loading ? (
<div className={cn("py-16 text-center text-sm", mutedText)}>加载中…</div>
) : (
<table className="w-full table-fixed text-left border-collapse">
<thead>
<tr className={cn("border-b", rowDivider, isDark ? "bg-white/[0.02]" : "bg-black/[0.02]")}>
<th className={cn("pl-8 pr-4 py-3.5 text-xs font-semibold uppercase tracking-wider w-[20%]", mutedText)}>业务</th>
<th className={cn("px-4 py-3.5 text-xs font-semibold uppercase tracking-wider w-[26%]", mutedText)}>智能体</th>
<th className={cn("px-4 py-3.5 text-xs font-semibold uppercase tracking-wider w-[26%]", mutedText)}>业务链</th>
<th className={cn("pl-4 pr-8 py-3.5 text-xs font-semibold uppercase tracking-wider w-[28%] text-right", mutedText)}>操作</th>
</tr>
</thead>
<tbody>
{filteredRows.length > 0 ? (
filteredRows.map((row) => {
const editing = editingCode === row.businessCode;
return (
<tr
key={row.businessCode}
className={cn(
"border-b last:border-0 transition-colors duration-150",
rowDivider,
!editing && (isDark ? "hover:bg-white/[0.025]" : "hover:bg-black/[0.015]"),
editing && (isDark ? "bg-[rgba(77,141,255,0.04)]" : "bg-red-50/30"),
)}
>
{/* 业务 */}
<td className="pl-8 pr-4 py-5 align-middle">
<div className="flex items-center gap-2.5">
<span className={cn("font-semibold tracking-tight text-sm", isDark ? "text-slate-100" : "text-gray-800")}>
{row.label}
</span>
<span
className={cn(
"text-[10px] font-mono font-bold px-2 py-0.5 rounded-[5px] leading-none",
isDark
? "bg-[rgba(77,141,255,0.12)] text-[#9CCBFF]"
: "bg-red-50 text-red-400",
)}
>
{row.businessCode}
</span>
</div>
</td>
{/* 智能体 */}
<td className="px-4 py-4 align-middle">
{editing ? (
<Select
className="min-w-[200px]"
value={draft.agentId ?? undefined}
options={agentOptions}
filterable
clearable
placeholder="搜索并选择智能体"
onChange={(v) =>
setDraft((d) => ({ ...d, agentId: (v as string) ?? null }))
}
/>
) : (
<div className="flex items-center gap-2.5">
<div
className={cn(
"p-1.5 rounded-[5px] transition-colors",
row.agentId
? isDark
? "bg-[rgba(77,141,255,0.1)] text-[#9CCBFF]"
: "bg-red-50 text-red-400"
: isDark
? "bg-white/5 text-slate-600"
: "bg-slate-50 text-gray-300",
)}
>
<Users className="w-3.5 h-3.5 shrink-0" />
</div>
{row.agentName || row.agentId ? (
<div className="space-y-0.5">
<span className={cn("text-xs font-semibold", isDark ? "text-slate-200" : "text-gray-800")}>
{row.agentName || row.agentId}
</span>
{row.agentId && agents.find((a) => a.id === row.agentId)?.description && (
<span className={cn("block text-[10px]", mutedText)}>
{(agents.find((a) => a.id === row.agentId)?.description ?? "").slice(0, 40)}
{(agents.find((a) => a.id === row.agentId)?.description?.length ?? 0) > 40 ? "…" : ""}
</span>
)}
</div>
) : (
<span className={cn("text-xs", mutedText)}>未配置</span>
)}
</div>
)}
</td>
{/* 业务链 */}
<td className="px-4 py-4 align-middle">
{editing ? (
<Select
className="min-w-[220px]"
value={draft.chainId ?? undefined}
options={chainOptions}
filterable
clearable
placeholder="搜索并选择业务链(公共)"
onChange={(v) =>
setDraft((d) => ({ ...d, chainId: (v as string) ?? null }))
}
/>
) : (
<div className="flex items-center gap-2.5">
<div
className={cn(
"p-1.5 rounded-[5px] transition-colors",
row.chainId
? isDark
? "bg-[rgba(34,197,94,0.08)] text-green-400"
: "bg-blue-50 text-blue-400"
: isDark
? "bg-white/5 text-slate-600"
: "bg-slate-50 text-gray-300",
)}
>
<Link2 className="w-3.5 h-3.5 shrink-0" />
</div>
{row.chainTitle || row.chainId ? (
<div className="space-y-0.5">
<span className={cn("text-xs font-semibold", isDark ? "text-slate-200" : "text-gray-800")}>
{row.chainTitle || row.chainId}
</span>
{row.chainId && (
<span className={cn("flex items-center gap-1 text-[10px] leading-none", mutedText)}>
<span
className={cn(
"px-1 rounded-[5px] text-[9px] font-medium",
isDark
? "bg-[rgba(34,197,94,0.08)] text-green-400"
: "bg-green-50 text-green-600",
)}
>
公共
</span>
节点数: {chains.find((c) => c.id === row.chainId)?.seats?.length ?? "—"}
</span>
)}
</div>
) : (
<span className={cn("text-xs", mutedText)}>未配置</span>
)}
</div>
)}
</td>
{/* 操作 */}
<td className="pl-4 pr-8 py-4 text-right align-middle whitespace-nowrap">
{editing ? (
<div className="inline-flex items-center gap-2 justify-end">
<button
type="button"
disabled={saving}
onClick={() => handleSave(row)}
className={cn(
"inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-all active:scale-95 disabled:opacity-50",
isDark
? "bg-[rgba(34,197,94,0.12)] text-green-400 hover:bg-[rgba(34,197,94,0.2)]"
: "bg-green-50 text-green-600 hover:bg-green-100",
)}
>
<Save className="h-3.5 w-3.5" />
保存
</button>
<button
type="button"
disabled={saving}
onClick={cancelEdit}
className={cn(
"inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-all active:scale-95 disabled:opacity-50",
isDark
? "bg-white/5 text-slate-400 hover:bg-white/10 hover:text-slate-200"
: "bg-slate-50 text-slate-500 hover:bg-slate-100",
)}
>
<X className="h-3.5 w-3.5" />
取消
</button>
</div>
) : (
<button
type="button"
onClick={() => beginEdit(row)}
title="编辑配置"
className={cn(
"inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-all duration-200 active:scale-95",
isDark
? "text-[#9CCBFF] bg-[rgba(77,141,255,0.12)] hover:bg-[rgba(77,141,255,0.18)]"
: "text-red-500 bg-red-50 hover:bg-red-100",
)}
>
<Pencil className="h-3.5 w-3.5" />
<span>编辑</span>
</button>
)}
</td>
</tr>
);
})
) : (
<tr>
<td colSpan={4} className="py-14 text-center">
<div className="flex flex-col items-center justify-center gap-2">
<AlertCircle className={cn("w-7 h-7", isDark ? "text-slate-700" : "text-gray-200")} />
<p className={cn("text-xs font-medium", mutedText)}>未找到符合筛选条件的业务配置</p>
<button
onClick={() => { setSearchQuery(""); setStatusFilter("all"); }}
className={cn("text-[11px] font-semibold mt-1 hover:underline", isDark ? "text-[#9CCBFF]" : "text-red-500")}
>
重置筛选条件
</button>
</div>
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
{/* 表格尾栏 */}
<div className={cn("px-6 py-4 border-t flex items-center justify-between text-xs font-medium", rowDivider, isDark ? "bg-white/[0.02]" : "bg-black/[0.015]", mutedText)}>
<span className="flex items-center gap-1.5">
<Info className="w-3.5 h-3.5" />
会商程序会在对应业务的业务链生效时开启多体交叉核验证。
</span>
<span className="font-mono text-[10px]">
显示 {filteredRows.length} / {rows.length} 条
</span>
</div>
</motion.div>
{/* ── 使用说明 ── */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
className={cn(
"rounded-[5px] p-5 flex gap-4 text-xs leading-relaxed",
isDark
? "bg-[rgba(245,158,11,0.05)] text-amber-300/70"
: "bg-amber-50/50 text-amber-800",
)}
>
<div className={cn("w-8 h-8 rounded-[5px] shrink-0 flex items-center justify-center", isDark ? "bg-[rgba(245,158,11,0.08)] text-amber-400" : "bg-amber-100/60 text-amber-500")}>
<HelpCircle className="w-4 h-4" />
</div>
<div>
<h4 className={cn("font-bold mb-0.5", isDark ? "text-amber-300" : "text-amber-900")}>如何配置业务流转?</h4>
<p className={isDark ? "text-amber-400/60" : "text-amber-700/80"}>
1. 将鼠标悬停在任一业务行右侧,点击出现的{" "}
<span className={cn("font-semibold", isDark ? "text-[#9CCBFF]" : "text-red-500")}>「编辑」</span>。
<br />
2. 在该行内直接搜索并选择 <strong>执行智能体</strong> 与 <strong>业务链</strong>,点击「保存」即时生效。
<br />
3. 若无需绑定,可搜索框清空后保存,置为「未配置」状态。
</p>
</div>
</motion.div>
</div>
</div>
);
+80 -11
View File
@@ -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";
@@ -36,6 +42,7 @@ import {
getLastAgentId,
saveLastAgentId,
useKnowledgeRagEnabled,
useLocalSettings,
useThreadSettings,
} from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
@@ -85,7 +92,11 @@ 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 [localSettings] = useLocalSettings();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
@@ -402,11 +413,32 @@ export default function ChatPage() {
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
// 紧凑模式(正常 App,有外层页面侧栏)落地态:用 CM 参考图样式重画;
// 判定与 PageRoutes 的 compactWorkspaceSidebar(pageSidebarAllowed) 保持一致 ——
// 优先看外观设置的「全量/简洁」,未设置过才回退到会话级 sidebarEnabled。否则用户显式
// 选「全量」时 PageRoutes 已挂出顶栏+侧栏,这里却还按旧 sidebarEnabled 判定,导致深蓝
// 主题底图(workspace-chat-bg)不生效。非紧凑(嵌入/standalone)保持原样。
const layoutMode = localSettings.display.layoutMode;
const chromeByPreference =
layoutMode === "full"
? true
: layoutMode === "compact"
? false
: readSidebarEnabled();
const compactChrome =
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
const compactLanding = isLandingState && compactChrome;
return (
<ThreadContext.Provider value={{ thread, isMock }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<div
className={cn(
"relative flex size-full min-h-0 justify-between",
// 紧凑模式(有顶栏+左侧页面侧栏)下,深色背景换成设计稿深蓝主题底图
compactChrome && "workspace-chat-bg",
)}
>
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
@@ -419,6 +451,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 +499,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 +534,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 +589,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],
+10 -10
View File
@@ -119,7 +119,7 @@ export default function LightAppListPage() {
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
应用列表
</h1>
<span className="rounded-full border border-[#CF1322]/15 bg-[#FFF1F0] px-2 py-0.5 font-mono text-xs font-medium text-[#CF1322] dark:border-[#1677FF]/30 dark:bg-[#1677FF]/15 dark:text-[#4096FF]">
<span className="rounded-[5px] border border-[#CF1322]/15 bg-[#FFF1F0] px-2 py-0.5 font-mono text-xs font-medium text-[#CF1322] dark:border-[#1677FF]/30 dark:bg-[#1677FF]/15 dark:text-[#4096FF]">
导航工作台
</span>
</div>
@@ -129,7 +129,7 @@ export default function LightAppListPage() {
</div>
{/* 搜索 */}
<div className="rounded-2xl border border-zinc-200/80 bg-white p-5 shadow-sm dark:border-[#1A2F5A] dark:bg-[#122347]">
<div className="rounded-[5px] bg-white p-5 shadow-sm dark:bg-[#122347]">
<div className="relative w-full">
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4.5 -translate-y-1/2 text-zinc-400 dark:text-[#6E88B0]" />
<input
@@ -137,7 +137,7 @@ export default function LightAppListPage() {
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="键入应用名称、目标地址、或描述,实时过滤检索…"
className="w-full rounded-xl border border-zinc-200 bg-zinc-50/50 py-3 pl-10 pr-10 text-sm transition focus:border-[#CF1322]/60 focus:bg-white focus:outline-none focus:ring-2 focus:ring-[#CF1322]/20 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#C5CFE0] dark:placeholder-[#4A6491] dark:focus:border-[#1677FF]/60 dark:focus:bg-[#122347] dark:focus:ring-[#1677FF]/25"
className="w-full rounded-[5px] border border-zinc-200 bg-zinc-50/50 py-3 pl-10 pr-10 text-sm transition focus:border-[#CF1322]/60 focus:bg-white focus:outline-none focus:ring-2 focus:ring-[#CF1322]/20 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#C5CFE0] dark:placeholder-[#4A6491] dark:focus:border-[#1677FF]/60 dark:focus:bg-[#122347] dark:focus:ring-[#1677FF]/25"
/>
{search && (
<button
@@ -171,7 +171,7 @@ export default function LightAppListPage() {
{/* 卡片网格 / 空态 */}
{apps.length === 0 ? (
<div className="rounded-2xl border border-dashed border-zinc-200 bg-white p-16 text-center shadow-sm dark:border-[#1A2F5A] dark:bg-[#122347]">
<div className="rounded-[5px] bg-white p-16 text-center shadow-sm dark:bg-[#122347]">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full border border-zinc-100 bg-zinc-50 text-zinc-300 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#6E88B0]">
<Search className="size-6" />
</div>
@@ -205,7 +205,7 @@ export default function LightAppListPage() {
pageSize={PAGE_SIZE}
total={apps.length}
onPageChange={setPage}
className="pt-2"
className="pt-2 [&_button]:rounded-[5px]"
/>
)}
</>
@@ -240,11 +240,11 @@ function AppCard({
const hasParams = (app.routeParams ?? []).some((p) => p.key) && parsedUrl !== app.url;
return (
<div className="group relative flex flex-col justify-between overflow-hidden rounded-2xl border border-zinc-100 bg-white p-5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition-all duration-300 hover:-translate-y-1 hover:border-zinc-200/90 hover:shadow-[0_12px_24px_rgba(0,0,0,0.04)] dark:border-[#1A2F5A] dark:bg-[#122347] dark:shadow-none dark:hover:border-[#2D4473]">
<div className="group relative flex flex-col justify-between overflow-hidden rounded-[5px] bg-white p-5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition-all duration-300 hover:-translate-y-1 hover:shadow-[0_12px_24px_rgba(0,0,0,0.04)] dark:bg-[#122347] dark:shadow-none">
{/* 上:图标 + 标题 + 描述 */}
<div>
<div className="mb-3.5 flex items-start gap-4">
<div className={cn("rounded-xl border border-white/60 p-3 shadow-sm dark:border-transparent", theme.iconBg)}>
<div className={cn("rounded-[5px] p-3 shadow-sm", theme.iconBg)}>
<Icon className="size-5.5" />
</div>
</div>
@@ -260,7 +260,7 @@ function AppCard({
{/* 下:URL 预览 + 动作 */}
<div className="mt-auto border-t border-zinc-100 pt-4 dark:border-[#1A2F5A]">
<div className="mb-4 rounded-xl border border-zinc-100 bg-zinc-50/70 p-3 dark:border-[#1A2F5A] dark:bg-[#0C1836]">
<div className="mb-4 rounded-[5px] bg-zinc-50/70 p-3 dark:bg-[#0C1836]">
<div className="mb-1 flex items-center justify-between text-[10px] font-semibold text-zinc-400 dark:text-[#6E88B0]">
<span>拼接后目标路由终端:</span>
<span className="text-[9px] font-bold uppercase tracking-wider text-[#CF1322] dark:text-[#4096FF]">
@@ -283,7 +283,7 @@ function AppCard({
type="button"
onClick={onCopy}
className={cn(
"flex items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-semibold transition",
"flex items-center justify-center gap-1.5 rounded-[5px] border px-3 py-2 text-xs font-semibold transition",
copied
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-[#52C41A]/30 dark:bg-[#52C41A]/15 dark:text-[#52C41A]"
: "border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-50 hover:text-zinc-800 dark:border-[#1A2F5A] dark:bg-[#122347] dark:text-[#C5CFE0] dark:hover:bg-[#1A2F5A]",
@@ -305,7 +305,7 @@ function AppCard({
<button
type="button"
onClick={onOpen}
className="flex items-center justify-center gap-1.5 rounded-lg bg-[#CF1322] px-3 py-2 text-xs font-semibold text-white shadow-[0_1px_3px_rgba(207,19,34,0.2)] transition hover:bg-[#A8071A] active:scale-[0.98] dark:bg-[#1677FF] dark:shadow-none dark:hover:bg-[#0958D9]"
className="flex items-center justify-center gap-1.5 rounded-[5px] bg-[#CF1322] px-3 py-2 text-xs font-semibold text-white shadow-[0_1px_3px_rgba(207,19,34,0.2)] transition hover:bg-[#A8071A] active:scale-[0.98] dark:bg-[#1677FF] dark:shadow-none dark:hover:bg-[#0958D9]"
>
<span>进入应用</span>
<ArrowUpRight className="size-3.5" />
+402 -176
View File
@@ -1,16 +1,28 @@
import { useMemo, useState } from "react";
import {
AlertCircle,
AlignLeft,
ChevronDown,
ChevronUp,
Copy,
ExternalLink,
FolderTree,
Globe,
HelpCircle,
Info,
Layers,
Link2,
Monitor,
Pencil,
Plus,
Search,
Sliders,
Tag,
Trash2,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Select } from "tdesign-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -41,6 +53,18 @@ interface FormState {
routeParams: LightAppRouteParam[];
}
const APP_TYPE_OPTIONS: { label: string; value: LightAppType }[] = [
{ label: "iframe 嵌套 (挂载到菜单)", value: "iframe" },
{ label: "window.open (新页跳转)", value: "window_open" },
];
const PARAM_TYPE_OPTIONS: { label: string; value: "login" | "theme" }[] = [
{ label: "登录参数", value: "login" },
{ label: "主题参数", value: "theme" },
];
const DIALOG_SELECT_POPUP = { attach: () => document.body };
const emptyForm = (): FormState => ({
name: "",
description: "",
@@ -98,6 +122,7 @@ export default function LightAppManagementPage() {
const [editing, setEditing] = useState<LightApp | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [activeParamPopover, setActiveParamPopover] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"table" | "cards">("table");
const saving = actions.create.isPending || actions.update.isPending;
@@ -180,109 +205,164 @@ export default function LightAppManagementPage() {
}
};
const handleCopyUrl = (url: string) => {
navigator.clipboard?.writeText(url);
toast.success("已复制地址");
};
const tableApps = useMemo(() => apps, [apps]);
return (
<div className="h-full overflow-y-auto bg-[#FAFAFA]">
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col space-y-6 px-6 py-6">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h1 className="text-[20px] font-bold tracking-tight text-[#222222]">应用管理</h1>
<p className="max-w-4xl text-[13px] leading-relaxed text-[#575757]">
注册轻应用。window.open 类型在「应用列表」以卡片展示;iframe 类型挂载到所选菜单下并注入侧栏。
<div className="h-full overflow-y-auto bg-neutral-50">
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col space-y-7 px-8 py-8">
<div className="flex flex-col justify-between gap-6 md:flex-row md:items-center">
<div className="space-y-1.5">
<h1 className="flex items-center text-xl font-black tracking-tight text-neutral-900">
应用管理
</h1>
<p className="max-w-2xl text-xs leading-relaxed text-neutral-500">
注册轻应用。
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
window.open
</code>
类型在「应用列表」以卡片展示;
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
iframe
</code>
类型挂载到所选菜单下并注入侧栏。
</p>
</div>
<button
onClick={openCreate}
className="flex h-9 cursor-pointer items-center space-x-1.5 rounded-[2px] bg-[#A8071A] px-4 text-[14px] font-bold text-white shadow-[0_2px_8px_rgba(34,34,34,0.08)] transition-all hover:bg-[#BC0D1E] active:bg-[#820014]"
className="flex cursor-pointer items-center justify-center space-x-1.5 rounded-[5px] bg-[#A8071A] px-5 py-2.5 text-xs font-semibold text-white shadow-md shadow-rose-950/10 transition-all hover:bg-[#BC0D1E] hover:shadow-lg hover:shadow-rose-900/15 active:bg-[#820014]"
>
<span>+ 注册应用</span>
<Plus className="h-4 w-4" />
<span>注册应用</span>
</button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[#C1C1C1]" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索应用名称 / 描述"
className="h-9 w-full rounded-[2px] border border-[#E8E8E8] bg-white pl-10 pr-4 text-[14px] text-[#222222] placeholder-[#C1C1C1] shadow-sm transition-all focus:border-[#F5222D] focus:outline-none focus:ring-1 focus:ring-[#F5222D]"
/>
{search && (
<div className="flex flex-col items-center gap-3.5 rounded-[5px] bg-white p-3 shadow-[0_1px_3px_rgba(0,0,0,0.01)] md:flex-row">
<div className="relative w-full flex-1">
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-400" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索应用名称 / 描述..."
className="w-full rounded-[5px] border border-neutral-100 bg-neutral-50/60 py-2 pl-10 pr-10 text-xs text-neutral-800 placeholder-neutral-400 transition-all hover:bg-white focus:border-[#F5222D]/40 focus:outline-none focus:ring-4 focus:ring-[#F5222D]/10"
/>
{search && (
<button
onClick={() => setSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer text-neutral-400 hover:text-neutral-700"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="flex shrink-0 items-center space-x-0.5 self-end rounded-[5px] border border-neutral-200/50 bg-neutral-100/60 p-0.5 md:self-auto">
<button
onClick={() => setSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer text-[12px] text-[#A6A6A6] hover:text-[#F5222D]"
onClick={() => setViewMode("table")}
className={`cursor-pointer rounded-[5px] px-4 py-1.5 text-xs font-bold transition-all ${
viewMode === "table"
? "bg-white text-[#F5222D] shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
清除
表格视图
</button>
)}
<button
onClick={() => setViewMode("cards")}
className={`cursor-pointer rounded-[5px] px-4 py-1.5 text-xs font-bold transition-all ${
viewMode === "cards"
? "bg-white text-[#F5222D] shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
卡片视图
</button>
</div>
</div>
<div className="overflow-visible rounded-[4px] border border-[#E8E8E8] bg-white shadow-[0_2px_8px_rgba(34,34,34,0.08)]">
<table className="w-full table-fixed border-collapse text-left">
<thead>
<tr className="border-b border-[#E8E8E8] bg-[#FAFAFA] text-[12px] font-bold uppercase tracking-wider text-[#A6A6A6]">
<th className="w-[18%] px-6 py-3.5">名称</th>
<th className="w-[12%] px-4 py-3.5">类型</th>
<th className="w-[34%] px-4 py-3.5">地址 / 挂载</th>
<th className="w-[12%] px-4 py-3.5">参数</th>
<th className="w-[12%] px-4 py-3.5">启用</th>
<th className="w-[12%] px-4 py-3.5 pr-6 text-right">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[#E8E8E8]">
<div className="space-y-4">
<div className="text-xs font-medium text-neutral-500">
搜寻到 <span className="font-bold text-[#F5222D]">{tableApps.length}</span>{" "}
个注册完成的子模块轻应用
</div>
{viewMode === "table" ? (
<div className="overflow-visible rounded-[5px] bg-white shadow-[0_1px_3px_0_rgba(0,0,0,0.01)]">
<table className="w-full table-fixed border-collapse text-left">
<thead>
<tr className="select-none border-b border-neutral-100/80 bg-neutral-50/40 text-[12.5px] font-semibold text-neutral-500">
<th className="w-[15%] px-6 py-4">名称</th>
<th className="w-[14%] px-4 py-4">类型</th>
<th className="w-[27%] px-4 py-4">地址</th>
<th className="w-[14%] px-4 py-4">挂载</th>
<th className="w-[9%] px-4 py-4">参数</th>
<th className="w-[9%] px-4 py-4">启用</th>
<th className="w-[12%] px-4 py-4 pr-6 text-center">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{tableApps.map((app) => (
<tr key={app.appId} className="group align-top transition-colors hover:bg-[#FEFAFA]">
<tr key={app.appId} className="group align-middle transition-colors hover:bg-neutral-50/40">
<td className="px-6 py-[18px]">
<div className="flex flex-col space-y-1">
<span className="text-[14px] font-bold text-[#222222] transition-colors group-hover:text-[#F5222D]">
{app.name}
</span>
{app.description ? (
<span className="line-clamp-2 text-[12px] text-[#575757]">{app.description}</span>
) : (
<span className="text-[11px] italic text-[#C1C1C1]">无描述说明</span>
)}
</div>
<span className="text-[14px] font-bold text-neutral-800 transition-colors group-hover:text-[#F5222D]">
{app.name}
</span>
</td>
<td className="px-4 py-[18px]">
{app.appType === "window_open" ? (
<span className="inline-flex select-none items-center space-x-1 rounded-[2px] border border-[#FDDADC] bg-[#FEEDED] px-2 py-0.5 text-[11px] font-medium text-[#F5222D] shadow-sm">
<ExternalLink className="h-3 w-3" />
<span className="inline-flex select-none items-center space-x-1.5 rounded-[5px] border border-rose-100 bg-rose-50 px-3 py-1 text-[12px] font-semibold text-rose-800">
<ExternalLink className="h-3.5 w-3.5" />
<span>新页跳转</span>
</span>
) : (
<span className="inline-flex select-none items-center space-x-1 rounded-[2px] border border-[#D4E9FF] bg-[#EAF4FF] px-2 py-0.5 text-[11px] font-medium text-[#0079FE] shadow-sm">
<Layers className="h-3 w-3" />
<span className="inline-flex select-none items-center space-x-1.5 rounded-[5px] border border-sky-100 bg-sky-50 px-3 py-1 text-[12px] font-semibold text-sky-800">
<Monitor className="h-3.5 w-3.5" />
<span>iframe 嵌套</span>
</span>
)}
</td>
<td className="overflow-hidden px-4 py-[18px]">
<div className="flex max-w-full flex-col space-y-1">
<div className="flex max-w-full items-center space-x-1.5 font-mono text-[12px] text-neutral-600 transition-colors group-hover:text-neutral-800">
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-1.5 truncate font-mono text-[13px] text-[#222222] hover:text-[#0079FE] hover:underline"
className="truncate hover:underline"
title={app.url}
>
<span className="truncate">{app.url}</span>
{app.url}
</a>
{app.appType === "iframe" && app.mountLocation && (
<div className="flex items-center space-x-1 text-[11px] text-[#A6A6A6]">
<span className="h-1.5 w-1.5 rounded-full bg-[#0079FE]" />
<span>挂载: {mountLabel(app.mountLocation)}</span>
</div>
)}
<button
onClick={() => handleCopyUrl(app.url)}
className="shrink-0 rounded-[5px] p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
title="复制网址"
>
<Copy className="h-3 w-3" />
</button>
</div>
</td>
<td className="px-4 py-[18px]">
{app.appType === "iframe" && app.mountLocation ? (
<span className="inline-flex max-w-full items-center space-x-1.5 rounded-[5px] bg-sky-50 px-2 py-0.5 text-[11px] font-medium text-sky-700">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
<span className="truncate">{mountLabel(app.mountLocation)}</span>
</span>
) : (
<span className="text-[12px] text-neutral-300">—</span>
)}
</td>
<td className="relative px-4 py-[18px]">
<div className="flex items-center select-none">
<div className="flex select-none items-center">
{app.routeParams.length > 0 ? (
<button
onClick={() =>
@@ -290,15 +370,17 @@ export default function LightAppManagementPage() {
activeParamPopover === app.appId ? null : app.appId
)
}
className="group/param inline-flex cursor-pointer items-center space-x-1 text-[14px] text-[#222222] transition-colors hover:text-[#0079FE]"
className="inline-flex cursor-pointer items-center space-x-1.5 rounded-[5px] border border-neutral-200/60 bg-neutral-50/60 px-2.5 py-1 text-xs font-medium text-neutral-500 transition-colors hover:bg-neutral-100"
>
<span className="font-medium">{app.routeParams.length}个</span>
<span className="text-[10px] text-[#A6A6A6] transition-colors group-hover/param:text-[#0079FE]">
▼
</span>
<span>{app.routeParams.length}个</span>
{activeParamPopover === app.appId ? (
<ChevronUp className="h-3.5 w-3.5 text-neutral-400" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-neutral-400" />
)}
</button>
) : (
<span className="text-[14px] text-[#A6A6A6]">0个</span>
<span className="text-xs text-neutral-400">0个</span>
)}
{activeParamPopover === app.appId && app.routeParams.length > 0 && (
@@ -307,23 +389,30 @@ export default function LightAppManagementPage() {
className="fixed inset-0 z-40"
onClick={() => setActiveParamPopover(null)}
/>
<div className="absolute left-4 top-11 z-50 mt-1 w-[280px] animate-in fade-in rounded-[4px] border border-[#E8E8E8] bg-white p-3 text-[12px] shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="mb-2 flex items-center justify-between border-b border-[#E8E8E8] pb-2 font-semibold text-[#3D3D3D]">
<span>路由参数明细</span>
<span className="rounded-sm bg-[#EAF4FF] px-1.5 py-0.5 text-[10px] text-[#0079FE]">
URL 拼接
<div className="absolute left-4 top-12 z-50 w-[280px] animate-in fade-in rounded-[5px] border border-neutral-100 bg-white p-3.5 text-[12px] shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="mb-2.5 flex items-center justify-between border-b border-neutral-100 pb-2">
<span className="text-[11px] font-bold uppercase tracking-wider text-neutral-400">
注入的路由参数
</span>
<button
onClick={() => setActiveParamPopover(null)}
className="text-[10px] font-semibold text-rose-700 hover:underline"
>
关闭
</button>
</div>
<div className="max-h-[160px] space-y-1.5 overflow-y-auto">
<div className="max-h-40 space-y-1.5 overflow-y-auto">
{app.routeParams.map((p, idx) => (
<div
key={`${app.appId}-${idx}`}
className="flex items-center justify-between rounded-sm bg-[#FAFAFA] p-1.5 font-mono hover:bg-[#FEF6F6]"
className="rounded-[5px] border border-neutral-100 bg-neutral-50/60 p-2 text-[11px] leading-relaxed"
>
<span className="font-bold text-[#F5222D]">{p.key}</span>
<span className="text-[11px] text-[#A6A6A6]">
({getParamSourceLabel(p)})
</span>
<div className="flex items-center justify-between gap-1 font-mono">
<span className="truncate font-bold text-rose-700">{p.key}</span>
<span className="truncate text-[10px] text-neutral-500">
{getParamSourceLabel(p)}
</span>
</div>
</div>
))}
</div>
@@ -337,36 +426,36 @@ export default function LightAppManagementPage() {
<div className="flex select-none items-center space-x-2">
{app.enabled ? (
<>
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#2FD69A] opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-[#2FD69A]" />
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
</span>
<span className="text-[13px] font-semibold text-[#189D72]">已启用</span>
<span className="text-[12.5px] font-bold text-emerald-700">已启用</span>
</>
) : (
<>
<span className="h-2 w-2 rounded-full bg-[#A6A6A6]" />
<span className="text-[13px] text-[#A6A6A6]">已禁用</span>
<span className="h-2.5 w-2.5 shrink-0 rounded-full bg-neutral-300" />
<span className="text-[12.5px] font-medium text-neutral-400">已停用</span>
</>
)}
</div>
</td>
<td className="px-4 py-[18px] pr-6 text-right">
<div className="inline-flex items-center justify-end space-x-2.5">
<td className="px-4 py-[18px] pr-6 text-center">
<div className="flex items-center justify-center space-x-1">
<button
onClick={() => openEdit(app)}
className="group/btn flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[4px] border border-[#D4E9FF] bg-[#EAF4FF] text-[#0079FE] transition-all duration-200 hover:scale-105 hover:border-[#0079FE] hover:bg-[#0079FE] hover:text-white active:scale-95"
className="cursor-pointer rounded-[5px] p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-emerald-600"
title="编辑配置"
>
<Pencil className="h-3.5 w-3.5 transition-transform duration-200 group-hover/btn:rotate-12" />
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(app)}
className="group/btn flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[4px] border border-[#FDDADC] bg-[#FEEDED] text-[#F5222D] transition-all duration-200 hover:scale-105 hover:border-[#F5222D] hover:bg-[#F5222D] hover:text-white active:scale-95"
className="cursor-pointer rounded-[5px] p-1.5 text-neutral-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
title="删除配置"
>
<Trash2 className="h-3.5 w-3.5 transition-transform duration-200 group-hover/btn:scale-110" />
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
@@ -375,17 +464,17 @@ export default function LightAppManagementPage() {
{tableApps.length === 0 && (
<tr>
<td colSpan={6} className="py-12 text-center">
<td colSpan={7} className="py-12 text-center">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#F5F5F5] text-[#C1C1C1]">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-300">
<Info className="h-6 w-6" />
</div>
<div className="space-y-1">
<p className="text-[14px] font-semibold text-[#A6A6A6]">
<p className="text-[14px] font-semibold text-neutral-400">
{isLoading ? "加载中..." : "暂无匹配的组件应用"}
</p>
{!isLoading && (
<p className="text-[12px] text-[#C1C1C1]">
<p className="text-[12px] text-neutral-300">
{search ? "请确认搜索关键词输入是否正确" : "点击右上角“注册应用”添加第一款轻应用"}
</p>
)}
@@ -396,25 +485,152 @@ export default function LightAppManagementPage() {
)}
</tbody>
</table>
</div>
) : tableApps.length === 0 ? (
<div className="rounded-[5px] bg-white py-20 text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-300">
<Info className="h-6 w-6" />
</div>
<p className="text-[14px] font-semibold text-neutral-400">
{isLoading ? "加载中..." : "暂无匹配的组件应用"}
</p>
{!isLoading && (
<p className="mt-1 text-[12px] text-neutral-300">
{search ? "请确认搜索关键词输入是否正确" : "点击右上角“注册应用”添加第一款轻应用"}
</p>
)}
</div>
) : (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{tableApps.map((app) => (
<div
key={app.appId}
className="group flex flex-col justify-between overflow-hidden rounded-[5px] bg-white shadow-[0_1px_3px_0_rgba(0,0,0,0.01)] transition-all hover:shadow-md"
>
<div
className={`h-1.5 w-full bg-gradient-to-r ${
app.appType === "window_open"
? "from-rose-600 to-amber-500"
: "from-indigo-600 to-sky-500"
}`}
/>
<div className="flex-1 space-y-3.5 p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1.5">
{app.appType === "window_open" ? (
<span className="inline-flex items-center space-x-1 rounded-[5px] border border-rose-100 bg-rose-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-rose-700">
<ExternalLink className="h-3 w-3" />
<span>新页跳转</span>
</span>
) : (
<span className="inline-flex items-center space-x-1 rounded-[5px] border border-sky-100 bg-sky-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-sky-700">
<Monitor className="h-3 w-3" />
<span>iframe 嵌套</span>
</span>
)}
<h4 className="line-clamp-1 text-sm font-bold leading-snug text-neutral-800 transition-colors group-hover:text-[#F5222D]">
{app.name}
</h4>
</div>
{app.enabled ? (
<span className="inline-flex shrink-0 items-center space-x-1 text-[11px] font-bold text-emerald-700">
<span className="h-2 w-2 rounded-full bg-emerald-500" />
<span>已启用</span>
</span>
) : (
<span className="inline-flex shrink-0 items-center space-x-1 text-[11px] font-medium text-neutral-400">
<span className="h-2 w-2 rounded-full bg-neutral-300" />
<span>已停用</span>
</span>
)}
</div>
<p className="line-clamp-2 min-h-[2.25rem] text-xs leading-relaxed text-neutral-500">
{app.description || "暂无应用详细描述说明。"}
</p>
<div className="flex items-center space-x-1.5 rounded-[5px] bg-neutral-50/60 p-2 font-mono text-[11px] text-neutral-600">
<Globe className="h-3.5 w-3.5 shrink-0 text-neutral-400" />
<span className="flex-1 truncate" title={app.url}>
{app.url}
</span>
<button
onClick={() => handleCopyUrl(app.url)}
className="shrink-0 rounded-[5px] p-1 text-neutral-400 transition-colors hover:bg-neutral-200 hover:text-neutral-600"
title="复制网址"
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
{app.routeParams.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{app.routeParams.map((p, idx) => (
<span
key={`${app.appId}-card-${idx}`}
className="rounded-[5px] border border-neutral-200/40 bg-neutral-100 px-1.5 py-0.5 font-mono text-[10px] text-neutral-500"
>
{p.key}
</span>
))}
</div>
)}
{app.appType === "iframe" && app.mountLocation && (
<div className="inline-flex items-center gap-1.5 rounded-[5px] bg-sky-50/60 p-2 text-[11px] font-medium leading-none text-sky-700">
<span>挂载分类:</span>
<code className="font-semibold">{mountLabel(app.mountLocation)}</code>
</div>
)}
</div>
<div className="flex items-center justify-end space-x-1 border-t border-neutral-100/60 bg-neutral-50/30 px-5 py-3">
<button
onClick={() => openEdit(app)}
className="cursor-pointer rounded-[5px] p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-emerald-600"
title="编辑配置"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(app)}
className="cursor-pointer rounded-[5px] p-1.5 text-neutral-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
title="删除配置"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="flex max-w-full select-none items-start space-x-3.5 rounded-[4px] border border-[#FFBB55] bg-[#FFF6EA] p-4 text-[13px] text-[#575757]">
<Info className="mt-0.5 h-5 w-5 flex-shrink-0 text-[#FF9900]" />
<div className="space-y-1">
<p className="font-bold text-[#222222]">📌 智能多维挂载规则:</p>
<ul className="list-inside list-disc space-y-1 pl-1">
<li>
当您注册 <strong>iframe 嵌套</strong>{" "}
应用时,您需要在菜单一栏指定分类(如“资源管理”),组件将直接作为新导航菜单项加入该分类。
{viewMode === "table" && (
<div className="select-none rounded-[5px] bg-amber-50/40 p-5 shadow-[0_1px_2px_rgba(0,0,0,0.005)]">
<div className="flex items-center space-x-2 border-b border-amber-100/50 pb-3 text-amber-800">
<AlertCircle className="h-4 w-4 shrink-0 text-amber-600" />
<h4 className="text-[13px] font-bold tracking-tight">智能多维挂载规则:</h4>
</div>
<ul className="mt-3.5 space-y-3 pl-1 text-[12px] leading-relaxed text-neutral-600">
<li className="flex items-start space-x-2">
<span className="mt-1 shrink-0 text-amber-600">•</span>
<span>
当您注册 <strong className="font-bold text-neutral-800">iframe 嵌套</strong>{" "}
应用时,您需要在菜单一栏指定分类(如“资源管理”),组件将直接作为新导航菜单项加入该分类。
</span>
</li>
<li>
当路由挂载成功,点击左侧注入的子栏名称可在主区域调起{" "}
<strong>真实 Iframe 预览器</strong>
,并自动将参数以加密/标准的QueryString传递过去。
<li className="flex items-start space-x-2">
<span className="mt-1 shrink-0 text-amber-600">•</span>
<span>
当路由挂载成功,点击左侧注入的子栏名称可在主区域调起{" "}
<strong className="font-bold text-neutral-800">真实 Iframe 预览器</strong>
,并自动将参数以加密/标准的QueryString传递过去。
</span>
</li>
</ul>
</div>
</div>
)}
</div>
{dialogOpen && (
@@ -424,14 +640,19 @@ export default function LightAppManagementPage() {
onClick={() => setDialogOpen(false)}
/>
<div className="relative z-[110] m-4 flex max-h-[90vh] w-full max-w-[580px] flex-col rounded-[4px] border border-[#E8E8E8] bg-white p-6 shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="relative z-[110] m-4 flex max-h-[90vh] w-full max-w-[580px] flex-col rounded-[5px] border border-[#E8E8E8] bg-white p-6 shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="flex flex-shrink-0 items-center justify-between border-b border-[#E8E8E8] pb-4">
<h2 className="text-[17px] font-bold text-[#222222]">
{editing ? "修改轻应用" : "注册应用"}
</h2>
<div className="flex items-center space-x-2.5">
<span className="rounded-[5px] bg-rose-50 p-1.5 text-[#A8071A]">
<Layers className="h-5 w-5" />
</span>
<h2 className="text-[17px] font-bold text-[#222222]">
{editing ? "修改轻应用" : "注册应用"}
</h2>
</div>
<button
onClick={() => setDialogOpen(false)}
className="cursor-pointer rounded-sm p-1 text-[#A6A6A6] transition-colors hover:bg-[#FAFAFA] hover:text-[#F5222D]"
className="cursor-pointer rounded-[5px] p-1 text-[#A6A6A6] transition-colors hover:bg-[#FAFAFA] hover:text-[#F5222D]"
>
<X className="h-5 w-5" />
</button>
@@ -439,84 +660,93 @@ export default function LightAppManagementPage() {
<div className="flex-1 space-y-4 overflow-y-auto py-4 pr-1 text-[#222222]">
<div className="space-y-1.5">
<label className="flex items-center text-[14px] font-medium">
<label className="flex items-center space-x-1.5 text-[14px] font-medium">
<span className="font-bold text-[#F5222D]">*</span>
<Layers className="h-4 w-4 text-[#A6A6A6]" />
<span>应用类型</span>
<span className="ml-1 text-[#F5222D]">*</span>
</label>
<select
<Select
className="w-full"
value={form.appType}
onChange={(e) => patchForm({ appType: e.target.value as LightAppType })}
className="h-9 w-full cursor-pointer rounded-[2px] border border-[#DBDBDB] bg-white px-3 text-[14px] focus:border-[#F5222D] focus:outline-none focus:ring-1 focus:ring-[#F5222D]"
>
<option value="iframe">iframe 嵌套 (挂载到菜单)</option>
<option value="window_open">window.open (新页跳转)</option>
</select>
options={APP_TYPE_OPTIONS}
popupProps={DIALOG_SELECT_POPUP}
onChange={(v) => patchForm({ appType: v as LightAppType })}
/>
<p className="text-[11.5px] leading-relaxed text-[#A6A6A6]">
{form.appType === "window_open"
? "在新标签页或卡片式路由容器打开。多用于外部高度复杂的系统跳转。"
: "通过标准 iframe 嵌入页面,可直接挂载到左侧导航树相应分类内。"}
</p>
</div>
<div className="space-y-1.5">
<label className="flex items-center text-[14px] font-medium">
<label className="flex items-center space-x-1.5 text-[14px] font-medium">
<span className="font-bold text-[#F5222D]">*</span>
<Tag className="h-4 w-4 text-[#A6A6A6]" />
<span>应用名称</span>
<span className="ml-1 text-[#F5222D]">*</span>
</label>
<Input
value={form.name}
onChange={(e) => patchForm({ name: e.target.value })}
placeholder="如: 舆情看板"
className="h-9 rounded-[2px] border-[#DBDBDB] text-[14px]"
className="h-9 rounded-[5px] border-[#DBDBDB] text-[14px]"
/>
</div>
<div className="space-y-1.5">
<label className="flex items-center text-[14px] font-medium">
<label className="flex items-center space-x-1.5 text-[14px] font-medium">
<span className="font-bold text-[#F5222D]">*</span>
<Link2 className="h-4 w-4 text-[#A6A6A6]" />
<span>应用地址</span>
<span className="ml-1 text-[#F5222D]">*</span>
</label>
<Input
value={form.url}
onChange={(e) => patchForm({ url: e.target.value })}
placeholder="https://example.com/page 或 http://47.88.25.99:3000/"
className="h-9 rounded-[2px] border-[#DBDBDB] font-mono text-[14px]"
className="h-9 rounded-[5px] border-[#DBDBDB] font-mono text-[14px]"
/>
</div>
{form.appType === "iframe" && (
<div className="animate-in fade-in space-y-1.5">
<label className="flex items-center text-[14px] font-medium">
<label className="flex items-center space-x-1.5 text-[14px] font-medium">
<span className="font-bold text-[#F5222D]">*</span>
<FolderTree className="h-4 w-4 text-[#A6A6A6]" />
<span>挂载菜单</span>
<span className="ml-1 text-[#F5222D]">*</span>
</label>
<select
value={form.mountLocation}
onChange={(e) => patchForm({ mountLocation: e.target.value })}
className="h-9 w-full cursor-pointer rounded-[2px] border border-[#DBDBDB] bg-white px-3 text-[14px] focus:border-[#F5222D] focus:outline-none focus:ring-1 focus:ring-[#F5222D]"
>
<option value="">请选择挂载菜单</option>
{MOUNT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<Select
className="w-full"
value={form.mountLocation || undefined}
placeholder="请选择挂载菜单"
options={MOUNT_OPTIONS}
popupProps={DIALOG_SELECT_POPUP}
onChange={(v) => patchForm({ mountLocation: (v as string) ?? "" })}
/>
</div>
)}
<div className="space-y-1.5">
<label className="text-[14px] font-medium">应用描述 (可选)</label>
<label className="flex items-center space-x-1.5 text-[14px] font-medium">
<span className="w-2 shrink-0" />
<AlignLeft className="h-4 w-4 text-[#A6A6A6]" />
<span>应用描述 (可选)</span>
</label>
<Textarea
value={form.description}
onChange={(e) => patchForm({ description: e.target.value })}
placeholder="简要说明该应用的用途"
rows={2}
className="resize-none rounded-[2px] border-[#DBDBDB] text-[14px]"
className="resize-none rounded-[5px] border-[#DBDBDB] text-[14px]"
/>
</div>
<div className="space-y-2.5 pt-1.5">
<div className="flex items-center justify-between">
<span className="flex items-center text-[14px] font-medium">
<span className="flex items-center space-x-1.5 text-[14px] font-medium">
<Sliders className="h-4 w-4 text-[#A6A6A6]" />
<span>路由参数</span>
<span
className="ml-1 inline-flex"
className="inline-flex"
title="参数会在加载此应用时作为 query 参数拼接在 URL 的尾部"
>
<HelpCircle className="h-3.5 w-3.5 text-[#A6A6A6]" />
@@ -525,7 +755,7 @@ export default function LightAppManagementPage() {
<button
type="button"
onClick={addParam}
className="flex h-7 cursor-pointer items-center space-x-1 rounded-[2px] border border-[#DBDBDB] bg-white px-2.5 text-[12px] font-bold text-[#575757] transition-colors hover:border-[#F5222D] hover:text-[#F5222D]"
className="flex h-7 cursor-pointer items-center space-x-1 rounded-[5px] border border-[#DBDBDB] bg-white px-2.5 text-[12px] font-bold text-[#575757] transition-colors hover:border-[#F5222D] hover:text-[#F5222D]"
>
<Plus className="h-3.5 w-3.5" />
<span>添加路由参数</span>
@@ -533,14 +763,14 @@ export default function LightAppManagementPage() {
</div>
{form.routeParams.length > 0 ? (
<div className="max-h-[220px] space-y-2 overflow-y-auto rounded-sm border border-[#E8E8E8] bg-[#FAFAFA] p-1">
<div className="max-h-[220px] space-y-2 overflow-y-auto rounded-[5px] border border-[#E8E8E8] bg-[#FAFAFA] p-1">
{form.routeParams.map((p, idx) => {
const themeMap =
p.paramType === "theme" ? parseThemeMap(p.themeValue) : {};
return (
<div
key={idx}
className="relative rounded-sm border border-[#E8E8E8] bg-white p-2"
className="relative rounded-[5px] border border-[#E8E8E8] bg-white p-2"
>
<div className="flex items-center space-x-2">
<div className="flex-1">
@@ -549,40 +779,36 @@ export default function LightAppManagementPage() {
value={p.key}
onChange={(e) => patchParam(idx, { key: e.target.value })}
placeholder="参数名 key"
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
className="h-8 w-full rounded-[5px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
/>
</div>
<div className="w-[110px]">
<select
<Select
className="w-full"
size="small"
value={p.paramType}
onChange={(e) =>
options={PARAM_TYPE_OPTIONS}
popupProps={DIALOG_SELECT_POPUP}
onChange={(v) =>
patchParam(idx, {
paramType: e.target.value as "login" | "theme",
source:
e.target.value === "login" ? (p.source || "accessToken") : undefined,
paramType: v as "login" | "theme",
source: v === "login" ? (p.source || "accessToken") : undefined,
})
}
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-[#FAFAFA] px-1 text-[12px] focus:border-[#F5222D] focus:outline-none"
>
<option value="login">登录参数</option>
<option value="theme">主题参数</option>
</select>
/>
</div>
{p.paramType === "login" && (
<div className="flex flex-[1.2] items-center gap-2 text-[11px]">
<select
<Select
className="w-full"
size="small"
value={p.source || "accessToken"}
onChange={(e) => patchParam(idx, { source: e.target.value })}
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-white px-1 text-[12px] focus:border-[#F5222D] focus:outline-none"
>
{LOGIN_SOURCE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
options={LOGIN_SOURCE_OPTIONS}
popupProps={DIALOG_SELECT_POPUP}
onChange={(v) => patchParam(idx, { source: v as string })}
/>
{p.source === "other" && (
<input
type="text"
@@ -591,7 +817,7 @@ export default function LightAppManagementPage() {
patchParam(idx, { customValue: e.target.value })
}
placeholder="自定义值"
className="h-8 w-[120px] rounded-[2px] border border-[#DBDBDB] px-2 text-[12px] focus:border-[#F5222D] focus:outline-none"
className="h-8 w-[120px] rounded-[5px] border border-[#DBDBDB] px-2 text-[12px] focus:border-[#F5222D] focus:outline-none"
/>
)}
</div>
@@ -600,7 +826,7 @@ export default function LightAppManagementPage() {
<button
type="button"
onClick={() => removeParam(idx)}
className="cursor-pointer rounded-sm p-1 text-[#A6A6A6] transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D]"
className="cursor-pointer rounded-[5px] p-1 text-[#A6A6A6] transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D]"
>
<X className="h-4 w-4" />
</button>
@@ -608,7 +834,7 @@ export default function LightAppManagementPage() {
{/* 主题参数:逐主题色配置传给目标应用的值(左=系统主题色,右=目标应用值,可改) */}
{p.paramType === "theme" && (
<div className="mt-2 space-y-1.5 rounded-sm bg-[#FAFAFA] p-2">
<div className="mt-2 space-y-1.5 rounded-[5px] bg-[#FAFAFA] p-2">
<p className="text-[11px] leading-relaxed text-[#A6A6A6]">
各系统主题色跳转/嵌套时传给该应用的值(默认同名,可按对方命名修改)
</p>
@@ -636,7 +862,7 @@ export default function LightAppManagementPage() {
})
}
placeholder={c.key}
className="h-7 flex-1 rounded-[2px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
className="h-7 flex-1 rounded-[5px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
/>
</div>
))}
@@ -647,7 +873,7 @@ export default function LightAppManagementPage() {
})}
</div>
) : (
<div className="rounded-sm border border-dashed border-[#E8E8E8] bg-[#FAFAFA] p-4 text-center text-[12px] text-[#A6A6A6]">
<div className="rounded-[5px] border border-dashed border-[#E8E8E8] bg-[#FAFAFA] p-4 text-center text-[12px] text-[#A6A6A6]">
暂无注入的路由参数,加载地址时不传递多余拼接。
</div>
)}
@@ -676,7 +902,7 @@ export default function LightAppManagementPage() {
variant="outline"
disabled={saving}
onClick={() => setDialogOpen(false)}
className="h-9 rounded-[2px] border-[#DBDBDB] px-[18px] text-[14px] font-semibold"
className="h-9 rounded-[5px] border-[#DBDBDB] px-[18px] text-[14px] font-semibold"
>
取消
</Button>
@@ -684,7 +910,7 @@ export default function LightAppManagementPage() {
type="button"
disabled={saving}
onClick={handleSave}
className="h-9 cursor-pointer rounded-[2px] bg-[#F5222D] px-[18px] text-[14px] font-bold text-white shadow-[0_2px_8px_rgba(34,34,34,0.08)] transition-colors hover:bg-[#CF1322] active:bg-[#A8071A] disabled:cursor-not-allowed disabled:opacity-70"
className="h-9 cursor-pointer rounded-[5px] bg-[#F5222D] px-[18px] text-[14px] font-bold text-white shadow-[0_2px_8px_rgba(34,34,34,0.08)] transition-colors hover:bg-[#CF1322] active:bg-[#A8071A] disabled:cursor-not-allowed disabled:opacity-70"
>
{saving ? "保存中..." : editing ? "保存修改" : "立即注册"}
</button>
+5 -1
View File
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useTheme } from '@/contexts/ThemeContext';
import { loginByToken, loginByUsername } from '@/core/auth/api';
import { setStoredAuth } from '@/core/auth';
import { setStoredAuth, setStoredUserName } from '@/core/auth';
import {
persistExternalLoginParams,
readExternalLoginParams,
@@ -114,6 +114,7 @@ export default function LoginPage() {
setErrorMessage('');
try {
let auth;
let loginUserName = '';
if (authToken) {
// token 换登录:后端用 authToken 调上游接口换出用户名再登录
auth = await loginByToken(authToken);
@@ -128,11 +129,14 @@ export default function LoginPage() {
}
auth = await loginByUsername(deerflowUsername, gatePassword);
loginUserName = deerflowUsername;
}
if (cancelled) {
return;
}
setStoredAuth(auth);
// 标准登录把用户名写入 userInfo,供顶栏用户区显示(已有则不覆盖)
setStoredUserName(loginUserName);
persistSidebarEnabled(sidebarEnabled);
setStatus('success');
router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath);
+9 -1
View File
@@ -7,6 +7,7 @@ import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-rout
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { useTheme } from "@/contexts/ThemeContext";
import { useLocation } from "react-router-dom";
import { useLocalSettings } from "@/core/settings";
import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes";
@@ -21,8 +22,15 @@ function PageLayout() {
const location = useLocation();
const { theme } = useTheme();
const isDark = theme === "dark";
const [settings] = useLocalSettings();
// 界面模式开关(外观设置):用户显式选「全量/简洁」时以其为准;未设置过则回退到
// 会话级 sidebarEnabled(外部门户/yUserId 登录时置位)这一旧默认。这样无论是否走门户
// 登录,「全量↔简洁」切换都能真正控制顶栏+侧边栏的显示。
const layoutMode = settings.display.layoutMode;
const chromeByPreference =
layoutMode === "full" ? true : layoutMode === "compact" ? false : sidebarEnabled;
const pageSidebarAllowed =
sidebarEnabled && !isChatsIframeWorkspacePath(location.pathname);
chromeByPreference && !isChatsIframeWorkspacePath(location.pathname);
// h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100%
// 高度链,根布局跟随容器高度。全屏下 100% 解析到视口,与 h-screen 等价;无界等嵌套
+3 -1
View File
@@ -108,8 +108,10 @@ export function WorkspaceLayout({
'--header-height': headerHidden ? '0rem' : '4rem',
} as CSSProperties
}
key={String(compactWorkspaceSidebar)}
data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen
// 紧凑模式下历史侧栏默认收起(聊天区更宽),由聊天页右上角图标钮展开
defaultOpen={!compactWorkspaceSidebar}
>
{!workspaceSidebarHidden && (
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
@@ -6,8 +6,8 @@
* - footer「配置」按钮 → 进入配置页(空白新建,等价于旧按钮的直接跳转)。
*
* 纯导航 / 浏览用途,不参与研讨席位选择(席位选择走 `BusinessChainPicker`)。
* 卡片席位名直接用落库快照(`seat.name`),无需实时校验,点「设置」进编辑页后
* 会看到实时数据。
* 卡片席位名优先用实时 agent 数据(`listAgents()`,与 `BusinessChainPicker` 一致),
* agent 改名后此弹窗也能同步显示;解析不到才回退到落库快照(`seat.name`)。
*/
import { Fragment, useCallback, useEffect, useState } from "react";
import {
@@ -30,6 +30,8 @@ import {
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { listAgents } from "@/core/agents";
import { listChains, setChainPublic, type ChainScope } from "../api/chains";
import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
@@ -71,6 +73,27 @@ export function BusinessChainListBody({
// 筛选:我的 / 公共(已发布)。
const [scope, setScope] = useState<ChainScope>("mine");
const [publishingId, setPublishingId] = useState<string | null>(null);
// 实时名称表(id 小写 → 最新 name):用于把席位的落库快照名刷新成最新(agent 改名同步)。
const [nameById, setNameById] = useState<Map<string, string>>(new Map());
useEffect(() => {
if (!active) return;
let cancelled = false;
void (async () => {
try {
const agents = await listAgents();
if (cancelled) return;
const m = new Map<string, string>();
for (const a of agents) if (a?.id) m.set(a.id.toLowerCase(), a.name ?? a.id);
setNameById(m);
} catch {
// 静默失败:解析不到就回退到席位快照名。
}
})();
return () => {
cancelled = true;
};
}, [active]);
const refresh = useCallback(async (s: ChainScope) => {
setLoading(true);
@@ -171,6 +194,7 @@ export function BusinessChainListBody({
<ChainRow
key={chain.id}
chain={chain}
nameById={nameById}
publishing={publishingId === chain.id}
onSettings={() => onConfigure(chain.id)}
onTogglePublic={() => handleTogglePublic(chain)}
@@ -218,11 +242,13 @@ export function BusinessChainListDialog({
function ChainRow({
chain,
nameById,
publishing,
onSettings,
onTogglePublic,
}: {
chain: BusinessChain;
nameById: Map<string, string>;
publishing: boolean;
onSettings: () => void;
onTogglePublic: () => void;
@@ -292,14 +318,20 @@ function ChainRow({
)}
{/* 席位编排:按 stage 分组——并行阶段竖向并列在虚线框里,阶段之间用 → 串联。 */}
<ChainSeatsFlow chain={chain} />
<ChainSeatsFlow chain={chain} nameById={nameById} />
</div>
);
}
// ── 链条席位编排流(体现「同层并行 · 层间串行」)─────────────────────────────
function ChainSeatsFlow({ chain }: { chain: BusinessChain }) {
function ChainSeatsFlow({
chain,
nameById,
}: {
chain: BusinessChain;
nameById: Map<string, string>;
}) {
// 名称回退表:用落库快照(seats)解析 stage 里的 agent_id。
const seatById = new Map(chain.seats.map((s) => [s.agent_id, s] as const));
@@ -322,6 +354,8 @@ function ChainSeatsFlow({ chain }: { chain: BusinessChain }) {
const seatTag = (agentId: string) => {
const seat = seatById.get(agentId);
// 优先实时名称(agent 改名同步)→ 落库快照 → agent_id。
const label = nameById.get(agentId.toLowerCase()) ?? seat?.name ?? agentId;
return (
<span
key={agentId}
@@ -330,7 +364,7 @@ function ChainSeatsFlow({ chain }: { chain: BusinessChain }) {
chainTagClasses(getAvatarTypeFor(agentId, orderById.get(agentId) ?? 0)),
)}
>
{seat?.name || agentId}
{label}
</span>
);
};
@@ -250,9 +250,10 @@ export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDi
grabbing ? "cursor-grabbing" : "cursor-grab",
)}
style={{
// 点阵背景:放大点尺寸(参考「业务链条配置器」设计稿,点更明显)。
backgroundImage:
"radial-gradient(circle, rgba(148,163,184,0.18) 1px, transparent 1px)",
backgroundSize: "24px 24px",
"radial-gradient(circle, rgba(148,163,184,0.4) 1.3px, transparent 1.3px)",
backgroundSize: "22px 22px",
}}
onPointerDown={(e) => {
if (e.button !== 0) return;
@@ -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,13 @@ 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,
renderDashboardHtml,
stripReportFence,
type DashboardTheme,
} from "@/roundtable-planning/lib/report-template";
import { useTheme } from "@/contexts/ThemeContext";
interface Step3DashboardModeProps {
selectedModel: string;
@@ -65,6 +74,7 @@ function formatNow(): string {
}
type DashView = "chat" | "page";
type RenderMode = "app" | "html";
export function Step3DashboardMode({
selectedModel,
@@ -90,23 +100,64 @@ export function Step3DashboardMode({
openDashboardPreview,
} = dashboard;
// 真实数据 → 大屏数据:优先本轮实时生成,其次草稿快照;都没有则 undefined(用 DEMO)。
const dashboardData = useMemo(() => {
const report = dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null);
return report ? adaptReportData(report, formatNow()) : undefined;
}, [dashboard.data, savedReport?.dashboardJson]);
// 真实数据(report-json):优先本轮实时生成,其次草稿快照。应用渲染/网页渲染共用。
const report = useMemo(
() => dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null),
[dashboard.data, savedReport?.dashboardJson],
);
const hasResult = !!dashboardData || !!html;
// 应用渲染用的大屏数据(不传则 DashboardApp 回落 DEMO)。
const dashboardData = useMemo(
() => (report ? adaptReportData(report, formatNow()) : undefined),
[report],
);
// 网页渲染主题跟随应用主题:renderDashboardHtml 是确定性的,主题切换时按 report
// 当场重渲(而非用 hook 在生成时烤死主题的 `html`),从而 iframe 大屏色随父元素变化。
// 仅 light/dark 两套(lightblue 归 light,与页面 themeMode 映射一致)。
const { theme } = useTheme();
const dashThemeMode: DashboardTheme = theme === "dark" ? "dark" : "light";
const dashboardHtml = useMemo(
() =>
report
? renderDashboardHtml(report, { generatedAt: formatNow(), theme: dashThemeMode })
: html,
[report, dashThemeMode, html],
);
const hasResult = !!dashboardData || !!dashboardHtml;
// 视图:有产物默认看「大屏页面」,否则进「交互区」看抽取过程。
const [view, setView] = useState<DashView>(() => (hasResult ? "page" : "chat"));
// 完成即自动切到大屏页面(含问答修订后的再次完成)。
const prevPhaseRef = useRef(phase);
// 大屏页面的两种渲染:
// - "app" 应用渲染:用应用内 React 组件(DashboardApp)渲染,交互更丰富(Tab/动画/钻取)。
// - "html" 网页渲染:直接把自包含 HTML 大屏塞进 iframe,与「新窗口打开 / 分享 / 下载」一致。
const [renderMode, setRenderMode] = useState<RenderMode>("app");
// 网页渲染:用 blob URL 作 iframe `src`(而非 `srcDoc`)。
// 原因:`srcdoc` 文档的 base URL 继承自父页面,大屏内的纯 CSS `:target` 导航锚点
// (#overview / #seat-x)会被解析成「父页面URL#seat-x」,导致点击导航时整个 iframe
// 跳去加载主应用并命中 HashRouter 的 404(Page not found)。blob: 文档有自己的 URL,
// 片段锚点在 iframe 内部解析,:target 切页正常 —— 与「新窗口打开」一致。
//
// 必须在 **effect 内部**创建/回收 blob:StrictMode(dev) 会 setup→cleanup→setup 双触发
// effect,若用 useMemo 跨两次 render 共享同一个 URL,cleanup 会把它 revoke 掉而 iframe
// 仍指向它 → 报「该文件可能已被移至别处」。effect 内每次 run 各自建 URL、cleanup 只回收
// 本 run 的,最后提交的那个存活,故 StrictMode-safe。dashboardHtml 随主题变 → blob 重建。
const [htmlBlobUrl, setHtmlBlobUrl] = useState<string | null>(null);
useEffect(() => {
if (phase === "done" && prevPhaseRef.current !== "done") setView("page");
prevPhaseRef.current = phase;
}, [phase]);
if (typeof window === "undefined" || !dashboardHtml) {
setHtmlBlobUrl(null);
return;
}
const url = URL.createObjectURL(new Blob([dashboardHtml], { type: "text/html;charset=utf-8" }));
setHtmlBlobUrl(url);
return () => URL.revokeObjectURL(url);
}, [dashboardHtml]);
// 绘制完成**不再**自动切到大屏页面:用户正在交互区看抽取过程/继续问答时,强行跳走很打断。
// 想看成品由用户手动点顶栏「查看大屏」。没有产物时初始即落交互区(见上 useState 初值)。
// 仅在交互区允许本流自动接管沙箱(并行流防抢);离开/卸载时让出。
useEffect(() => {
@@ -114,10 +165,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" && dashboardHtml) {
const blob = new Blob([dashboardHtml], { 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 +198,9 @@ export function Step3DashboardMode({
window.open(url, "_blank", "noopener,noreferrer");
};
const handleRedraw = () => {
// 「编辑」:回到交互区接着上次的聊天记录继续问答修改(不丢弃上下文、不重画)。
const handleEdit = () => {
setView("chat");
redraw();
};
const isStreaming =
@@ -160,19 +223,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={!dashboardHtml}
title={
dashboardHtml
? "用自包含 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 +315,18 @@ export function Step3DashboardMode({
{/* 主体:交互区(聊天 + 抽取沙箱) / 大屏页面(React) */}
{view === "page" ? (
<div className="flex-1 min-h-0 overflow-auto">
<DashboardApp data={dashboardData} />
</div>
renderMode === "html" && htmlBlobUrl ? (
<iframe
title="方案研讨大屏"
src={htmlBlobUrl}
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 +397,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 +491,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>
))}
@@ -25,6 +25,19 @@
--dash-accent: #13c2c2; /* 辅色 zs-003-06 */
}
/* 深色主题下「大屏页面」整体背景换成设计稿深蓝主题底图(应用渲染模式)。
* 未分层规则优先级高于 @layer utilities,稳定覆盖 DashboardApp 根 className 上的
* `dark:bg-[var(--dash-page)]` 底色与 `dark:[background-image:radial-gradient(...)]`
* 深蓝点阵 —— 浅色模式不受影响(仅 .dark 生效)。底色保留作图片加载前/超出区域回落。 */
.dark .dash-root {
background-color: var(--dash-page);
background-image: url("../../assets/img/layout-bg-theme-dark-blue.svg");
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
background-attachment: fixed;
}
/* 深色下网格 overlay 改为极淡深蓝描线(浅色仍走上方 .bg-grid-slate-100)。 */
.dark .dash-root .bg-grid-slate-100 {
background-image:
@@ -42,6 +55,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;
}
@@ -341,6 +341,13 @@ export interface UseStep2OrchestrationReturn {
hasConsensus: boolean;
lastLeaderContent: string;
}) => void;
/**
* 「后台挂起接管」(2026-06-13):点「查看」一个运行中的后台作业并取消它、
* `hydrateFromDraft` 恢复作业真实线程 + 完整对话后调用 —— 在那些线程上**由前端
* 继续实时流式编排**(leader 自由派活续轮,不从头重跑 DAG)。返回是否成功接管
* (无线程时返回 false,调用方据此回退到只读查看)。
*/
resumeLiveTakeover: () => boolean;
/** 「新建任务」清空 Step 2 数据(threadIds / dialogues / 共识等)。 */
resetStep2State: () => void;
/** 当前沙箱预览绑定的席位 thread id。 */
@@ -2634,6 +2641,11 @@ export function useStep2Orchestration(
}
setThreadIds(snapshot.threadIds);
setCoordinatorName(snapshot.coordinatorName);
// 同步把线程/协调名写进 ref(平时由 [threadIds]/[coordinatorName] 的 effect 异步回写,
// 滞后一拍)。后台挂起「接管续跑」会在 hydrate 之后**同帧**调 resumeLiveTakeover,
// 需要 ref 立刻可读到作业的真实线程,否则读到旧值导致接管失败。
threadIdsRef.current = snapshot.threadIds;
coordinatorNameRef.current = snapshot.coordinatorName;
setStep2RoundtableDialogues(snapshot.step2RoundtableDialogues);
setLastLeaderContent(snapshot.lastLeaderContent);
setHasConsensus(snapshot.hasConsensus);
@@ -2671,6 +2683,32 @@ export function useStep2Orchestration(
[],
);
// 「后台挂起接管」:取消后端作业 + hydrateFromDraft(恢复作业真实线程/协调名/对话)后,
// 由前端在这些线程上**继续实时流式编排**。续跑走 leader 自由派活循环(把 dagPlanExecuted
// 置 true 避免从头重跑整张 DAG),并清掉残留的取消/编排锁让新一轮立刻起跑——
// 与 handleInjectToRoundtable 末尾「直接启动新一轮编排」同构。
const resumeLiveTakeover = useCallback((): boolean => {
if (!threadIdsRef.current || !coordinatorNameRef.current) {
triggerToast("⚠️ 接管失败:未找到研讨线程,已切换为只读查看");
return false;
}
dagPlanExecutedRef.current = true;
orchestrationCancelRef.current = true;
abortAllInFlight();
isOrchestratingRef.current = false;
setIsOrchestrating(false);
setPendingClarification(null);
setClarificationDraft("");
setIsRoundTableRunning(true);
void runActiveOrchestration(
"用户已从后台挂起接管本次研讨,请基于现有进展继续上一轮的协调与派活工作;若研讨已可收口则直接综合输出结论。",
threadIdsRef.current,
coordinatorNameRef.current,
);
triggerToast("🎮 已接管至前端,继续实时研讨");
return true;
}, [abortAllInFlight, runActiveOrchestration, triggerToast]);
const resetStep2State = useCallback(() => {
orchestrationCancelRef.current = true;
abortAllInFlight();
@@ -2749,6 +2787,7 @@ export function useStep2Orchestration(
handleSaveAgentAndRecalculate,
hydrateFromDraft,
applyLiveJobFrame,
resumeLiveTakeover,
resetStep2State,
activeArtifactThreadId,
seatStubMessages,
@@ -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],
);
@@ -33,6 +33,8 @@
* 文件头嵌入 `RT_DASHBOARD_MARKER` 注释,恢复草稿时据此识别「这是模板大屏」。
*/
import darkBgSvgRaw from "@/assets/img/layout-bg-theme-dark-blue.svg?raw";
// ── 契约类型 ─────────────────────────────────────────────────────────────────
export interface ReportKpi {
@@ -414,6 +416,12 @@ interface Palette {
link: string;
titleColor: string;
navBg: string;
/** 深色主题大屏背景图(自包含 HTML 内联 data URI);浅色为空串走纯色底。 */
bgImage: string;
/** 导航选中态 tab 背景(浅=白,深=深蓝卡片色)。 */
navActiveBg: string;
/** 导航 tab 悬停背景。 */
tabHoverBg: string;
trackBg: string;
/** 图表 / KPI / 席位强调色序(规范图表色序)。 */
accents: string[];
@@ -421,6 +429,16 @@ interface Palette {
modalMask: string;
}
/**
* 深色主题大屏背景图:从 SVG(内嵌 JPEG)里取出其 data URI,内联进自包含 HTML
* 当背景图用 —— 避免 iframe srcDoc / blob 新窗口 / 下载分享场景下相对路径取不到图
* (保持离线自包含;草稿存的是 report-json 不是 HTML,不会被这张图撑大)。
*/
const DARK_BG_DATA_URI: string = (() => {
const m = /href="(data:image\/[^"]+)"/.exec(darkBgSvgRaw);
return m ? m[1] : "";
})();
const PALETTES: Record<DashboardTheme, Palette> = {
dark: {
bgPage: "#0C1836",
@@ -438,7 +456,10 @@ const PALETTES: Record<DashboardTheme, Palette> = {
primary: "#1677FF",
link: "#4096FF",
titleColor: "linear-gradient(90deg,#91CAFF,#4096FF 45%,#13C2C2)",
navBg: "rgba(6,14,36,.88)",
navBg: "rgba(10,20,46,.78)",
bgImage: DARK_BG_DATA_URI,
navActiveBg: "#122347",
tabHoverBg: "rgba(64,150,255,.12)",
trackBg: "rgba(110,136,176,.22)",
accents: ["#4096FF", "#13C2C2", "#52C41A", "#FAAD14", "#FF7875", "#69B1FF"],
risk: { high: "#F5222D", mid: "#FAAD14", low: "#52C41A" },
@@ -462,7 +483,10 @@ const PALETTES: Record<DashboardTheme, Palette> = {
primary: "#E11D48",
link: "#E11D48",
titleColor: "linear-gradient(90deg,#1E293B,#334155)",
navBg: "rgba(255,255,255,.95)",
navBg: "#F1F5F9",
bgImage: "",
navActiveBg: "#FFFFFF",
tabHoverBg: "rgba(255,255,255,.6)",
trackBg: "#F1F5F9",
accents: ["#E11D48", "#4F46E5", "#059669", "#D97706", "#0284C7", "#7C3AED"],
risk: { high: "#DC2626", mid: "#D97706", low: "#059669" },
@@ -684,7 +708,7 @@ export function renderDashboardHtml(
const ovBase = `body:not(:has(.page:target)) ${sel("overview")},body:has(#overview:target) ${sel("overview")}`;
const seatBase = seats.map((s) => `body:has(#${esc(s.id)}:target) ${sel(s.id)}`).join(",");
const allActive = seatBase ? `${ovBase},${seatBase}` : ovBase;
const navActiveCss = `${allActive}{background:#fff;color:var(--primary);box-shadow:0 1px 5px rgba(15,23,42,.1);font-weight:700;border-color:${P.primary}33}`;
const navActiveCss = `${allActive}{background:var(--navactive);color:var(--primary);box-shadow:0 1px 5px rgba(15,23,42,.1);font-weight:700;border-color:${P.primary}33}`;
const KPI_ICONS = ["📊", "📈", "⚠️", "🧩", "🏆", "📅"];
const kpis = data.kpis
@@ -750,36 +774,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("");
@@ -800,10 +817,11 @@ export function renderDashboardHtml(
<style>
/* 蓝图工程图风模板(light=参考大屏设计图 / dark=深蓝 token,渲染时注入 palette) */
:root{--line:${P.cardBorder};--txt:${P.text};--dim:${P.textSecondary};--strong:${P.textStrong};
--primary:${P.primary};--link:${P.link};--heading:${P.heading};--track:${P.trackBg};--bracket:${P.bracket}}
--primary:${P.primary};--link:${P.link};--heading:${P.heading};--track:${P.trackBg};--bracket:${P.bracket};
--navbg:${P.navBg};--navactive:${P.navActiveBg};--tabhover:${P.tabHoverBg}}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:"Inter","Noto Sans SC","Microsoft YaHei",system-ui,sans-serif;color:var(--txt);min-height:100vh;
font-size:13px;background:${P.bgPage}}
font-size:13px;background:${P.bgImage ? `url("${P.bgImage}") center top/cover no-repeat fixed,` : ""}${P.bgPage}}
/* 蓝图圆点网格底纹(对齐设计原图的 radial dots) */
body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:0;
background-image:radial-gradient(${P.gridLine} 1.3px,transparent 1.3px);background-size:20px 20px}
@@ -819,10 +837,10 @@ main{position:relative;z-index:1;padding:20px 26px 48px;max-width:1500px;margin:
background:#fff;border:1px solid var(--line);border-radius:6px;padding:6px 12px;white-space:nowrap}
/* Nav:浅灰圆角胶囊容器 + 图标文字 tab(对齐设计原图) */
nav{position:relative;z-index:1;margin-bottom:22px}
.navwrap{display:inline-flex;flex-wrap:wrap;gap:6px;background:var(--track);border:1px solid var(--line);border-radius:10px;padding:5px}
.navwrap{display:inline-flex;flex-wrap:wrap;gap:6px;background:var(--navbg);border:1px solid var(--line);border-radius:10px;padding:5px;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}
.tab{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;padding:6px 12px;
border:1px solid transparent;border-radius:7px;color:var(--dim);text-decoration:none;transition:.15s;background:transparent}
.tab:hover{color:var(--strong);background:rgba(255,255,255,.6)}
.tab:hover{color:var(--strong);background:var(--tabhover)}
.tab svg{width:14px;height:14px;flex:none}
.page{display:none;animation:fi .25s ease}
#overview{display:block}
@@ -948,16 +966,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>
@@ -140,6 +140,11 @@ export default function BusinessChainEditorPage() {
// ─── 数据 ────────────────────────────────────────────────────────────
const [pool, setPool] = useState<RecommendCandidate[]>([]);
const [poolLoading, setPoolLoading] = useState(true);
// 名称解析专用:一次性拉「全量」智能体(不受左栏搜索/标签过滤影响),用于把链条里
// 落库的名称快照刷新成最新名称——agent 改名后此页能同步显示。解析不到才回退到快照。
const [agentLiveById, setAgentLiveById] = useState<
Map<string, { name: string; description?: string }>
>(new Map());
const [chains, setChains] = useState<BusinessChain[]>([]);
// 左栏检索/筛选 —— 服务端过滤(listAgents 支持 search + tag_ids)。
const [search, setSearch] = useState("");
@@ -185,6 +190,22 @@ export default function BusinessChainEditorPage() {
);
const seatCount = useMemo(() => countSeats(stageGroups), [stageGroups]);
// 展示用 stageGroups:把每个席位的名称/描述刷新为最新(agent 改名同步),
// 解析不到再回退落库快照。仅用于显示,编辑操作仍作用于原始 stageGroups。
const displayStageGroups = useMemo<StageGroups>(
() =>
stageGroups.map((g) => ({
...g,
seats: g.seats.map((s) => {
const live = agentLiveById.get(s.agent_id.toLowerCase());
return live
? { ...s, name: live.name || s.name, description: live.description ?? s.description }
: s;
}),
})),
[stageGroups, agentLiveById],
);
// ─── Phase 7 只读流程图预览(§2.2 底部 / §7)──────────────────────────────
// 把当前分层(去掉空 stage)映射成 DagFlowChart 的 stages,并提供席位名/配色解析。
const previewStages = useMemo(
@@ -196,10 +217,10 @@ export default function BusinessChainEditorPage() {
);
const seatNameById = useMemo(() => {
const m = new Map<string, string>();
for (const g of stageGroups)
for (const g of displayStageGroups)
for (const s of g.seats) m.set(s.agent_id.toLowerCase(), s.name || s.agent_id);
return m;
}, [stageGroups]);
}, [displayStageGroups]);
const resolvePreviewSeat = useCallback(
(agentId: string, order: number) => ({
name: seatNameById.get(agentId.toLowerCase()) ?? agentId,
@@ -211,10 +232,10 @@ export default function BusinessChainEditorPage() {
// 步骤一右侧「精美流程图」的数据源:过滤空 stage,带上各阶段目标作为依赖标签。
const flowStages = useMemo(
() =>
stageGroups
displayStageGroups
.filter((g) => g.seats.length > 0)
.map((g) => ({ seats: g.seats, goal: g.goal ?? null })),
[stageGroups],
[displayStageGroups],
);
const hasFlowContent = flowStages.length > 0;
@@ -256,6 +277,28 @@ export default function BusinessChainEditorPage() {
};
}, [search, filterTagIds]);
// ─── 全量名称表(一次性,不受搜索过滤):用于刷新席位的快照名称 ──────────────
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const agents = filterRecommendCandidates(await listAgents());
if (cancelled) return;
const m = new Map<string, { name: string; description?: string }>();
// 大小写不敏感:席位落库的 agent_id 与 agent.id 大小写可能不一致(同页 seatNameById
// 也按 toLowerCase 匹配),统一小写键避免解析失败回退到旧快照名。
for (const a of agents)
m.set(a.agent_id.toLowerCase(), { name: a.name, description: a.description });
setAgentLiveById(m);
} catch {
// 静默失败:解析不到就回退到席位的名称快照。
}
})();
return () => {
cancelled = true;
};
}, []);
// ─── 链条选择 / 新建 ──────────────────────────────────────────────────
const loadChain = useCallback((chain: BusinessChain) => {
setCurrentChainId(chain.id);
@@ -523,11 +566,15 @@ export default function BusinessChainEditorPage() {
// 展平 + 折叠:seats 用候选池最新 name/description 覆盖快照;stages 在「全单席位且无
// 目标」时折叠为 null(线性链),有并行或填了「本阶段目标」时落显式分层。
const flat = stageGroupsToSeats(stageGroups);
const payloadSeats = flat.map((s) => ({
agent_id: s.agent_id,
name: poolById.get(s.agent_id)?.name ?? s.name,
description: poolById.get(s.agent_id)?.description ?? s.description ?? "",
}));
const payloadSeats = flat.map((s) => {
// 优先全量名称表(不受搜索过滤)→ 候选池 → 落库快照,保证改名后保存写入最新名。
const live = agentLiveById.get(s.agent_id.toLowerCase()) ?? poolById.get(s.agent_id);
return {
agent_id: s.agent_id,
name: live?.name ?? s.name,
description: live?.description ?? s.description ?? "",
};
});
// 总控编排提示:trim 后空串落 null(清空),与后端「空=不注入」一致。
const coordPrompt = coordinatorPrompt.trim() || null;
// 填了「总控编排提示」也强制落显式分层 —— 该提示仅 dag 模式注入,否则纯线性链里被静默忽略。
@@ -917,8 +964,8 @@ export default function BusinessChainEditorPage() {
{/* 步骤导航:① 基本信息 → ② 链条编排(点击可切换)。 */}
<nav className="flex shrink-0 items-center justify-center gap-3 border-b border-border bg-card px-6 py-2.5">
{([
{ n: 1 as const, label: "基本信息" },
{ n: 2 as const, label: "链条编排" },
{ n: 1 as const, label: "链条编排" },
{ n: 2 as const, label: "基本信息" },
]).map((s, i) => (
<Fragment key={s.n}>
{i > 0 && <span className="h-px w-10 bg-border" />}
@@ -947,8 +994,8 @@ export default function BusinessChainEditorPage() {
))}
</nav>
{/* 步骤一:左=基本信息,右=精美流程图预览(有编排内容时)。无内容则居中单列。 */}
{step === 1 && (
{/* 步骤二:左=基本信息,右=精美流程图预览(有编排内容时)。无内容则居中单列。 */}
{step === 2 && (
<div className="flex min-h-0 flex-1 overflow-hidden">
{hasFlowContent ? (
<>
@@ -969,15 +1016,15 @@ export default function BusinessChainEditorPage() {
<div className="mx-auto w-full max-w-3xl overflow-y-auto px-6 py-6">
{basicInfoForm}
<p className="mt-4 text-center text-xs text-muted-foreground">
还没有编排内容。点击上方「② 链条编排」去配置链条,保存后这里会显示流程图预览。
还没有编排内容。点击上方「① 链条编排」去配置链条,保存后这里会显示流程图预览。
</p>
</div>
)}
</div>
)}
{/* 双栏拖拽编辑器(步骤二)*/}
{step === 2 && (
{/* 双栏拖拽编辑器(步骤一)*/}
{step === 1 && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
@@ -1046,9 +1093,9 @@ export default function BusinessChainEditorPage() {
</div>
</section>
{/* 右栏:分层 Stage */}
{/* 右栏:分层 Stage(用展示版,名称已按实时 agent 刷新) */}
<StagesColumn
stageGroups={stageGroups}
stageGroups={displayStageGroups}
seatCount={seatCount}
seatCountInvalid={seatCountInvalid}
hasEmptyStage={hasEmptyStage}
@@ -1083,7 +1130,7 @@ export default function BusinessChainEditorPage() {
{/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。
改为弹窗查看:底栏一个入口按钮,点击后在 Dialog 内展示完整流程图。 */}
{step === 2 && previewStages.length > 0 && (
{step === 1 && previewStages.length > 0 && (
<section className="shrink-0 flex items-center justify-between gap-2 border-t border-border bg-card px-6 py-2">
<span className="flex items-center gap-2 text-[11px] font-semibold text-muted-foreground">
<span>编排预览</span>
@@ -1133,8 +1180,8 @@ export default function BusinessChainEditorPage() {
}}
/>
{/* 页脚(步骤二):分层编排说明。步骤切换走顶部步骤导航。 */}
{step === 2 && (
{/* 页脚(步骤一):分层编排说明。步骤切换走顶部步骤导航。 */}
{step === 1 && (
<footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground">
分层编排:同一 Stage 内的席位并行研讨,Stage 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
</footer>
@@ -103,12 +103,11 @@ import {
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import {
cancelJob,
getJobByDraft,
startJob,
startJobKeepalive,
streamJob,
} from "@/roundtable-planning/api/roundtable-jobs";
import { mapJobToLiveStep2 } from "@/roundtable-planning/lib/job-live";
import { getChain } from "@/roundtable-planning/api/chains";
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
import { listAgents } from "@/core/agents";
@@ -978,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(按当前草稿快照恢复或新建)。进第三步、切草稿后停留第三步都复用它,
@@ -1014,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]);
@@ -1028,40 +1038,32 @@ export default function RoundtablePlanningPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep, drafts.currentDraftId]);
// ── 「查看=恢复流程」(2026-06-13):查看运行中的后台作业 → SSE 实时跟随 ──
// 加载草稿后订阅 streamJob,把作业每帧对话/产物/共识进度灌进第二步画面(像本地
// 编排一样实时长出气泡),终态时重载草稿衔接最终 run + step3,并停止订阅。
// ── 「查看=接管回前端」(2026-06-13):查看运行中的后台作业 → 接管续跑 ──
// 取消后端作业 → 加载作业最近写回草稿的快照(含作业真实线程 + 完整对话)→ 解除
// 「看历史」只读闸门 → 由前端在那些线程上**继续实时流式编排**(leader 自由派活续轮)。
// 这样「后台挂起 → 查看」会把会商真正搬回前端,按钮/暂停/干预/再挂起全部回归,而不是
// 停在只读跟随的后台态。
const jobFollowUnsubRef = useRef<(() => void) | null>(null);
const stopJobFollow = useCallback(() => {
jobFollowUnsubRef.current?.();
jobFollowUnsubRef.current = null;
}, []);
// 「查看运行中作业」:先 await 草稿 hydrate(建好席位/线程基础 + 切第二步),**再**
// 订阅 SSE —— 保证实时帧总是最后权威,不被草稿快照覆盖(竞态根因)。强制停在第二步
// 看研讨流程(作业可能已到 Step3,但用户点查看就是要看实时研讨过程)。
const followRunningJob = useCallback(
const takeOverRunningJob = useCallback(
async (jobId: string, draftId: string) => {
stopJobFollow();
step2.setIsRoundTableRunning(false);
step2.stopOrchestration();
// 点查看前**立刻存一次**当前聊天(若用户在本地还跑着研讨,保住其记录再切去看作业)。
await drafts.saveDraftNow(2).catch(() => null);
// 1. 取消后端作业:避免前后端双跑/抢同一线程(409),线程随后由前端接管续跑。
// executor.cancel 会 task.cancel() 中断在跑的 run,DB 同步置 cancelled。
await cancelJob(jobId).catch(() => null);
// 2. 加载作业最近写回草稿的快照:恢复 threadIds(作业真实线程)+ 协调名 + 完整对话。
// hydrateFromDraft 已同步写好 threadIds/coordinatorName 的 ref,供下一步接管即时读取。
await drafts.handleLoadDraft(draftId);
setCurrentStep(2); // 覆盖 hydrate 可能切到的 furthestStep,停在第二步看流程
jobFollowUnsubRef.current = streamJob(jobId, {
onProgress: (j) => {
step2.applyLiveJobFrame(mapJobToLiveStep2(j));
},
onDone: (j) => {
stopJobFollow();
// 终态:作业已把最终 run + step3 写回草稿 → 重载草稿无缝衔接(含报告)。
if (j.draftId) void drafts.handleLoadDraft(j.draftId);
void drafts.refresh();
},
onError: () => {
/* 断流静默:画面保留最后一帧;用户可重新点「查看」再续 */
},
});
setCurrentStep(2); // 覆盖 hydrate 可能切到的 furthestStep,停在第二步继续研讨
// 3. 关掉「看历史」只读闸门 → 后台挂起/暂停/干预按钮回归,回到前端可交互态。
drafts.loadedFromDraftRef.current = false;
// 4. 前端接管:在作业线程上继续实时流式编排(无线程则回退为只读查看,不再续跑)。
step2.resumeLiveTakeover();
void drafts.refresh();
},
[stopJobFollow, step2, drafts, setCurrentStep],
);
@@ -1306,13 +1308,13 @@ export default function RoundtablePlanningPage() {
if (!o) setChainModalJobId(null);
}}
onView={(job) => {
// 「查看」(2026-06-13 恢复流程语义):done → 加载草稿(含 step3)看报告;
// **运行中/待澄清 → 草稿 hydrate 完成后 SSE 实时跟随**——第二步消息列表像
// 本地编排一样实时长出新气泡/步骤卡,不再是静态聊天记录。
// 「查看」(2026-06-13 接管语义):done → 加载草稿(含 step3)看报告;
// **运行中/待澄清 → 接管回前端**:取消后端作业、加载聊天记录、解除只读,
// 由前端在作业线程上继续实时流式编排(不再是只读跟随后台)。
setChainModalJobId(null);
if (!job.draftId) return;
if (job.status === "running" || job.status === "awaiting_input") {
void followRunningJob(job.id, job.draftId);
void takeOverRunningJob(job.id, job.draftId);
return;
}
void drafts.handleLoadDraft(job.draftId);
@@ -1766,8 +1768,8 @@ function HistoryDropdown(props: HistoryDropdownProps) {
</button>
{isOpen && (
<div className="absolute right-0 top-full mt-2 w-[360px] max-h-[60vh] overflow-y-auto bg-white border border-slate-200 rounded-2xl shadow-xl z-50 dark:bg-secondary dark:border-border">
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10 dark:bg-secondary dark:border-border">
<div className="absolute right-0 top-full mt-2 w-[360px] max-h-[60vh] overflow-y-auto bg-white border border-slate-200 rounded-2xl shadow-xl z-50 dark:bg-[#0d1d3b] dark:border-border">
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10 dark:bg-[#0d1d3b] dark:border-border">
<span className="text-xs font-black text-slate-800 tracking-wide dark:text-foreground">历史草稿</span>
<span className="text-[10px] font-mono text-slate-400">{drafts.length} 条</span>
</div>
@@ -69,28 +69,28 @@
* 着色,所以这里统一覆盖即可整页换肤;浅色模式(无 .dark)保持红色主题不受影响。 */
.dark .roundtable-planning,
.roundtable-planning--dark {
/* —— 背景 / 容器(ZS-004 深蓝系) —— */
--background: #0c1836; /* zs-004-09 页面背景 */
--foreground: #c5cfe0; /* zs-004-02 正文字体 */
--card: #122347; /* zs-004-08 卡片背景 */
/* —— 背景 / 容器(ZS-004 深蓝系,半透明版 —— 让背景图透出)—— */
--background: rgba(6, 14, 36, 0.92); /* zs-004-09 页面底,半透明 */
--foreground: #c5cfe0; /* zs-004-02 正文字体 */
--card: rgba(3, 38, 80, 0.82); /* 卡片底:对齐 sidebar-contents 半透明深蓝 */
--card-foreground: #c5cfe0;
--popover: #122347; /* 下拉 / 浮层与卡片同底 */
--popover: rgba(3, 38, 80, 0.92); /* 下拉 / 浮层,略深一档 */
--popover-foreground: #c5cfe0;
--muted: #16294d; /* 次级容器(步骤条 / slate-50 表面) */
--muted-foreground: #6e88b0; /* zs-004-04 次级字体 */
--secondary: #1a2f5a; /* zs-004-07 次级按钮底 */
--muted: rgba(2, 24, 58, 0.75); /* 次级容器(步骤条 / slate-50 表面) */
--muted-foreground: #6e88b0; /* zs-004-04 次级字体 */
--secondary: rgba(26, 47, 90, 0.78); /* zs-004-07 次级按钮底,半透明 */
--secondary-foreground: #c5cfe0;
--accent: #1a2f5a; /* hover / 软高亮表面 */
--accent-foreground: #e8edf5; /* zs-004-01 */
--accent: rgba(26, 47, 90, 0.78); /* hover / 软高亮表面,半透明 */
--accent-foreground: #e8edf5; /* zs-004-01 */
/* —— 主色 / 链接(ZS-002 渐蓝系) —— */
--primary: #1677ff; /* zs-002-06 主操作 */
--primary-foreground: #ffffff;
--ring: rgba(22, 119, 255, 0.45);
/* —— 边框 / 控件描边(取较浅阶以保证深底上可见) —— */
--border: #2d4473; /* zs-004-06 容器边框 */
--input: #4a6491; /* zs-004-05 控件描边 */
/* —— 边框 / 控件描边:对齐 sidebar-contents 的蓝色发光边框 —— */
--border: rgba(43, 143, 254, 0.55); /* sidebar border-color 同款蓝边 */
--input: rgba(90, 169, 255, 0.35); /* 控件描边,略浅 */
/* —— 功能语义色 —— */
--destructive: #f5222d; /* zs-001-06 错误 */
@@ -110,7 +110,16 @@
--rt-primary-hover: var(--primary);
--rt-primary-soft: var(--accent);
color: var(--foreground);
/* 整页背景换深蓝主题底图(仅深色;浅色保持红白主题不受影响)。
* background-image 叠在 background-color 上 —— 底色作为图加载前/未覆盖区域的回落;
* cover 铺满、fixed 固定不随内容滚动(大屏背板观感,与应用渲染大屏一致)。
* url() 里的 SVG 由 Vite 当独立资源输出(非内联),浏览器缓存。 */
background-color: var(--background);
background-image: url("../../assets/img/layout-bg-theme-dark-blue.svg");
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
background-attachment: fixed;
}
.dark .roundtable-planning ::-webkit-scrollbar-thumb,
@@ -146,7 +155,7 @@
background-color: var(--background) !important;
}
/* ---- 表面:白 / 近白底 → 卡片底 ---- */
/* ---- 表面:白 / 近白底 → 卡片底(半透明,对齐 sidebar-contents 风格) ---- */
:is(.dark .roundtable-planning, .roundtable-planning--dark)
:is(.bg-white, .bg-white\/70, .hover\:bg-white:hover, .bg-\[\#fcfcfd\], .bg-\[\#fffdfa\]) {
background-color: var(--card) !important;
+250 -133
View File
@@ -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, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
@@ -12,7 +12,8 @@ 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";
import { readUserInfo } from "./lib/light-app";
const iconButtonClass = (isDark: boolean) =>
cn(
@@ -22,19 +23,121 @@ const iconButtonClass = (isDark: boolean) =>
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
);
// 深色顶栏菜单左右分组(设计稿:左 问答中心/任务管理/应用中心,右 综合管理/资源管理)
const LEFT_MENU_IDS = ["qa-center", "task-management", "light-app-center"];
const RIGHT_MENU_IDS = ["comprehensive-management", "resource-management"];
// 换肤色板:红色=红白主题(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();
const navigate = useNavigate();
const { pathname } = useLocation();
const isAdmin = getStoredAuth()?.system_role === "admin";
const auth = getStoredAuth();
const isAdmin = auth?.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
// 登录时写入的真实用户名(外部登录存于 localStorage.userInfo.userName);
// 缺省回退到 stored auth 的邮箱前缀。
const displayName = useMemo(() => {
const info = readUserInfo();
const name = typeof info.userName === "string" ? info.userName.trim() : "";
if (name) return name;
const email = auth?.email?.trim();
return email ? email.split("@")[0] : "用户";
}, [auth?.email]);
const showTopNav = isDark && !collapsed && !isWujieEnv();
const hasActiveChild = (item: MenuItem): boolean => {
@@ -67,6 +170,9 @@ const Header = ({ collapsed }: HeaderProps) => {
const activeSecondItem = activeTopItem?.children?.find(hasActiveChild);
const thirdSubNavItems = activeSecondItem?.children ?? [];
const leftMenuItems = visibleMenuItems.filter((i) => LEFT_MENU_IDS.includes(i.id));
const rightMenuItems = visibleMenuItems.filter((i) => RIGHT_MENU_IDS.includes(i.id));
const handleNavItemClick = (item: MenuItem) => {
const path = item.path ?? findFirstRoutePath(item.children);
if (path) {
@@ -74,149 +180,160 @@ const Header = ({ collapsed }: HeaderProps) => {
}
};
const renderTopItem = (item: MenuItem) => {
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
return (
<button
key={item.id}
type="button"
className={`top-submenu-item ${isSelected ? "top-submenu-item--active" : ""}`}
onClick={() => handleNavItemClick(item)}
>
{item.label}
</button>
);
};
// 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用
const actionCluster = (
<>
<ThemeSwitcher isDark={isDark} />
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setNotificationOpen(true)}
className={cn(iconButtonClass(isDark), "relative")}
title="通知"
>
<Bell className="size-4" />
{unreadCount > 0 && (
<span
className={cn(
"absolute top-1 right-1 flex h-4 min-w-4 items-center justify-center rounded-sm px-1 text-[10px] leading-none font-medium",
isDark
? "bg-cyan-500 text-white"
: "bg-white tabular-nums text-[var(--brand-900)]",
)}
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</Button>
<div
className={cn(
"mx-1 h-8 w-px",
isDark ? "bg-blue-800/30" : "bg-white/30",
)}
aria-hidden
/>
<Button
type="button"
variant="ghost"
className={cn(
"h-8 gap-2 rounded-sm px-2",
isDark
? "text-slate-200 hover:bg-slate-800/50"
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
)}
>
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-sm",
isDark
? "bg-gradient-to-br from-blue-500 to-cyan-400"
: "bg-white",
)}
>
<User
className={cn(
"size-4",
isDark ? "text-white" : "text-[var(--brand-900)]",
)}
/>
</div>
<div className="hidden text-left sm:block">
<p className="text-sm leading-tight font-medium text-white">
{displayName}
</p>
</div>
<ChevronDown
className={cn(
"size-4 shrink-0",
isDark ? "text-slate-500" : "text-white/80",
)}
/>
</Button>
</>
);
return (
<>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<header
className={cn(
"strategy-app-header theme-transition flex h-16 items-center justify-between border-b px-4",
isDark ? "border-blue-800/30" : "border-[var(--brand-950)]",
)}
>
<div className="flex h-full min-w-0 flex-1 items-center gap-3">
<div
className={cn(
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",
isDark
? "bg-gradient-to-br from-blue-500 to-cyan-400"
: "bg-white/20",
)}
>
<FileText className="h-4 w-4 text-white" />
</div>
{!collapsed && (
<span className="text-sm font-semibold text-white">
CM助手
</span>
)}
{showTopNav && visibleMenuItems.length ? (
<nav className="top-submenu-bar" aria-label="一级菜单">
<div className="top-submenu-list">
{visibleMenuItems.map((item) => {
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
<header className="strategy-app-header theme-transition relative flex h-16 items-center justify-between px-4">
{showTopNav ? (
<>
{/* 左分区(flex-1):菜单靠右贴近中间基座 */}
<div className="flex h-full min-w-0 flex-1 items-center justify-end">
<nav
className="top-submenu-bar top-submenu-bar--group"
aria-label="左侧菜单"
>
<div className="top-submenu-list">
{leftMenuItems.map(renderTopItem)}
</div>
</nav>
</div>
return (
<button
key={item.id}
type="button"
className={`top-submenu-item ${isSelected ? "top-submenu-item--active" : ""}`}
onClick={() => handleNavItemClick(item)}
>
{item.label}
</button>
);
})}
{/* 中:CM助手(落在背景图发光基座上,纯文字无图标) */}
<div className="header-center-title">CM助手</div>
{/* 右分区(flex-1):菜单靠左贴近中间基座,操作区靠最右 */}
<div className="flex h-full min-w-0 flex-1 items-center justify-between">
<nav
className="top-submenu-bar top-submenu-bar--group"
aria-label="右侧菜单"
>
<div className="top-submenu-list">
{rightMenuItems.map(renderTopItem)}
</div>
</nav>
<div className="flex shrink-0 items-center gap-2">
{actionCluster}
</div>
</nav>
) : null}
</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>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setNotificationOpen(true)}
className={cn(iconButtonClass(isDark), "relative")}
title="通知"
>
<Bell className="size-4" />
{unreadCount > 0 && (
<span
</div>
</>
) : (
<>
<div className="flex h-full min-w-0 flex-1 items-center gap-3">
<div
className={cn(
"absolute top-1 right-1 flex h-4 min-w-4 items-center justify-center rounded-sm px-1 text-[10px] leading-none font-medium",
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",
isDark
? "bg-cyan-500 text-white"
: "bg-white tabular-nums text-[var(--brand-900)]",
? "bg-gradient-to-br from-blue-500 to-cyan-400"
: "bg-white/20",
)}
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</Button>
<div
className={cn(
"mx-1 h-8 w-px",
isDark ? "bg-blue-800/30" : "bg-white/30",
)}
aria-hidden
/>
<Button
type="button"
variant="ghost"
className={cn(
"h-8 gap-2 rounded-sm px-2",
isDark
? "text-slate-200 hover:bg-slate-800/50"
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
)}
>
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-sm",
isDark
? "bg-gradient-to-br from-blue-500 to-cyan-400"
: "bg-white",
<FileText className="h-4 w-4 text-white" />
</div>
{!collapsed && (
<span className="text-sm font-semibold text-white">
CM助手
</span>
)}
>
<User
className={cn(
"size-4",
isDark ? "text-white" : "text-[var(--brand-900)]",
)}
/>
</div>
<div className="hidden text-left sm:block">
<p className="text-sm leading-tight font-medium text-white">
张策略
</p>
<p
className={cn(
"text-xs leading-tight",
isDark ? "text-blue-400/60" : "text-white/75",
)}
>
策略分析师
</p>
<div className="flex shrink-0 items-center gap-2">
{actionCluster}
</div>
<ChevronDown
className={cn(
"size-4 shrink-0",
isDark ? "text-slate-500" : "text-white/80",
)}
/>
</Button>
</div>
</>
)}
</header>
{showTopNav && thirdSubNavItems.length ? (
<nav className="third-submenu-bar" aria-label="三级菜单">
@@ -221,15 +221,15 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
}, []);
const shellClass = isDark
? 'strategy-data h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)]'
? 'strategy-data h-full overflow-y-auto'
: 'strategy-data h-full overflow-y-auto bg-[linear-gradient(180deg,#FFFDFD_0%,#FFF7F7_46%,#FAFAFA_100%)]';
const toolbarClass = isDark
? 'border border-[rgba(67,89,124,0.46)] bg-[rgba(10,23,42,0.94)] text-white shadow-[0_16px_36px_rgba(3,10,22,0.2)]'
? 'border border-[rgba(43,143,254,0.55)] bg-[rgba(3,38,80,0.82)] text-white shadow-[0_16px_36px_rgba(3,10,22,0.2)]'
: 'border border-[#E8E8E8] bg-white text-[#222222] shadow-[0_2px_8px_rgba(34,34,34,0.08)]';
const metricClass = isDark
? 'border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)]'
? 'border border-[rgba(43,143,254,0.55)] bg-[rgba(3,38,80,0.82)]'
: 'border border-[#E8E8E8] bg-white';
const inputClass = isDark
@@ -221,15 +221,15 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
}, []);
const shellClass = isDark
? 'strategy-data h-full overflow-y-auto bg-[radial-gradient(circle_at_top_left,rgba(27,94,168,0.28),transparent_32%),linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100'
? 'strategy-data h-full overflow-y-auto text-slate-100'
: 'strategy-data h-full overflow-y-auto bg-[radial-gradient(circle_at_top_left,rgba(245,34,45,0.08),transparent_26%),linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]';
const toolbarClass = isDark
? 'border border-[rgba(110,136,176,0.24)] bg-[rgba(10,23,42,0.94)] text-slate-100 shadow-[0_16px_36px_rgba(3,10,22,0.2)]'
? 'border border-[rgba(43,143,254,0.55)] bg-[rgba(3,38,80,0.82)] text-slate-100 shadow-[0_16px_36px_rgba(3,10,22,0.2)]'
: 'border border-[#E8E8E8] bg-white text-[#222222] shadow-[0_2px_8px_rgba(34,34,34,0.08)]';
const metricCardClass = isDark
? 'border border-[rgba(110,136,176,0.24)] bg-[linear-gradient(180deg,rgba(13,31,55,0.98),rgba(8,19,34,0.98))] shadow-[0_14px_42px_rgba(2,8,20,0.30)]'
? 'border border-[rgba(43,143,254,0.55)] bg-[rgba(3,38,80,0.82)] shadow-[0_14px_42px_rgba(2,8,20,0.30)]'
: 'border border-[#E8E8E8] bg-white';
const inputClass = isDark
@@ -25,7 +25,7 @@
--color-main: var(--color-primary);
--td-brand-color: var(--color-primary);
--bg-color: rgba(22, 119, 255, 0.12);
--card-border: rgba(110, 136, 176, 0.32);
--card-border: rgba(43, 143, 254, 0.55);
--text-color: var(--color-text-strong);
--card-bg: 220 59% 16%;
@@ -33,6 +33,14 @@
--border-color: 220 54% 29%;
--input-bg: 220 71% 10%;
--input-border: 220 35% 38%;
/* 深色背景图:对齐多智能体会商页风格,fixed 固定不随内容滚动 */
background-image: url("../../../../assets/img/layout-bg-theme-dark-blue.svg");
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
background-attachment: fixed;
background-color: #0c1836;
}
/* ── Theme card (TaskCard, KanbanColumn) ─────────────────────────────── */
@@ -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";
}
+131 -1
View File
@@ -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;
@@ -459,6 +502,20 @@
background: var(--background);
}
/* 深色背景换成设计稿深蓝主题底图。background-color 作为图片加载前/超出
* 区域的回落底色。仅 .dark 生效,浅色模式不受影响。
* - .workspace-chat-bg:紧凑模式聊天页(ChatPage 在 compactChrome 下挂)
* - .canvas-writing-bg:AI 写作页(AIWritingPage 根容器)*/
.dark .workspace-chat-bg,
.dark .canvas-writing-bg {
background-color: var(--background);
background-image: url("../assets/img/layout-bg-theme-dark-blue.svg");
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
background-attachment: fixed;
}
.workspace-panel {
border: 1px solid #f0d7d0;
background: #ffffff;
@@ -881,5 +938,78 @@ body {
}
html.dark .strategy-app-header {
background-image: url("../assets/img/dark-blue-header.svg");
background-image: url("../assets/img/top-bg.svg");
background-size: cover;
}
/* 深色顶栏:居中「CM助手」标题,落在背景图中间的发光梯形基座上。
左右菜单分区各 flex-1 等宽,本元素居中即对齐背景图基座。 */
.header-center-title {
flex: 0 0 auto;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 155px;
/* 文字略微上移,贴合基座视觉中心 */
transform: translateY(-6px);
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese",
"Microsoft YaHei", sans-serif;
font-size: 26px;
font-weight: 700;
letter-spacing: 4px;
white-space: nowrap;
pointer-events: none;
user-select: none;
/* 白→浅蓝→白 金属质感渐变文字 */
background: linear-gradient(
180deg,
#f9f9fa 0%,
#ffffff 15%,
#d8e5ff 49%,
#ffffff 89%,
#f8fafb 100%
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
filter: drop-shadow(0 0 14px rgba(120, 200, 255, 0.35));
}
/* ── 浅蓝主题:覆盖散落在各处的红色硬编码(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);
}
@@ -980,6 +980,18 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# re-run the heavy sync even though we just did it.
_services_mark_sync_fresh()
logger.info("Legacy folder-backed agents synced at startup")
# 内置功能型 agent(roundtable-* / ai-writing-*)按用途默认打标签,
# 供管理员「内置智能体」筛选按用途归类。幂等、只追加、失败不阻塞启动。
try:
from app.gateway.routers._builtin_agent_tag_seed import (
ensure_builtin_agent_purpose_tags,
)
tag_store = getattr(app.state, "tag_store", None)
if tag_store is not None:
await ensure_builtin_agent_purpose_tags(agent_store, tag_store)
except Exception:
logger.exception("Built-in agent purpose-tag seeding failed at startup (non-fatal)")
except Exception:
logger.exception("Legacy agent sync failed at startup (non-fatal)")
@@ -0,0 +1,97 @@
"""内置智能体「用途」默认标签的种子化。
把**功能型内置 agent**(roundtable-* / ai-writing-*)按用途默认打上标签,使管理员在
「内置智能体」筛选里一眼能按用途归类。没有明确用途的内置 agent(各种测试/用户残留的
user_id 为空的 agent)**不打标签**。
用途映射(可继续补充):
- **多智能体规划**:roundtable-intent(任务理解)/ roundtable-recommender(智能体推荐)/
roundtable-coordinator(总控协调)/ roundtable-report / roundtable-summary /
roundtable-execution-plan / roundtable-risk-review / roundtable-solution-design
- **AI写作**:ai-writing-researcher / ai-writing-outliner / ai-writing-writer(作家)/
ai-writing-editor
幂等:标签缺失则建,assignment 缺失则补;**只做并集追加**,绝不删用户/管理员后加的
其它标签。在启动 lifespan 里 `_sync_legacy_agents` 之后调用(那时 agent 行已入库)。
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
# 标签按用途分组(tag_type 固定为 "agent",与 target_type 对齐)。id 用稳定 slug,
# 便于幂等;name 是展示名;color 走标签库的命名色。
_PURPOSE_TAGS: dict[str, dict[str, str]] = {
"多智能体规划": {"id": "builtin-purpose-multi-agent-planning", "color": "blue"},
"AI写作": {"id": "builtin-purpose-ai-writing", "color": "green"},
}
# 内置 agent id → 用途名(必须是 _PURPOSE_TAGS 的 key)。未列出的内置 agent 不打标签。
_AGENT_PURPOSE: dict[str, str] = {
# 多智能体规划(圆桌全链路)
"roundtable-intent": "多智能体规划",
"roundtable-recommender": "多智能体规划",
"roundtable-coordinator": "多智能体规划",
"roundtable-report": "多智能体规划",
"roundtable-summary": "多智能体规划",
"roundtable-execution-plan": "多智能体规划",
"roundtable-risk-review": "多智能体规划",
"roundtable-solution-design": "多智能体规划",
# AI 写作(杂志社协作写作四阶段)
"ai-writing-researcher": "AI写作",
"ai-writing-outliner": "AI写作",
"ai-writing-writer": "AI写作",
"ai-writing-editor": "AI写作",
}
_TARGET_TYPE = "agent"
async def ensure_builtin_agent_purpose_tags(agent_store, tag_store) -> int:
"""确保内置功能型 agent 带上默认「用途」标签。返回新增 assignment 数。
错误只记录不抛——种子化失败绝不能阻塞服务启动。
"""
try:
# 1) 确保用途标签存在,建立 name -> id 映射。
existing = {t.get("name"): t for t in await tag_store.list_tags(_TARGET_TYPE)}
name_to_id: dict[str, str] = {}
for name, meta in _PURPOSE_TAGS.items():
found = existing.get(name)
if found is not None:
name_to_id[name] = found["id"]
continue
try:
created = await tag_store.create_tag(
{"id": meta["id"], "name": name, "tag_type": _TARGET_TYPE, "color": meta.get("color")}
)
name_to_id[name] = created["id"]
except ValueError:
# 并发/已存在(按 name 唯一)——重查一次取其 id。
again = {t.get("name"): t for t in await tag_store.list_tags(_TARGET_TYPE)}
if name in again:
name_to_id[name] = again[name]["id"]
# 2) 给映射里**实际存在**的内置 agent 追加用途标签(并集,不覆盖已有标签)。
added = 0
for agent_id, purpose in _AGENT_PURPOSE.items():
tag_id = name_to_id.get(purpose)
if not tag_id:
continue
agent = await agent_store.get_any(agent_id)
if agent is None:
continue # 该部署未装此内置 agent,跳过
current = await tag_store.get_target_tags(_TARGET_TYPE, agent_id)
cur_ids = [t["id"] for t in current]
if tag_id in cur_ids:
continue # 已打过该用途标签
await tag_store.set_target_tags(_TARGET_TYPE, agent_id, [*cur_ids, tag_id])
added += 1
if added:
logger.info("Seeded %d built-in agent purpose tag assignment(s)", added)
return added
except Exception: # noqa: BLE001
logger.exception("Failed to seed built-in agent purpose tags")
return 0
@@ -587,8 +587,8 @@ async def list_agents(
tag_ids: list[str] | None = Query(default=None, description="Keep agents carrying any of these tags (OR)"),
scope: str | None = Query(
default=None,
pattern="^(mine|square)$",
description="Pagination scope. 'mine' = owned + favorited; 'square' = built-in/published public. Omit for the default combined visibility list.",
pattern="^(mine|square|builtin)$",
description="Pagination scope. 'mine' = owned + favorited; 'square' = built-in/published public; 'builtin' = 内置智能体 (user_id IS NULL, **admin only**). Omit for the default combined visibility list.",
),
page: int | None = Query(default=None, ge=1, description="1-based page index. Providing this (or 'scope') enables server-side pagination."),
page_size: int = Query(default=20, ge=1, le=200, description="Items per page when paginating."),
@@ -623,6 +623,9 @@ async def list_agents(
user_id = _current_user_id(request)
if admin and not _is_admin_user(request):
raise HTTPException(status_code=403, detail="Only system admins can list all agents")
# 「内置智能体」筛选仅限管理员(普通用户不应看到全部内置/功能型 agent 目录)。
if scope == "builtin" and not _is_admin_user(request):
raise HTTPException(status_code=403, detail="Only system admins can list built-in agents")
paginate = scope is not None or page is not None
square_id = square.strip() if square and square.strip() else None
@@ -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)
@@ -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",
@@ -195,6 +195,9 @@ class MemoryAgentStore(AgentStore):
return owned or favorited
if scope == "private":
return bool(agent.get("is_private"))
if scope == "builtin":
# 内置智能体 = user_id IS NULL(仅管理员;路由层 admin 闸门)。
return agent.get("user_id") is None
return self._is_visible(agent, user_id)
async def list_paginated(
@@ -275,6 +275,9 @@ class AgentRepository(AgentStore):
return or_(AgentRow.user_id == user_id, AgentRow.id.in_(fav_subq))
if scope == "private":
return AgentRow.is_private.is_(True)
if scope == "builtin":
# 内置智能体 = user_id IS NULL。仅管理员可用此 scope(路由层已 admin 闸门)。
return AgentRow.user_id.is_(None)
return self._listing_filter(user_id)
@staticmethod