Merge branch 'dhk-1' into 'master'
feat(圆桌): 业务链条配置页流程图点阵放大 + 侧边栏菜单项按设计稿还原 See merge request knowledge-base-bj/deerflow-server!119
This commit is contained in:
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>
|
||||
```
|
||||
@@ -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 |
@@ -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 |
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: "在不同语言之间切换。",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -135,7 +135,7 @@ export async function updatePublishSquares(
|
||||
return (await response.json()) as PublishSquaresSettings;
|
||||
}
|
||||
|
||||
export type ThemeMode = "light" | "dark";
|
||||
export type ThemeMode = "light" | "dark" | "lightblue";
|
||||
export type ViewMode = "card" | "list";
|
||||
|
||||
export interface AppearanceSettings {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
export const DESENSITIZE_MAP: Record<string, string> = {
|
||||
态势: "TS",
|
||||
你好: "nh",
|
||||
滨海作战体系: "第一张网",
|
||||
滨海作战部队: "第一张网",
|
||||
'Taiwan Air Force Flight Units': "第二张网",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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 等价;无界等嵌套
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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="三级菜单">
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+9
-1
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+2
-2
@@ -47,9 +47,9 @@ class AppearanceSettings(BaseModel):
|
||||
toggle, and any future first-time loads.
|
||||
"""
|
||||
|
||||
default_theme: Literal["light", "dark"] = Field(
|
||||
default_theme: Literal["light", "dark", "lightblue"] = Field(
|
||||
default="light",
|
||||
description="Theme applied for users who haven't picked their own (light / dark)",
|
||||
description="Theme applied for users who haven't picked their own (light / dark / lightblue)",
|
||||
)
|
||||
default_skill_view: Literal["card", "list"] = Field(
|
||||
default="list",
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user