Merge remote-tracking branch 'origin/master' into ji

# Conflicts:
#	frontend-web/src/components/workspace/input-box.tsx
#	offline-backend-20260512/backend/CLAUDE.md
This commit is contained in:
吉鑫升 committed 2026-06-16 11:07:31 +08:00
commit fa70b31873
159 files changed
+15521 -2415

No files matched your search

-319
View File
@@ -1,319 +0,0 @@
// src/roundtable-planning/lib/flow-graph.test.ts
import assert from "node:assert/strict";
import test from "node:test";
// src/roundtable-planning/lib/flow-graph.ts
var FLOW_NODE_TYPE_META = {
start: { label: "\u5F00\u59CB", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" },
end: { label: "\u7ED3\u675F", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" },
stage: { label: "\u9636\u6BB5", bg: "#eff6ff", border: "#60a5fa", text: "#1d4ed8", dot: "#3b82f6" },
task: { label: "\u4EFB\u52A1", bg: "#ecfeff", border: "#22d3ee", text: "#0e7490", dot: "#06b6d4" },
decision: { label: "\u5224\u65AD", bg: "#fffbeb", border: "#fbbf24", text: "#b45309", dot: "#f59e0b" },
risk: { label: "\u98CE\u9669", bg: "#fef2f2", border: "#f87171", text: "#b91c1c", dot: "#ef4444" },
deliverable: { label: "\u4EA4\u4ED8\u7269", bg: "#f0fdf4", border: "#4ade80", text: "#15803d", dot: "#22c55e" },
role: { label: "\u89D2\u8272", bg: "#faf5ff", border: "#c084fc", text: "#7e22ce", dot: "#a855f7" }
};
var VALID_TYPES = new Set(Object.keys(FLOW_NODE_TYPE_META));
var FLOW_FENCE_OPEN = "```flow-json";
function collectObjects(text, key) {
const keyIdx = text.indexOf(key);
if (keyIdx < 0) return [];
const arrStart = text.indexOf("[", keyIdx);
if (arrStart < 0) return [];
const out = [];
let depth = 0;
let objStart = -1;
let inStr = false;
let esc = false;
for (let i = arrStart + 1; i < text.length; i++) {
const ch = text[i];
if (inStr) {
if (esc) esc = false;
else if (ch === "\\") esc = true;
else if (ch === '"') inStr = false;
continue;
}
if (ch === '"') {
inStr = true;
continue;
}
if (ch === "{") {
if (depth === 0) objStart = i;
depth++;
continue;
}
if (ch === "}") {
depth--;
if (depth === 0 && objStart >= 0) {
try {
const parsed = JSON.parse(text.slice(objStart, i + 1));
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
out.push(parsed);
}
} catch {
}
objStart = -1;
}
continue;
}
if (ch === "]" && depth === 0) break;
}
return out;
}
function asString(v) {
return typeof v === "string" ? v.trim() : "";
}
function toNode(raw) {
const id = asString(raw.id);
const label = asString(raw.label) || asString(raw.name);
if (!id || !label) return null;
const rawType = asString(raw.type).toLowerCase();
const type = VALID_TYPES.has(rawType) ? rawType : "task";
const detail = asString(raw.detail) || asString(raw.description) || void 0;
return { id, label, type, detail };
}
function edgesFromNode(raw, nodeId) {
const from = raw.from ?? raw.prev;
if (!Array.isArray(from)) return [];
const out = [];
for (const entry of from) {
if (typeof entry === "string" && entry.trim()) {
out.push({ from: entry.trim(), to: nodeId });
} else if (entry && typeof entry === "object") {
const o = entry;
const fromId = asString(o.id) || asString(o.from);
if (!fromId) continue;
const label = asString(o.label) || void 0;
out.push({ from: fromId, to: nodeId, label });
}
}
return out;
}
function toEdge(raw) {
const from = asString(raw.from) || asString(raw.source);
const to = asString(raw.to) || asString(raw.target);
if (!from || !to) return null;
const label = asString(raw.label) || void 0;
return { from, to, label };
}
function extractFlowGraph(text) {
const start = text.lastIndexOf(FLOW_FENCE_OPEN);
if (start < 0) return null;
let body = text.slice(start + FLOW_FENCE_OPEN.length);
const close = body.indexOf("```");
if (close >= 0) body = body.slice(0, close);
const titleMatch = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(body);
let title = "";
if (titleMatch) {
try {
title = JSON.parse(`"${titleMatch[1]}"`);
} catch {
title = titleMatch[1];
}
}
const rawNodes = collectObjects(body, '"nodes"');
const nodes = [];
const seen = /* @__PURE__ */ new Set();
const edges = [];
for (const raw of rawNodes) {
const node = toNode(raw);
if (!node || seen.has(node.id)) continue;
seen.add(node.id);
nodes.push(node);
edges.push(...edgesFromNode(raw, node.id));
}
for (const raw of collectObjects(body, '"edges"')) {
const edge = toEdge(raw);
if (edge) edges.push(edge);
}
if (nodes.length === 0) return null;
const edgeKeys = /* @__PURE__ */ new Set();
const validEdges = edges.filter((e) => {
if (!seen.has(e.from) || !seen.has(e.to) || e.from === e.to) return false;
const key = `${e.from}->${e.to}`;
if (edgeKeys.has(key)) return false;
edgeKeys.add(key);
return true;
});
return { title, nodes, edges: validEdges };
}
var FLOW_INTENT_PATTERNS = [
"\u6D41\u7A0B\u56FE",
"\u53EF\u89C6\u5316\u6D41\u7A0B",
"\u7ED3\u6784\u5316\u8F93\u51FA",
"\u7ED3\u6784\u5316\u6570\u636E",
"\u7ED3\u6784\u5316\u6D41\u7A0B",
"\u7ED3\u6784\u5316\u603B\u7ED3",
"\u683C\u5F0F\u5316\u8F93\u51FA",
"\u683C\u5F0F\u5316\u603B\u7ED3",
"flow-json",
"flowchart"
];
function detectFlowIntent(text) {
if (!text) return false;
const lower = text.toLowerCase();
return FLOW_INTENT_PATTERNS.some((p) => lower.includes(p));
}
function stripFlowFence(text, streaming) {
const start = text.lastIndexOf(FLOW_FENCE_OPEN);
if (start < 0) return text;
const after = text.slice(start + FLOW_FENCE_OPEN.length);
const close = after.indexOf("```");
const tail = close >= 0 ? after.slice(close + 3) : "";
const placeholder = close < 0 && streaming ? "\n\n> \u{1F4CA} \u6B63\u5728\u8F93\u51FA\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E\uFF0C\u6D41\u7A0B\u56FE\u753B\u5E03\u540C\u6B65\u7ED8\u5236\u4E2D\u2026" : "\n\n> \u{1F4CA} \u5DF2\u751F\u6210\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E\uFF0C\u53EF\u5728\u53F3\u4FA7\u300C\u6D41\u7A0B\u56FE\u300D\u753B\u5E03\u67E5\u770B\uFF08\u70B9\u51FB\u8282\u70B9\u770B\u8BE6\u60C5\uFF09\u3002";
return `${text.slice(0, start).trimEnd()}${placeholder}${tail}`;
}
var X_GAP = 224;
var Y_GAP = 132;
function layoutFlowGraph(data) {
const preds = /* @__PURE__ */ new Map();
for (const e of data.edges) {
const list = preds.get(e.to);
if (list) list.push(e.from);
else preds.set(e.to, [e.from]);
}
const memo = /* @__PURE__ */ new Map();
const visiting = /* @__PURE__ */ new Set();
const layerOf = (id) => {
const cached = memo.get(id);
if (cached !== void 0) return cached;
if (visiting.has(id)) return 0;
visiting.add(id);
const ps = preds.get(id) ?? [];
const layer = ps.length === 0 ? 0 : Math.max(...ps.map(layerOf)) + 1;
visiting.delete(id);
memo.set(id, layer);
return layer;
};
const byLayer = /* @__PURE__ */ new Map();
for (const n of data.nodes) {
const layer = layerOf(n.id);
const list = byLayer.get(layer);
if (list) list.push(n);
else byLayer.set(layer, [n]);
}
const pos = /* @__PURE__ */ new Map();
for (const [layer, nodes] of byLayer) {
const rowWidth = (nodes.length - 1) * X_GAP;
nodes.forEach((n, i) => {
pos.set(n.id, { x: i * X_GAP - rowWidth / 2, y: layer * Y_GAP });
});
}
return data.nodes.map((n) => ({ ...n, ...pos.get(n.id) }));
}
// src/roundtable-planning/lib/flow-graph.test.ts
var FULL = [
"\u62A5\u544A\u6B63\u6587\u2026\u2026",
"```flow-json",
'{"title":"\u65B9\u6848\u6267\u884C\u6D41\u7A0B","nodes":[',
'{"id":"n1","label":"\u73AF\u5883\u8BC4\u4F30","type":"stage","detail":"\u8BC4\u4F30\u73B0\u573A\u73AF\u5883"},',
'{"id":"n2","label":"\u7F16\u5236\u65B9\u6848","type":"task","from":["n1"]},',
'{"id":"n3","label":"\u662F\u5426\u53EF\u884C","type":"decision","from":[{"id":"n2","label":"\u8BC4\u5BA1"}]}',
"]}",
"```"
].join("\n");
test("extractFlowGraph\uFF1A\u5B8C\u6574\u5757\u89E3\u6790\u51FA\u8282\u70B9/\u8FB9/\u6807\u9898", () => {
const g = extractFlowGraph(FULL);
assert.ok(g);
assert.equal(g.title, "\u65B9\u6848\u6267\u884C\u6D41\u7A0B");
assert.equal(g.nodes.length, 3);
assert.equal(g.nodes[0].type, "stage");
assert.deepEqual(g.edges, [
{ from: "n1", to: "n2" },
{ from: "n2", to: "n3", label: "\u8BC4\u5BA1" }
]);
});
test("extractFlowGraph\uFF1A\u6D41\u5F0F\u534A\u622A\uFF08fence \u672A\u95ED\u5408 + \u5C3E\u5BF9\u8C61\u622A\u65AD\uFF09\u53EA\u53D6\u5B8C\u6574\u8282\u70B9", () => {
const partial = [
"\u6B63\u6587",
"```flow-json",
'{"title":"T","nodes":[',
'{"id":"a","label":"A","type":"start"},',
'{"id":"b","label":"B","type":"task","from":["a"]},',
'{"id":"c","label":"C","ty'
// 截断
].join("\n");
const g = extractFlowGraph(partial);
assert.ok(g);
assert.equal(g.nodes.length, 2);
assert.deepEqual(g.edges, [{ from: "a", to: "b" }]);
});
test("extractFlowGraph\uFF1A\u672A\u77E5 type \u843D task\u3001\u91CD\u590D id \u53BB\u91CD\u3001\u60AC\u7A7A\u8FB9\u4E22\u5F03", () => {
const text = [
"```flow-json",
'{"nodes":[',
'{"id":"x","label":"X","type":"banana"},',
'{"id":"x","label":"X-dup","type":"task"},',
'{"id":"y","label":"Y","type":"risk","from":["x","ghost"]}',
"]}",
"```"
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
assert.equal(g.nodes.length, 2);
assert.equal(g.nodes[0].type, "task");
assert.equal(g.nodes[0].label, "X");
assert.deepEqual(g.edges, [{ from: "x", to: "y" }]);
});
test("extractFlowGraph\uFF1A\u65E0 fence / \u65E0\u5B8C\u6574\u8282\u70B9\u8FD4\u56DE null", () => {
assert.equal(extractFlowGraph("\u666E\u901A\u56DE\u590D\uFF0C\u6CA1\u6709\u7ED3\u6784\u5316\u6570\u636E"), null);
assert.equal(extractFlowGraph('```flow-json\n{"nodes":[{"id":"a"'), null);
});
test("extractFlowGraph\uFF1A\u517C\u5BB9\u72EC\u7ACB edges \u6570\u7EC4\uFF08source/target \u522B\u540D\uFF09", () => {
const text = [
"```flow-json",
'{"nodes":[{"id":"a","label":"A"},{"id":"b","label":"B"}],',
'"edges":[{"source":"a","target":"b","label":"\u8D70"}]}',
"```"
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
assert.deepEqual(g.edges, [{ from: "a", to: "b", label: "\u8D70" }]);
});
test("stripFlowFence\uFF1A\u95ED\u5408\u5757\u66FF\u6362\u4E3A\u5F15\u5BFC\u8BED\u5E76\u4FDD\u7559\u524D\u540E\u6587", () => {
const out = stripFlowFence(FULL, false);
assert.ok(out.includes("\u62A5\u544A\u6B63\u6587"));
assert.ok(!out.includes("```flow-json"));
assert.ok(out.includes("\u5DF2\u751F\u6210\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E"));
});
test("stripFlowFence\uFF1A\u672A\u95ED\u5408\uFF08\u6D41\u5F0F\u4E2D\uFF09\u7528\u300C\u6B63\u5728\u8F93\u51FA\u300D\u63AA\u8F9E", () => {
const out = stripFlowFence('\u6B63\u6587\n```flow-json\n{"nodes":[', true);
assert.ok(out.includes("\u6B63\u5728\u8F93\u51FA\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E"));
assert.ok(!out.includes("```flow-json"));
});
test("layoutFlowGraph\uFF1A\u6309\u524D\u9A71\u5206\u5C42\uFF08\u6839 0 \u5C42\uFF0C\u5B50\u5C42\u9012\u589E\uFF09\uFF0C\u4FDD\u6301\u539F\u59CB\u987A\u5E8F", () => {
const g = extractFlowGraph(FULL);
const positioned = layoutFlowGraph(g);
assert.equal(positioned[0].y, 0);
assert.ok(positioned[1].y > positioned[0].y);
assert.ok(positioned[2].y > positioned[1].y);
assert.deepEqual(positioned.map((n) => n.id), ["n1", "n2", "n3"]);
});
test("detectFlowIntent\uFF1A\u660E\u786E\u77ED\u8BED\u547D\u4E2D\uFF08\u7F16\u6392\u63D0\u793A / \u8F93\u5165\u6846\u6D88\u606F\uFF09", () => {
assert.ok(detectFlowIntent("\u6700\u7EC8\u603B\u7ED3\u8BF7\u7528\u7ED3\u6784\u5316\u8F93\u51FA"));
assert.ok(detectFlowIntent("\u603B\u7ED3\u6210\u683C\u5F0F\u5316\u8F93\u51FA"));
assert.ok(detectFlowIntent("\u7ED9\u6211\u753B\u4E00\u5F20\u6D41\u7A0B\u56FE"));
assert.ok(detectFlowIntent("\u8F93\u51FA\u53EF\u89C6\u5316\u6D41\u7A0B"));
assert.ok(detectFlowIntent("\u8BF7\u6309 flow-json \u5951\u7EA6\u8F93\u51FA"));
assert.ok(detectFlowIntent("\u91CD\u70B9\u603B\u7ED3\u65B9\u6848\u8BBE\u8BA1\u5E2D\u4F4D\uFF0C\u6700\u540E\u7ED9\u7ED3\u6784\u5316\u6570\u636E"));
});
test("detectFlowIntent\uFF1A\u666E\u901A\u63AA\u8F9E\u4E0D\u8BEF\u89E6\u53D1", () => {
assert.equal(detectFlowIntent("\u7528\u8868\u683C\u628A\u8981\u70B9\u7ED3\u6784\u5316\u5448\u73B0"), false);
assert.equal(detectFlowIntent("\u628A\u6267\u884C\u8DEF\u7EBF\u5199\u5F97\u66F4\u7EC6\u4E00\u4E9B"), false);
assert.equal(detectFlowIntent("\u9762\u5411\u9AD8\u7BA1\u6C47\u62A5\uFF0C\u63A7\u5236\u5728\u4E00\u9875\u5185"), false);
assert.equal(detectFlowIntent(""), false);
assert.equal(detectFlowIntent(null), false);
assert.equal(detectFlowIntent(void 0), false);
});
test("layoutFlowGraph\uFF1A\u73AF\u4E0D\u6B7B\u5FAA\u73AF", () => {
const text = [
"```flow-json",
'{"nodes":[{"id":"a","label":"A","from":["b"]},{"id":"b","label":"B","from":["a"]}]}',
"```"
].join("\n");
const g = extractFlowGraph(text);
const positioned = layoutFlowGraph(g);
assert.equal(positioned.length, 2);
positioned.forEach((n) => assert.ok(Number.isFinite(n.x) && Number.isFinite(n.y)));
});
@@ -223,3 +223,31 @@ result = await run_writing_agent(
### 已知存量测试失败(与本次无关)
`test_ai_writing_graph.py`(mock 已删除的 `intent_parser.llm_json`)、`test_ai_writing_word_count.py`(mock 已删除的 `writer_draft.get_model`)、`test_ai_writing_draft_headings.py`(断言三级结构升级前的 `####→###` 行为)、`test_ai_writing_outline_revise.py`(断言旧 prompt 文案),改造前即失败,待另行清理。
## 10. Phase 3 收尾:检索类型两项化 + 配置技能驱动检索(2026-06-13 已实施)
本节落地了 §9 遗留的「researcher 检索改挂载技能」,并按新需求重新设计了检索类型与 PAUSE-1 交互。
### 10.1 检索类型(material_source)收敛为两项
- 写作表单「素材来源」改名「检索类型」,只剩 `general`(通用检索,默认)/ `notebook`(我的空间);旧值 `knowledge_base` / `skill` 由 researcher 节点按 `general` 兼容(老会话 resume 不受影响)。
- 前端:`AIWritingForm.tsx`(选项/标签)、`WritingFormContext.tsx`(默认值/类型/applyProposal 旧值映射)、`types/ai-writing.ts`、`message-list.tsx`(审批卡标签兼容旧值)。
### 10.2 通用检索 = 素材收集专家配置的技能编排
- `ai-writing-researcher` agent 的 `config.yaml → skills` 是权威配置(智能体管理页可增删/替换);种子默认 `[knowledge-base-search]`。
- 内置技能 **knowledge-base-search(知识库检索)**:把原 web/intranet 关键词并发检索包装成技能。researcher 对它特殊处理——不拼 description,直接对 Step1 生成的检索词做原生并发检索(行为与旧 knowledge_base 模式一致)。其它配置技能按 `意图 + description` 拼定向 query 检索。按配置顺序执行、累积达 `max_materials` 早停(`stop_early`)。
- 技能配置为空/全部失效 → 内存兜底回退知识库检索(`researcher._load_configured_skills`),检索永不瘫痪。
- 种子:`_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` → `skills/public/`(never-overwrite);旧部署的 researcher `config.yaml` 缺 `skills` 键时就地补默认(显式 `skills: []` 不动)。`_ai_writing_seed.py::ensure_ai_writing_functional_agents()` 一并处理。
### 10.3 PAUSE-1 输入框 → 匹配技能检索并**追加**
- 用户在素材确认卡输入自然语言(如「检索 A 的数据」)→ `re_search + user_query`:配置技能 >1 个时 `SkillPicker(candidates=配置技能)` 让模型按输入匹配排序(流式打字机保留),单技能直接用;用用户原话(非知识库技能附 description)检索。
- **追加语义**:保留原素材列表与顺序,新素材按 URL 去重后追加在末尾(ID 续号),跳过 LLM 重排与 max_materials 截断(仅 100 条硬上限),summary 显示「追加检索到 X 条新素材,当前共 Y 条」。原 20 条 + 新 20 条 = 40 条。
- 「技能检索」独立按钮已删除(通用检索本来就走技能编排);后端继续兼容旧 `enable_skill_search` action(按 user_query 追加处理)。无输入的「重新搜索」仍为覆盖式重检。
- 前端:`InterventionCard.tsx`(按钮/文案,hasQuery 时按钮文案「检索并追加素材」)、`MaterialsToolbar.tsx`(提示语)、`useAIWritingStream.ts`(素材卡回到 researching 一律置 skillSearchPending,让 SkillStepsCard 占位即时挂载)。
### 10.4 测试
- `tests/test_ai_writing_material_source.py` 重写(9 例):缺省/旧值→通用检索、自定义技能 description query、user_query 追加+URL 去重+多技能 picker 匹配(candidates 断言)、无输入重检覆盖、空配置回退。
- `tests/test_ai_writing_seed.py` 扩展(+5 例):技能资产存在、researcher 种子含默认 skills、技能种子 never-overwrite、缺 skills 键就地补丁、显式 `[]` 不动。
@@ -54,21 +54,22 @@ roundtable-report 真调 deepseek、真搜索、跑到 report run success)。
## 三、遗留问题(下次重点,已按优先级排)
### P0 —— 报告 agent 不写 HTML 文件(结果绘制拿不到产物)
- **现象**:后台报告 run 跑完,日志 `report html: no .html found in ...threads/<id>/user-data/outputs`;
`run.step3.html` 回退成报告 agent 的**纯文字**(不含 `<html>`),第3步画不出东西。
- **已做**:`build_report_prompt`(`roundtable_orchestrator/engine.py`)已对齐前端
`useStep3Report.buildReportPrompt`,明确「用 write_file 写入 /mnt/user-data/outputs/方案总览.html」;
`_read_report_html`(`roundtable_inprocess_gateway.py`)已改为扫 outputs 目录任意 `.html`。
- **仍没解决**:报告 agent(deepseek-chat)还是没调 `write_file`。
- **下次怎么查**:
1. 对比**前端那条能写出 HTML 的报告 run** 的确切调用(`useStep3Report.ts` 里 `streamMultiAgent`
调 `/api/multi-agent/run/stream` 的 body:`agent_type`/`excluded_tools`/`context` 等),
和后台 `_run_agent_turn`(`roundtable_inprocess_gateway.py`,agent_name=roundtable-report,
`excluded_tools=_REPORT_EXCLUDED`)逐项比对,找出差异(怀疑 reasoning_effort / thinking /
某个 context 字段 / recursion 步数导致它没走到 write_file)。
2. 看报告 run 的最终 messages(checkpoint)里有没有 write_file 的 tool_call(被 dangling 截断?)。
3. 报告 agent 的 SOUL:`app/gateway/routers/_roundtable_seed/roundtable-report/SOUL.md`——确认它默认行为。
### P0 —— 报告 agent 不写 HTML 文件(结果绘制拿不到产物)✅ 已于 2026-06-12 修复并恢复
- **根因(2026-06-12 定位)**:`dc8fca6`(2026-06-11 智能体可见性对齐)给 `services._enforce_agent_access`
加 admin 判断时**裸访问 `request.state`** —— 后台进程内虚拟 request(`SimpleNamespace(app=app)`)
没有 `state` 属性 → `AttributeError` 杀死整个编排(前端真 HTTP 请求有 auth 中间件写的 `state.user`
所以手动路径一直正常)。修复:getattr 全链防御 + 虚拟 request 补空 `state`(含 scheduler 同款),
回归测试 `tests/test_roundtable_inprocess_request.py`。
- **已恢复**:`ROUNDTABLE_BACKGROUND_REPORT_ENABLED = True`。后台收口后自动跑 Step3 双产物:
① `roundtable-summary` 写「方案总结报告.md」(新增 `gateway.run_summary` + `engine._run_step3`,
prompt 对齐 `useStep3Summary.buildSummaryPrompt`)→ ② `roundtable-report` 画「方案总览.html」。
两段故障隔离(一段失败保留另一段产物);step3 形状对齐前端 `DraftStep3Snapshot`
(`summaryReport` 嵌套),写草稿 `draft.step3` 与手动调用同款。测试
`tests/test_roundtable_step3_pipeline.py`。
- **待真机验证**:早期(06-05,回归引入前)曾观察到 deepseek-chat 在后台不调 `write_file`,
当时根因可能就是更早版本的同类访问崩溃,也可能另有 prompt 层问题——恢复后跑一单真模型确认
outputs 下两份产物都落盘;若报告仍不写 HTML,按旧排查清单对比前端 run 参数(run policy 现已
由 `roundtable_run_policy.py` 单源统一,差异面只剩 prompt/上下文)。
### P1 —— 内容质量(本次刚加 inline 广播,待真机验证)
- **已做**:席位/总控 prompt 注入「任务背景 + 前序交付 + 别提问」。
@@ -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>
```
+13 -3
View File
@@ -30,6 +30,7 @@ import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
@@ -65,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}
@@ -97,11 +99,15 @@ export function App() {
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
<Route
path="/roundtable-share/:shareCode"
element={<PublicRoundtableSharePage />}
element={<PublicRoundtableSharePage view="landing" />}
/>
<Route
path="/roundtable-share/:shareCode/report"
element={<PublicRoundtableSharePage report />}
element={<PublicRoundtableSharePage view="report" />}
/>
<Route
path="/roundtable-share/:shareCode/dashboard"
element={<PublicRoundtableSharePage view="dashboard" />}
/>
<Route
path="/public/scheduled-tasks/:taskId"
@@ -120,10 +126,14 @@ export function App() {
}
/>
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route
path="/roundtable-dashboard"
element={<DashboardStandalonePage />}
/>
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AppProviders>
</HashRouter>
);
}
}
@@ -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/2wCEAAYEBAQFBAYFBQYJBgUGCQsIBgYICwwKCgsKCgwQDAwMDAwMEAwODxAPDgwTExQUExMcGxsbHB8fHx8fHx8fHx8BBwcHDQwNGBAQGBoVERUaHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fH//AABEIBaAKAAMBEQACEQEDEQH/xACVAAEBAQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgBAQEBAQEBAAAAAAAAAAAAAAABAgMEBRAAAQMDAwIEBAYCAgICAAENAQARITFBAlFhEnEDgZGhIrHBMhPw0eFCUgTxBWIjcjMUBoKSokOywtIkFeJTFmMHEQEBAQEAAwEBAAIDAQEAAAAAARECITEDEkFhBFFxEyIy/9oADAMBAAIRAxEAPwD/ADJ3O73PuZjnk3I1J1XqcWfu91/ry8ygfd7n88urlA+73W+vJ+pQT7nd/nlG5QX7vdE88tpKCDu9z+WXmUFPd7tOeUblA+73f5nzKB97ufyy8ygfe7teeXmVA+73J9+TdSgfe7n88m6lBPu92nLLzKov3e4R9eXVygn3e5/PLzKB93uH9+XmUD7vd/nl5lA+73a88o3KB93u/wA8vMoq/d7rtzy0qUD7vdvnkPEoh93ufzy8yig7vdrzPmUQ+73K88vNA+93P5ZeZRT7vcB+vLzKiH3e4w9+XmUU+73P55HxKIfd7tTkfMoH3e6/15eZQPu93+eXmUD7vdec8vMoH3e7/LLq5QT7nd/nl5lFX73dpyy8ygHu93+eW0lUPu90/vPmUQPd7v8APLzKB93u/wAz5lA+73a88vMop97uv9eXmUD7vd/nl5lED3e6z88tpKB93ufzy8ygfd7v88vMoL93ut9WUblBPudz+eXmUF+53f55eaB93ufzy6uUA93uv9eXmVQ+73P55eZQPu93+ZPiUAd3uP8AXl5lA+73f55eZQPvd1/rPmURfu93+eXmUE+73f55eZVF+73P55P1KB93u/yy8ygfd7rPzPmUA97u/wAz5lUPu9yvM+ZQPu9xvryHiUAd3ufzPiSgfd7leeXVygv3e7/M9HKCfd7v8svMoL93u/zy8yqIO73f5HzKC/e7rPzy8ygfd7ls8vMoh97ufzy2koH3O7/M+ZQPu9z+eXmUU+73P55dHKB93uU55eZQPu9wfvy8ygn3e7/PJupQX7vd/nk3UoH3e7/PLzKQfs/9f/tv6eX+qyJ7gxyxxIyxL1ZvVeueXyPp8+p2/HZ97uHPIjPJiYkrzd5vh9bn0n3e7/PLq5WVT7vd/nl5lA+73H+vLzKgfd7l88vMoH3e5/PLzKAO73bZZeZQD3O5bPI+JRT7vc/nl5lBPu9x/ry8yoL93uT7svMoIe73K88vMoH3e7/PLzKC/d7n88m1coH3e40Z5N1KIfd7n88vMoA7vc/nl5lFPu9yvPLzKgn3e5/PLzKoDu93+eXmUD7vdf68vMoH3e7/ADy8ygfd7v8AM+ZUD7vd/nk/UoIe73R+/J+pQX7vcpzy8ygfd7leeXmUD7vd/nkPEoB73c/nl5lA+73f55eZQPu92+eXmVA+73f55eZVD7vctmfMoJ93umeWXmUD7vdb68vMqB93u/zPmVRfu93+eQ8Sgfc7tOeXmUD7vc/mT4lA+73P55eZQT7nc/nl5lBfu93+Z8ygfd7p/efMoH3e7/LLzKgfd7lOeXmVQ+73f55eZQPu9z+eXmUD7vd/mfMoH3e7/PLzKB93ut9eT9Sgfd7v88vMoH3e431k+JQPu9z+eXmUF+73f55eZVD7nc/nl5lBPud3+eXmVBfu9z+R8yqh93u/zy8ygfd7t88vMoq/d7jkc8tpKCHu9z+eXmUD7vcb68vMoH3e6f35eZQPu9z+eXmUD7nddueRGjlA+53R+8t1KB93uu/PLzKC/e7o/fl5lUT7vdj35dHKAO53W+vLzKCnu9xvqy8ygv3e9/PLzKIfd7rfXk3UoH3O649x8ygfd7pP15eZQPu9yPefMwgfd7kjlkPEop93u/yy8yqh93uH9+XmUD7vcMc8urlAHd7n88vMpA+73f5ZeZVD7vdvlk53KAe73f5ZeZUD7nd/nk3UoJ93ufyy8ygfd7l88vMoOv8AV/uZdnv493PlmMX9pyIdwyJZsTu/2Mu538+5iTjjkTkMXJZMWMfe7teeU7lAHd7v88n6lA+73KjPLzKo32v7f9jtl+33csTsSkqWad3+3/Y7uXLudzLI6klW3STGD3e5/M+ZRU+73Qfry8yoB7vcvnl5lA+73THPLaSgfc7388o3QB3e4/15eZQPu9z+eXRygfd7n88vMoi/d7v8z5lFT7nc/nlvJQUd3uV5ZdHKIn3e40Z5byUF+73R+/LzKB97ufyy8ygn3O5/PLzKB93u/wAzTUoL9zufzPmUD7vc/mW6lA+73G+sv1KB93uU55RuVRR3u7fPKdygfd7grnltJQD3O4R9eXRygfd7k+8nxKofc7p/fltJQPu91/rPmYQD3e7/ADy8yiH3e6G9+T9Sgfd7n88vMqi/c7p/efMoJ9zu/wA8vMoL93uP9WXgSgv3e5fPLzKofc7v/wDcy8ymCDu9z+eT9Sgfc7v88tpKgp7ncf68urlBPu9w/uy8ygfd7n8st5KB93ufyy8ygfd7n88p3Kgp7vdf68n6lUPud3+eXVyiB7ndH78vMoJ93un9+QazlBfu9yvLLzKAO73p958ygfc7v88t5KYan3e7QZ5P1KC/d7ls8t5KCfd7k+8kdSgfd7jfXkfEqB93vWzy8SVRfvd1m5mdygn3O6I55eZUD73ceMy3UoH3e68ZZOdyqL9zu3yy2koIO73XnM+ZUFHe7xLcsnO5VD7vdEc8n6qAO53ZbPI+JTDUHe7gB9+XmUgDud2+WWgkpgfdz4znl5lBB3e7Tkd5KBj3O4x9xi7lSQqjvd3+eQ8Sgfd7teWTauUDn3WnPKNymGh7vcrzyO7mqofc7onmZ3Kgfd7wD8y4oHKB9zumueXmUU+73T+7JjdyiB7ndY+8ltygfc7rPyym7lFT7ndb68n6lTBfu9z+eR8SqiHu9wBxnlpUqKDud0//AKQ+ZQPu9wge7LzKoDu9z+eTdSoH3e7/ADy8ygfd7teR8ygn3e67c8iTSSin3u431FxUOVBD3e63/sy1qUF+73D+8v1KBj3e7Puy8yqH3O67cj1coJ93u2zLdSgp7ubfXl5lBPu93+eRrcqCnud2pyPmUEHd7g/fl5lA+73ZHPLq5hBfu9z+eQbcqCc+88ZF+pQU9zuGeeT9UE+93Kc8h0KKo73c/nl5lED3e63/ALMtalUPu93+eXRyoJ93u/zIOjlFX7vcYe8+ZQPu94zzy6uUEPc7gH15TuWUA93u/wAyB1KCfc7j/WX6lBfu93+WQaklAPd7tszO5QPu9y+WR8Sgg7vd/nl4koL93uuRzMblA+73Sfryncqh93uH9+U7lA+53P55eZQT7ndvnkT1KB93u055dXKgfd7v8yAayVQ+73Kcy2rlBfu9yTzy8ygn3e659+T3YlQPu912OeT6uUwPudz+WXmgfd7rxnk3UoIO93aDPLq5QPu9Line 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,iVBORw0KGgoAAAANSUhEUgAAAhgAAACWCAYAAACcofGRAAAABHNCSVQICAgIfAhkiAAAIABJREFUeF7tnVmSJLmSXTMiMiKzqt7A12S3cAcU4Sa4Cn7zj39cA4WbpEjvgMIm+/ENNWRmDNSrgMLV4BgUk7l5pHtJlnvYAIPBBj129QJ29+H2ubXAshZ4u1tW9F4F//cP++/D/7zANvdqz5Xb+Y8f3lYWnyz7f1xgm9N38m7/dpu+D7cCj9gC+988j9gKtzpNaoErBYo9IOIGDZPOsUnF7AEjVwsfN+CYdJZ998XcAOO7PwVGGuCKgGIVROwJDv/7pmzw2fpPO6oGq0DkquDjBhwjd8nved0bYHzPR79r368AKmbCxCqAuMFC19nXvdIqKJkJIFcBHTfY6D4Hv8MVb4DxHR70tl0+MFDMAomZEHEDh7bT62hLzwSRWfBxaPC4AcfRTuEj1ecGGEc6GoeqyzsHixlQcYOJQ52xyyozAzpmwMYNNJYd4lvBa1rgBhhr2vVKSz0gVIyqFCMg8R4A4j8c3Lfxzzv6KVZdlSMAMgoeh4SOm6qx6lS7tnJvgHFtR2xqfQ8EFL0gMQIQ0pZHBImjg8HU83BCYUcElRHwkCbpBZBDgccNOCac4VdZxA0wrvKwjVb6AGDRAxQjMHFpiNgbGP58cOUiPoX/tLOScWkgGYGPHug4BHDcQGP0zn1t698A49qOWFd9rxAoemHiEiAxGx6uDQ66zskFK82GlEtASC94tELHDTgWnIC3IuMWuAHGuz4nvhOw2BsqZgHFDST2ufpmgcfewNEDGzfQ2Oecum3F1AI3wDA107UtdEGwaE19tCgVe4JED0TcgOHaLpR0fXuAZE/4aAGPqwKOWwrlfVxAp724Aca7OKIXAoprhYkeeMB5cg0A8e8vdC7Muo7+1xUEmR4AQfushJB3CR1XcC7MOu/faTk3wLjqA3uBYNICFVZ1YqUy0QoTl4aIaweES19PlwaUVvg4AnS0qBwX8W7cQOPSl1Xv9m+A0dtyF1tvZ6i4FqBoAYm9IWI2NPz17vu8bv/wNvetn3vDSAt8rAAPq8pxaOC4wcbFQk/Hhr/PG1VHQx1jlR3h4hrA4ohQMQsmvleI6L3QZsHHXtBxSdi4etC4QUbvZbL3ejfA2LvFm7d3xVAxM/VhhYmV6sQIPNyAofnMX7rCCJCshBAreMxUOCzAcVhV4wYbS6+TwcJvgDHYgGtW3wkqrCrF3l4KC0ysAIlegLhWePh5p/PspysNAr0QsgJALOAxCzoswIEbnxU6dvNtXOl5tiaIHKLUG2Ac4jBIJXa44VugYk+guARMtIDEpeFhLwg41HXQUJlLw0sLhMwGj72gYyZw7AIbN9BouIKWLnoDjKXN21L4FcHFjNTH3mBxdKg4Akj8wyTz6L9ONmO2XEay7CXA41KwcSTQsKgaN8joOaOvcp0bYFz0sC2GCotagf2vKRZ7AMWslIcVJPZSJmaCwywAuOg5P2HjMwFmLxCxwscslaMGHTPSKTVlwwIbOB2WA8dN0Zhw1XUVcQOMrmYbXekAYLEaKmoKxQygsMDESpDohYfVoPDL4vNr9PTPrf/j4kDQCyYrIcQCHjOgYzVwzICNG2isurIuVu4NMHZr+sU3/ZpaUQMKtMOIUlECilGYuARI9MDDKDhcKxjsdg0ZNzQKKj0gMhtC9gCPEnSMKBw12MBhrKkbN9gwnuzHXuwGGMuPz0KwGIWKIwLFnjDRChE9AHGDhuVX2NAGemCkFUBmwcdq6LgUcFwUNharZkMn5/WvfAOMpcfwHcLFKqWiBhazUh0tUNEKFO8RJr5UjJ+fDmDonH0Nt0JHC3DsBRsjaZUVoFFTNW6QMfssPkR5N8CYfhjeGVRcAij2hIkWiDgCQNQC/vTz+WAFHgFoWgDEAh97QMeRgOMGGwe7qNZV5wYYU9r2BhXFZlypTlgUCStE7A0Qe8HC7xeenyPXz992kqf3hhIrgKyGj1pKpQc6ZqsbN9gYuYIOv+4NMIYO0aIb94i3otdXMVOpKAHFiDoxCyZWgsQMaDgqEAxdKzusPANYVsKIBTwuCR2twDEbNnCKlIDjYmmUnUB4h0tk703cAKOrxQ8GFrOhoqfXRw4qeoGiBhMWVWIFSPQCxA0auq60ZSv1wsgKAJkBHr1plpzK0QobOFA54OjtkZKDjRtoLLssZhd8A4zmFr0AXJS6mPbARU6taAWLFUpFCSwuARU9QHGDiear6hAr9EDHbOAYhY3ZoIED0wobM0GjV9FY1s31pma0XKs3wDC31gKw6E2FHBEqepSKEZiYqU60QsTRAOLLh/d5HX/68Ga+PPdYsBVAZsJHDTxKqZUe6JilbOwBGxdRNG6gYbnk3ueNybLn5mV2BouZasUMpWJm6qMXKGbARAtEXAog3isomK+1SQteCkxaAGQGfJSg44jAMQs2DqVq3ECjdNXeACPbOpPBYi+1YhVUzFQoaqmOEaCwgsReELEIGv7006QXk02K6auL+fPPi8bb2AtGrPAxAh69KscshaMllbIaNnZXNW6gkboH3ADjrFV2BItZakUKKmb4KVqhIqdQlIBiNUysAolBcDgSILwO7ksOLu73Ct4GuhkGlFX7YgGPVdCRUzlagSOVTmmBjZxBtMUcehhV4wYa+nK8Acbm5vQdwkUqBbIaLEagAserpFIcECj2golVoGCI31MX2QtMhqDjEsAxAho4QjmF4yigkVI1bpAx9drau7AbYHCLHwAsWoybo4rFKFTspVTU0h2zYaLzSX4UIN4LGOx986ptbxRUugFkNnzUVI4e8Ojxb7QoG6OqxirYKKVOpvc8uakZN8CYCRcln0UqHTIKFbjDWlMhK6BiZupjT5hoAIkReLiBQw0BjjF/BESaIGQmeMyGjlbg2As2Zng1UumT3UDj+4aM7xQwdlIsch6LUbDYAypaVIqelMfqNEcDRHxoMEz+ScXEG0AcAxBW10IDyJ9bNtZiTJ0BHyXomKVyjKZTRpSNvVWNm6LRcrYnl/0OAWMSXLSqFWh+K1iMpEBGlIoUVORUilaoWAkUVphoAIk/WsscvgQ7C/j2Tse+yDXH48HGxIjq+ZcWQLCCR0uZqXabCRwt/g2runEp2OgZIXQabHxfisZ3BhjfGVy0mDWtcHEUsLACgBEqDgkU3xtEdLLWhwPChxk49oCN1aCRUjWskIFjHoOGtQfKiKJxg4zeq61pve8IMCbAxZ6qxeo0iBUocDq1QEVOqRgxZFpgwggS2J2LwcTBgOHthwnXhOF2c/frwZ7aLgQkZuhAm1rAY0ThyEFHSypltqoxU9Gw9j65GGgc7JowXMc9i3wHgDHpJpqDixHzZm8qpDcNYoWKSwLFRJjYFSR2gIe9gKDnRrLnOrsAy44QYgaPldCxCjh61Y1e2JitauTMoLeUiemSf8eAMQEsWhWL1R6LGCwsKZDZUDFboagBRUWZWA4RC8DhBgqme9P0hZaAyWIQqcJHDTp6VY5R4EipG7Ngw5JC2Qs0cJZOgY33qWi8Q8CYABY4aWYrFrPUiqNARU/KowQThhTHUpgYBIkbNEzngYsWOAwjC8GjCh21FEsPdKSAw5pOuVbYaOneOgUycODeF2i8M8C4wQXlbrfHNNULxJoCmaVWHBUsBqDiBhQXjf+7b3wIOBbBxu6gsYeqYTGH9phCexWNG2QMXWvvCDAmwMVs1QKHJlYuLOZNnQqZpViMQMVMteISKY9OkPgeIOLt69rurndPx+5eOnT39Ct3w8cC8KhCRy6lcmlVY0b6pCd1MmIGXerPeB9KxjsBjEG4mA0WPemQUX/FbKWiBSoGFIppaY/vACJWw8CMYLtHGdcELZeGj27gwIFsgY6VKZQVqsZVKBrXDxlXDhiDYJHzWvT2DGkFi1GoQP1jsLAoFTNSHzmo2EOhaICJI6sQN2DYA0coq31gFaUJQCYoHt3AMQobDCxvb9UjHvs1YmVjNmysBo0p3ozrBY0rBoxBuEipFr1De7emQXrAwhm33PGaBRWLVYpp6oTclWpg8Ue34NvXwXOjehcsL3AEcHh7vruqa/vuoyH4DB6X2uqXBJG7JxVE/lKp6QTQ0FvIQkeph8oocIzAhgUysIOtXo0YNmrpk5YxNIZB4zoh46puQqeLYjCAWOHC0u10NVzUTJu9ioUVLjqUimGwqIGEujteWqG4BExcGzzUArt1/iUg5JLQgXbZU+Eoqhuj3o3eFMqoorEaMnCQrEbQ7xAyrhAwBuDC6rU4Oli8N6gwAsXeMLESHr5XSLDCxOzlVsLJ3hBiho4BpePwsNGaOrkEaCwxgV6XknFFgDEAFrhbzVItVioWo2pFyluxQKnYQ6HYEyZmgsTFwOHz4PUxO6KPlvfbZW6kM0FkT/AwQccAcOBwJqHjEsrGiKqxGjRuasbmyr8SwBi4eV4CLFo9FiWw6FErLFDRmProhooDqRMzQGI5QLw3UBgFjdb1F4PJDADZAzxMwIG27YSOZt+GxbMRp1FafRorVY0Z/oyUojGUNrkMhLdcklcAGO8YLi6lWKTgItP749rBYhQqlgHFDSRa7lPjyy4Cj1HgWA0bhwKNHsjAka+Bxl6Kxg0ymq/DgwPGheFiVTpkBCx60yAHgIpVaY9RiMBVMxUkbvDQfCM6xAoLine truncated

After

Width:  |  Height:  |  Size: 33 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="64" viewBox="0 0 1920 64"><defs><style>.a{fill:url(#a);}.b{fill:url(#b);}.c{clip-path:url(#c);}.d{fill:url(#d);}</style><linearGradient id="a" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#131f3d"/><stop offset="1" stop-color="#094593"/></linearGradient><linearGradient id="b" x1="0.473" x2="0.473" y2="0.95" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#131f3d"/><stop offset="1" stop-color="#094593" stop-opacity="0"/></linearGradient><clipPath id="c"><rect class="a" width="1920" height="64" transform="translate(-2 5)"/></clipPath><pattern id="d" preserveAspectRatio="xMidYMid slice" width="100%" height="100%" viewBox="0 0 1917 104"><image width="1917" height="104" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAB30AAABoCAYAAAD1oOqOAAAABHNCSVQICAgIfAhkiAAAIABJREFUeF7svQnUbMdRJli3qv717U+bpSdZsi1LtiUbtVdsQ7Nz2LpZvNADPdimWYfumW4Yz4Dpw+FwGs2hB4YZppsxDeOF5TCNMYYeNwM+mIPBm7zbki3JkmxL1tPytLz1X6vq3onIyMiMjBt5q/7//ZL+/ylL+l/VzZtL5BdLZkbkzVt986eaqlc+BYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgILAnEahK0HdP8q0QXRAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQEHAIl6FsEoSBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQECgI7GEEStB3DzOvkF4QKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBErQt8hAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBDYwwiUoO8eZl4hvSBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQECgIlKBvkYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgILCHEShB3z3MvEL6U4pA9ZS2bjfe7EKaCkkFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgsDsRKH7u3cmXQlVBYFsIlKDvtmArhQoCDoHdMiCWYG8RyIJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBDYDgLFz70d1EqZgsAuRKAEfXchUwpJewqB3TAglqDvnhKZQmxBoCBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQENhVCBQ/965iRyGmILA9BErQd3u4lVJ7G4HdMIDtVgRLAHm3cqbQVRAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQEIgLFz52XhuLnLprytESgBH2flmx/2ne6DIZlMHzaK0EBoCBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQENjTCBQ/d/Fz72kBLsTvPAIY9O3vfLWlxoJAQaAgUBAoCBQECgK7GoGy43NXs6cQVxAoCBQECgIFgYJAQaAgUBDYNgIlCLJt6ErBgkBBoCBQECgIFAT2MgIl6LuXuVdoLwgUBAoCBYGCQEFgJxEogeCdRLPUVRAoCBQECgIFgYJAQaAgUBB44hEoAd4nHuPSQkGgIFAQKAgUBAoCewSBEvTdI4wqZO4qBGZdUGDwYNa8XR3U9XRdl4DFrhKVQkxBoCBwgSGwE3a92OkLTChKdwoCBYGCQEGgIFAQKAgUBEwELH/IrPPpWfMV6AsCBYGCQEFg+whsx2/9RNtnWX/xn2yft6Xk0xiBEvR9GjO/dH1LCGxnENxSA09A5jIwPgGglioLAgWBgsAOIFDs8w6AWKooCBQECgIFgYJAQaAgUBDY1QjsRT/Krga0EFcQKAgUBHYIgb1on4sfZYeYX6q58BGobvqPHxwcfeWrm8c+9KGq8m/3xevH4bqH13UE4eirp6djWS6BdbgyH4FvrBO+sb5QD2bkNwpjO/C7gW9ndbht/91A4kWvinXo+4FKyB/akvzz9Ye+wT2ss8LGNE3cPt7zveFyeHmR7xNXL+8xhlgn16/pcX0U2CZ4+EoZN3fpaeffrl5t5sSbmRu4F+5LHI26knb4PnfM4IHDiz8ZXubkJ9Cde4u0qi+Li5fNRNZQtuDDbVtyJ+UZ70N2V+bsbbdWJAjIbw8sXuvfPu3AjS9ssIz7/vxt1YEbbgzcCOlYJ3wwj2sD8rlryIt5HI8EliGfpMXTw+0wfQ0UPOjrcZVIOgV7Qn98Q9w2lpE0n/G0jk+fcbQGmfC6H3TP6zPmcfYBP17fXJrXb66DSXnswx+qpO5i/TpN1hXsBbQRdBQzeHpMO6RlSuhMS+c9t1rb0ry8a52Q5YO98vQktGTSWN81z009Ufoq87AdYRvZ0l0PuLQ3rCPBvmtcgGdsL6S+ef1wNTq+Mv4GzhYGQQzVGMLpWsaS/Hwhxp4ue8/20cIqjF+SDm0TNd+kDsnxyac78eGxA/VFjG0sx5Imrk6Of0GHrDHW65EcnzQ+SZtCByXpgW8STzk+qnGF9VaO/S3eajngOoTtTngrxm83Jmk+qPqkjPM4G8ZLySdh7wOWyAs/R5G6zbLM8xGNvewjt896E7CQdHJfUXf8WJ7kF0zQY5g1prnsYkA/+qqvQ/Kb43/27urYD7ymOf7ud5sLsWOviffwN1Zz/5++uyJzLyYIrEdaF0WbjBd+X/na1/m63uXma/i58vU+7U8gzX8w7X5xbaVznlxertuqR8ryLG1JOmXZvfq7mUx6zXjcw+8av/FvNMLfVTMaw/cIrq1vyof5XTlRz3axYP5vt/xeLCdlUsqx0zND7qUsS3mV2HXJ+Sw6YtExTTem3Z+FN5b+W7qby3e+eafRqPs4zV7NypNZbNA02vR9i9YuWcvJm8XXLvy77PY0mnJlu2TW6rfuS64PjMesdE0bk7bKo5K/ILATuq/nP9a1bEfL/Sz6pccariP4omQDuKjQn2RNl96Xc0xdjO+5bz9XxTyPf/iDsUZoz5p/u3zsH/S+BL0eb/kJ9fpfEJRdK4n1icsu1/jKhyF9Gi4v+z/wt/AVJmtRkU+ue1prCuFP5XvW2kquP+R6xeGlfFzsi0j8i3KuL2jTflbpJ2NeJPV5bALvlA8g8YlABdx3XA8hVs7fI/BNZEfQmPjrpL9HrE0Tf+YUs8S+JfbrYvYgV9baU9aX8zdwHuU7QR+W5T/WfhS5Dmdfe/AhC/mUdOZ4mkuXGGm/EPLEtaf0jX13vMZl/5TVhpbZRC6kH8HLnKkLjGPOJ235psT6WvoFuX7te0l8zJKfks+WDRC2QdsJ9lU6H5qQdeZliFfgPY6d6PaU3OqYQLA3yucubUTwH2qfi9BN5gvSPHfwYDDm7of0FXt62JeNl+G3dExDo4lvmvvBdUm/uUiTvmbnn5Y+aPSFC591yOtp4Cacz5zzyXb9bzleBL+6b8c5NRWtmHLwxhfWLO/a9rCtcLYeMksZb/l35Jjp/UFYXvuLEhsv5LHlX9W+OIyFQX0XSd+ib7MVI5N6JfWP7a+/nx2TfBmuJrEfYsx1/RM+frzW/iwZ8+L6ZBxQxhOt8YbLI/uSeB9UJmNvLb1H8ZY4aFuOtPrYpPaLmv59gx8hTsjxTBUjlfMcppXHCEmvNT6YsQDsD/tr1Rwp6LmP23LfJI8sPyzed0HfhBFRVZLAayuQyIJiOMWTwIgSKFd9LkAjGOUUGgVfBjCR8XryZdWv29BOT1YeVXdCG5SRAadE0TJttiYXVsBD4mUFIgwFZrwCDWiRkuirZBr8tgRW0txFF1dlDOStwItBPwVECIng5Jfk6XotupR8uH5jZcgQvgeC0cD13KE4sCkUOi+9Z7w3OnNajA693twBqM8PXhhcJf+5xxvb5DSrdj3Q6Dz+vquu3w7WNnWTstUKRCt6zE4ag3sY8HNyg33bCoAi7/gsBIvl4sqyCZZcs55aQQnUfeS7L8f2wHXfTwRlEFXqp95UoQO1VgDRrYWRL9yeH/hde06a1SfXR5EuaTbxsXQtg0UreNllR1intJ3VfRCL2rAJxehXa7PGlMl0blGsF56JnWI1k0Io7H/CA22/jEWEmV/3LTcOSfy0LZXyoHmgy0mZl/oh5V7TnrOHurxVt2rfXOxKGVAT1xZ/JC1WOSmrmb63+KD7Z2FtjRE+n9bF7CJvlrFX9smYJEqdNbHU+DFfc7hJW6Z1ka9bu7liobWHHu400Sk2Pqulq76NUFlOn6VBZPoyFCS22Jie6A0vVvdnyZODraQXBJ4MBLr21z0Z7W+nDUuvcrq21XRNzzQ7gPl3Ws/PtLine 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

+312 -41
View File
@@ -43,8 +43,8 @@
width: 100%;
height: 75px;
margin-top: 13px;
font-family: Noto Sans S Chinese;
font-weight: 500;
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif;
font-weight: 700;
color: #fff;
font-size: 20px;
display: flex;
@@ -111,6 +111,42 @@
word-break: break-all;
}
.sidebar-contnet--top-submenu .sidebar-contents {
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-contnet--top-submenu .item-nav {
height: auto;
min-height: 75px;
padding: 10px 2px;
font-size: 19px;
line-height: 1.35;
}
.sidebar-contnet--top-submenu .item-nav__label {
width: 3.1em;
max-width: 100%;
padding-left: 0;
letter-spacing: 0;
line-height: 1.35;
text-align: center;
word-break: normal;
overflow-wrap: normal;
white-space: normal;
}
.sidebar-contnet--top-submenu .item-nav__label--four-char {
width: 2.2em;
}
.sidebar-contnet--top-submenu .item-nav:hover::after,
.sidebar-contnet--top-submenu .item-nav-active::after,
.sidebar-contnet--top-submenu .item-nav-hovered::after {
top: auto;
bottom: 0;
}
.item-nav-content {
width: 155px;
height: calc(100vh - 4rem - 20px);
@@ -194,10 +230,10 @@
border: 0;
background: transparent;
padding: 3px 0;
font-family: Noto Sans S Chinese;
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif;
font-size: 18px;
line-height: 16px;
font-weight: 500;
font-weight: 700;
letter-spacing: 0;
color: rgba(255, 255, 255, 0.8);
text-align: left;
@@ -343,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;
@@ -362,48 +405,31 @@
border: 0;
padding: 0 18px;
background: transparent;
color: rgba(224, 242, 255, 0.9);
font-family: Noto Sans S Chinese;
font-size: 16px;
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: 18px;
line-height: 1;
font-weight: 500;
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 {
@@ -443,10 +469,10 @@
padding: 0 16px;
background: rgba(2, 28, 65, 0.36);
color: rgba(221, 242, 255, 0.78);
font-family: Noto Sans S Chinese;
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif;
font-size: 14px;
line-height: 1;
font-weight: 500;
font-weight: 700;
letter-spacing: 0;
white-space: nowrap;
cursor: pointer;
@@ -472,3 +498,248 @@
background: linear-gradient(90deg, rgba(62, 178, 255, 0) 0%, #4ee6ff 50%, rgba(62, 178, 255, 0) 100%);
box-shadow: 0 0 10px rgba(78, 230, 255, 0.72);
}
/* ══════════════════════════════════════════════════════════════════════
浅色主题导航(红/蓝两套,颜色经 --brand-* 变量自动切换)
逻辑同深色:一级导航在顶栏、二级导航在侧边栏、三级导航在顶栏下方横条。
但一级导航不做深色那种围绕基座的特殊处理,正常横排即可。
══════════════════════════════════════════════════════════════════════ */
/* ── 顶栏一级导航(白字落在 red/blue-header banner 上)──────────────────── */
.light-topnav {
display: flex;
align-items: center;
height: 64px;
min-width: 0;
gap: 6px;
/* 整体往右移(用百分比,随容器宽度自适应;约等于宽屏下的 200px 量级) */
margin-left: 10%;
overflow-x: auto;
overflow-y: hidden;
}
.light-topnav-item {
position: relative;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
height: 60px;
padding: 0 18px;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.85);
/* 复用全局字体栈(与「多智能体会商」等页面一致):Source Han Sans SC 在 Windows 上
常按字重分家族,放栈首会导致 font-weight 选不到粗体;用 --font-sans(Noto Sans SC
领先)才能正常加粗。 */
font-family: var(--font-sans);
font-size: 16px;
/* 普通项用 500(雅黑兜底下渲染为常规字面),选中项 700 才能看出粗体对比 */
font-weight: 500;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: color 0.18s ease;
}
.light-topnav-item:hover {
color: #ffffff;
}
/* 选中态:不改背景,文字加粗变亮 + 贴近导航栏底部一条粗白线 */
.light-topnav-item--active,
.light-topnav-item--active:hover {
color: #ffffff;
font-weight: 700;
}
.light-topnav-item--active::after {
content: '';
position: absolute;
left: 12px;
right: 12px;
bottom: 0;
height: 4px;
border-radius: 3px 3px 0 0;
background: #ffffff;
}
/* ── 侧边栏:二级图标卡片列(96px)+ 右侧三级列(展开时整体变宽,带过渡)──────── */
.light-sidebar {
position: fixed;
left: 0;
top: 4rem;
z-index: 40;
width: 96px;
height: calc(100vh - 4rem);
display: flex;
flex-direction: row;
background: #ffffff;
border-right: 1px solid var(--border);
box-sizing: border-box;
overflow: hidden;
/* 展开三级列时整体变宽的过渡动画 */
transition: width 0.3s ease;
}
/* 展开三级列:96(二级) + 140(三级) = 236px */
.light-sidebar--expanded {
width: 236px;
}
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
.light-sidebar__primary {
flex: 0 0 96px;
width: 96px;
height: 100%;
display: flex;
flex-direction: column;
/* 横向 padding 置 0:让选中态浅红背景能铺满与列宽一致 */
padding: 10px 0;
box-sizing: border-box;
}
/* 三级列:固定 140px,随侧边栏展开淡入 */
.light-sidebar__secondary {
flex: 0 0 140px;
width: 140px;
height: 100%;
border-left: 1px solid var(--border);
/* 横向 padding 置 0:让三级选中背景能铺满与列宽一致(文字内缩由 subitem 自身 padding 保证) */
padding: 12px 0;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
background: #ffffff;
opacity: 0;
transition: opacity 0.2s ease;
}
.light-sidebar--expanded .light-sidebar__secondary {
opacity: 1;
}
.light-sidebar__list {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
overflow-x: hidden;
}
.light-sidebar-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 14px 4px;
border: 0;
border-radius: 0;
background: transparent;
color: #222222;
/* 复用全局字体栈,确保加粗生效(详见 .light-topnav-item 注释) */
font-family: var(--font-sans);
font-size: 16px;
font-weight: 500;
line-height: 1.3;
text-align: center;
cursor: pointer;
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.light-sidebar-item__icon {
flex-shrink: 0;
/* 图标统一用主题色(红主题红、蓝主题蓝) */
color: var(--brand-900);
transition: color 0.18s ease, transform 0.18s ease;
}
.light-sidebar-item__label {
display: block;
width: 100%;
word-break: break-word;
}
.light-sidebar-item:hover {
background: var(--brand-200);
}
.light-sidebar-item:hover .light-sidebar-item__icon {
color: var(--brand-900);
transform: scale(1.05);
}
.light-sidebar-item--active {
background: var(--brand-200);
color: var(--brand-900);
font-weight: 600;
/* 选中态:去圆角去边框,浅红底铺满父元素宽度,右侧一条粗主题色边(用 inset 阴影避免挤压内容) */
box-shadow: inset -4px 0 0 0 var(--brand-900);
}
.light-sidebar-item--active .light-sidebar-item__icon {
color: var(--brand-900);
}
/* 侧边栏底部:设置入口(浅色模式不支持收起,故无收起按钮) */
.light-sidebar__footer {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
/* ── 三级列条目(左对齐图标 + 文字,参考 xiugai-1.png)─────────────────────── */
.light-sidebar-subnav {
display: flex;
flex-direction: column;
gap: 2px;
}
.light-sidebar-subitem {
position: relative;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px;
border: 0;
background: transparent;
color: #222222;
font-family: var(--font-sans);
font-size: 14px;
font-weight: 500;
line-height: 1.3;
text-align: left;
cursor: pointer;
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.light-sidebar-subitem__icon {
flex-shrink: 0;
/* 图标用主题色(红主题红、蓝主题蓝) */
color: var(--brand-900);
}
.light-sidebar-subitem__label {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.light-sidebar-subitem:hover {
background: var(--brand-200);
}
.light-sidebar-subitem--active {
background: var(--brand-200);
color: var(--brand-900);
font-weight: 700;
box-shadow: inset -4px 0 0 0 var(--brand-900);
}
@@ -124,6 +124,13 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
}
};
const handleSecondItemClick = (item: MenuItem) => {
const path = item.path ?? findFirstRoutePath(item.children);
if (path) {
navigate(path);
}
};
const handleMenuItemClick = (item: MenuItem) => {
if (item.children?.length) {
toggleExpanded(item.id);
@@ -135,6 +142,35 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
}
};
const activeTopItem =
visibleMenuItems.find((item) => item.id === activeTopId) ??
visibleMenuItems.find(hasActiveChild) ??
visibleMenuItems[0];
const sidebarMenuItemsToRender = inWujieEnv
? visibleMenuItems
: activeTopItem?.children ?? [];
const isSidebarItemActive = (item: MenuItem) =>
inWujieEnv
? item.id === activeTopId
: item.path
? pathname === item.path
: hasActiveChild(item);
const handleSidebarItemClick = (item: MenuItem) => {
if (inWujieEnv) {
handleTopItemClick(item);
return;
}
handleSecondItemClick(item);
};
const getSidebarLabelClassName = (label: string) => {
if (!inWujieEnv && Array.from(label.trim()).length === 4) {
return "item-nav__label item-nav__label--four-char";
}
return "item-nav__label";
};
const renderFlyoutMenu = (items: MenuItem[], level = 0) => (
<div className={`sidebar-flyout-list sidebar-flyout-list--level-${level}`}>
{items.map((item) => {
@@ -181,7 +217,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
return (
<aside className={`sidebar-contnet ${inWujieEnv ? "" : "sidebar-contnet--top-submenu"}`}>
<div className="sidebar-contents">
{visibleMenuItems.map((item) => (
{sidebarMenuItemsToRender.map((item) => (
<div
key={item.id}
className="item-nav-group"
@@ -198,10 +234,10 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
<div className="item-nav-wrap">
<button
type="button"
className={`item-nav ${item.id === activeTopId ? "item-nav-active" : ""} ${hoveredTopId === item.id ? "item-nav-hovered" : ""}`}
onClick={() => handleTopItemClick(item)}
className={`item-nav ${isSidebarItemActive(item) ? "item-nav-active" : ""} ${hoveredTopId === item.id ? "item-nav-hovered" : ""}`}
onClick={() => handleSidebarItemClick(item)}
>
<span className="item-nav__label">{item.label}</span>
<span className={getSidebarLabelClassName(item.label)}>{item.label}</span>
</button>
{inWujieEnv && item.children?.length && hoveredTopId === item.id ? (
<span className="item-nav-hover-bridge" aria-hidden="true" />
@@ -0,0 +1,303 @@
import { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
Bell,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
Folder,
FolderOpen,
Settings,
User,
Wrench,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { SettingsDialog } from "@/components/workspace/settings";
import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import {
defaultExpandedIds,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
type PageSidebarProps = {
collapsed: boolean;
onToggleCollapsed: () => void;
};
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
export function PageSidebar({
collapsed,
onToggleCollapsed,
}: PageSidebarProps) {
const { theme } = useTheme();
const isDark = theme === "dark";
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(defaultExpandedIds));
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] =
useState<SettingsSection>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const navigate = useNavigate();
const { pathname } = useLocation();
useEffect(() => {
setMounted(true);
}, []);
const openSettings = (section: SettingsSection) => {
setSettingsDefaultSection(section);
setSettingsOpen(true);
};
const iconButtonClass = isDark
? "rounded-lg p-2 text-slate-400 transition-all duration-300 ease-out hover:scale-105 hover:bg-slate-800/50 hover:text-white"
: "rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
const panelClass = isDark
? "ml-4 space-y-1 border-l border-blue-800/30 pl-4"
: "ml-4 space-y-1 border-l border-red-100 pl-4";
const labelClass = (compact: boolean, small = false) =>
`overflow-hidden whitespace-nowrap transition-all duration-300 ease-out ${
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) =>
`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
? "bg-blue-500/15 text-cyan-300 font-semibold"
: "bg-red-50 text-red-600 font-semibold"
: isDark
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
: "text-gray-600 hover:bg-red-50 hover:text-red-600"
}`;
const footerButtonClass = menuButtonClass(false, 0);
const toggleExpanded = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const renderLeaf = (item: MenuItem, depth = 0) => {
const Icon = item.icon;
const isActive = !!item.path && pathname === item.path;
return (
<button
key={item.id}
type="button"
title={collapsed && depth === 0 ? item.label : undefined}
onClick={() => item.path && navigate(item.path)}
className={menuButtonClass(isActive, depth)}
>
{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}
</span>
)}
</button>
);
};
const renderBranch = (item: MenuItem, depth = 0) => {
const expanded = expandedIds.has(item.id);
const isTopLevel = depth === 0;
const Icon = depth > 0 && item.icon === Folder && expanded ? FolderOpen : item.icon;
return (
<div key={item.id} className="space-y-1">
<button
type="button"
onClick={() => {
if (collapsed && isTopLevel) {
onToggleCollapsed();
return;
}
toggleExpanded(item.id);
}}
title={collapsed && isTopLevel ? item.label : undefined}
className={menuButtonClass(false, depth)}
>
<Icon
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" : ""
}`}
/>
{(!collapsed || !isTopLevel) && (
<span className={labelClass(false, depth > 0)}>
{item.label}
</span>
)}
{(!collapsed || !isTopLevel) && (
<ChevronRight
className={`h-4 w-4 transition-transform duration-300 ease-out ${expanded ? "rotate-90" : ""}`}
/>
)}
</button>
{(!collapsed || !isTopLevel) && item.children && (
<div
className={`${panelClass} overflow-hidden transition-all duration-300 ease-out ${
expanded ? "max-h-[1200px] opacity-100" : "max-h-0 opacity-0"
}`}
>
{item.children.map((child) =>
child.children ? renderBranch(child, depth + 1) : renderLeaf(child, depth + 1),
)}
</div>
)}
</div>
);
};
return (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<aside
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
: "border-r border-red-100 bg-white"
} ${collapsed ? "w-16" : "w-64"}`}
>
<button
type="button"
onClick={onToggleCollapsed}
title={collapsed ? "展开侧边栏" : "收起侧边栏"}
className={`absolute -right-3 top-6 z-50 flex h-6 w-6 items-center justify-center rounded-full shadow-md theme-transition ${
isDark
? "bg-slate-700 text-slate-300 hover:bg-slate-600"
: "bg-white text-gray-500 hover:bg-red-50 hover:text-red-600 border border-red-100"
}`}
>
{collapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronLeft className="h-3 w-3" />}
</button>
<nav className={`flex-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
{visibleMenuItems.map((item) =>
item.children ? renderBranch(item) : renderLeaf(item),
)}
</nav>
<div
className={`mt-auto shrink-0 theme-transition ${
isDark ? "border-t border-blue-800/30" : "border-t border-red-100"
} ${collapsed ? "p-2" : "p-4"}`}
>
{mounted ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title={collapsed ? t.workspace.settingsAndMore : undefined}
className={footerButtonClass}
>
<Settings className="h-5 w-5 flex-shrink-0" />
{!collapsed && (
<>
<span className={labelClass(false)}>
{t.workspace.settingsAndMore}
</span>
<ChevronsUpDown className="h-4 w-4 flex-shrink-0" />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={collapsed ? "start" : "end"}
side={collapsed ? "right" : "top"}
sideOffset={4}
className="min-w-56 rounded-lg"
>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<Bell className="size-4" />
{t.notifications.title}
{unreadCount > 0 && (
<span
className={`ml-auto flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium leading-none text-white ${
isDark ? "bg-cyan-500" : "bg-red-500"
}`}
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("tools")}>
<Wrench className="size-4" />
{t.settings.sections.tools}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("account")}>
<User className="size-4" />
个人信息
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
className={`${footerButtonClass} pointer-events-none`}
>
<Settings className="h-5 w-5 flex-shrink-0" />
{!collapsed && (
<span className={labelClass(false)}>
{t.workspace.settingsAndMore}
</span>
)}
</button>
)}
</div>
<div
className={`pointer-events-none absolute right-0 top-0 h-32 w-32 rounded-full blur-3xl theme-transition ${
isDark ? "bg-blue-500/10" : "bg-red-500/5"
}`}
/>
<div
className={`pointer-events-none absolute bottom-20 right-0 h-24 w-24 rounded-full blur-3xl theme-transition ${
isDark ? "bg-cyan-400/5" : "bg-red-400/5"
}`}
/>
</aside>
</>
);
}
+210 -8
View File
@@ -22,14 +22,18 @@ import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { SettingsDialog } from "@/components/workspace/settings";
import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import {
defaultExpandedIds,
getActiveThirdLevelItems,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import "@/components/page-sdebar.css";
type PageSidebarProps = {
collapsed: boolean;
onToggleCollapsed: () => void;
@@ -37,7 +41,201 @@ type PageSidebarProps = {
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
export function PageSidebar({
const hasActiveChild = (item: MenuItem, pathname: string): boolean => {
if (item.path && pathname === item.path) {
return true;
}
return item.children?.some((child) => hasActiveChild(child, pathname)) ?? false;
};
const findFirstRoutePath = (items?: MenuItem[]): string | null => {
if (!items?.length) {
return null;
}
for (const child of items) {
if (child.path) {
return child.path;
}
const nested = findFirstRoutePath(child.children);
if (nested) {
return nested;
}
}
return null;
};
/**
* 浅色侧边栏入口。
*
* - 普通桌面环境:渲染「二级导航图标卡片」(一级在顶栏、二级在侧边栏,参考 导航-1.png)。
* - wujie 嵌入环境:顶栏被宿主隐藏,一级导航无处安放,故回落到原手风琴整树菜单
* (保证嵌入模式下所有层级仍可达,零回归)。
*/
export function PageSidebar({ collapsed, onToggleCollapsed }: PageSidebarProps) {
if (isWujieEnv()) {
return (
<LegacyAccordionSidebar
collapsed={collapsed}
onToggleCollapsed={onToggleCollapsed}
/>
);
}
return <SecondaryNavSidebar />;
}
/**
* 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --brand-* 变量自动切换)。
* 浅色模式不支持侧边栏收起,故无收起按钮、不接收 collapsed 相关 props。
*/
function SecondaryNavSidebar() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] =
useState<SettingsSection>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const navigate = useNavigate();
const { pathname } = useLocation();
useEffect(() => {
setMounted(true);
}, []);
const openSettings = (section: SettingsSection) => {
setSettingsDefaultSection(section);
setSettingsOpen(true);
};
// 当前激活的一级菜单(与顶栏一级导航联动);其 children 即侧边栏要渲染的二级导航。
const activeTopItem =
visibleMenuItems.find((item) => hasActiveChild(item, pathname)) ??
visibleMenuItems[0];
const secondaryItems = activeTopItem?.children ?? [];
const isItemActive = (item: MenuItem) =>
item.path ? pathname === item.path : hasActiveChild(item, pathname);
const handleItemClick = (item: MenuItem) => {
const path = item.path ?? findFirstRoutePath(item.children);
if (path) {
navigate(path);
}
};
// 当前激活二级项的三级子项;非空时侧边栏在右侧展开「三级列」并整体变宽(见 css 过渡)。
const thirdItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
const expanded = thirdItems.length > 0;
return (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<aside className={`light-sidebar ${expanded ? "light-sidebar--expanded" : ""}`}>
{/* 一级列:二级图标卡片 + 底部设置 */}
<div className="light-sidebar__primary">
<nav className="light-sidebar__list" aria-label="二级菜单">
{secondaryItems.map((item) => {
const Icon = item.icon;
const active = isItemActive(item);
return (
<button
key={item.id}
type="button"
title={item.label}
onClick={() => handleItemClick(item)}
className={`light-sidebar-item ${active ? "light-sidebar-item--active" : ""}`}
>
<Icon className="light-sidebar-item__icon h-7 w-7" />
<span className="light-sidebar-item__label">{item.label}</span>
</button>
);
})}
</nav>
<div className="light-sidebar__footer">
{mounted ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title={t.workspace.settingsAndMore}
className="light-sidebar-item"
>
<Settings className="light-sidebar-item__icon h-7 w-7" />
<span className="light-sidebar-item__label">设置</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="right"
sideOffset={8}
className="min-w-56 rounded-lg"
>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<Bell className="size-4" />
{t.notifications.title}
{unreadCount > 0 && (
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--brand-900)] px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("tools")}>
<Wrench className="size-4" />
{t.settings.sections.tools}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("account")}>
<User className="size-4" />
个人信息
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
{/* 二级列:三级导航(激活二级项有三级子项时展开,整体动画见 css width transition) */}
<div className="light-sidebar__secondary" aria-hidden={!expanded}>
<nav className="light-sidebar-subnav" aria-label="三级菜单">
{thirdItems.map((child) => {
const ChildIcon = child.icon;
const childActive = child.path
? pathname === child.path
: hasActiveChild(child, pathname);
return (
<button
key={child.id}
type="button"
title={child.label}
onClick={() => handleItemClick(child)}
className={`light-sidebar-subitem ${childActive ? "light-sidebar-subitem--active" : ""}`}
>
<ChildIcon className="light-sidebar-subitem__icon h-4 w-4" />
<span className="light-sidebar-subitem__label">{child.label}</span>
</button>
);
})}
</nav>
</div>
</aside>
</>
);
}
/**
* 旧版浅色整树手风琴侧边栏。仅用于 wujie 嵌入环境(顶栏被宿主隐藏时保证所有层级可达)。
*/
function LegacyAccordionSidebar({
collapsed,
onToggleCollapsed,
}: PageSidebarProps) {
@@ -66,9 +264,6 @@ export function PageSidebar({
setSettingsOpen(true);
};
const iconButtonClass = isDark
? "rounded-lg p-2 text-slate-400 transition-all duration-300 ease-out hover:scale-105 hover:bg-slate-800/50 hover:text-white"
: "rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
const panelClass = isDark
? "ml-4 space-y-1 border-l border-blue-800/30 pl-4"
: "ml-4 space-y-1 border-l border-red-100 pl-4";
@@ -77,8 +272,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 +309,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 +346,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>
@@ -4,15 +4,16 @@ import {
ArrowDownIcon,
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
FileType2Icon,
LoaderIcon,
PackageIcon,
SaveIcon,
UploadCloudIcon,
XIcon,
} from "lucide-react";
DownloadIcon,
ExternalLinkIcon,
EyeIcon,
FileType2Icon,
LoaderIcon,
PackageIcon,
SaveIcon,
UploadCloudIcon,
XIcon,
} from "lucide-react";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
@@ -37,11 +38,11 @@ import {
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { publishArtifactToKnowledge } from "@/core/agents/api";
import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api";
import {
downloadArtifactFile,
} from "@/core/artifacts/download";
import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api";
import {
downloadArtifactFile,
} from "@/core/artifacts/download";
import {
useArtifactContent,
useUpdateArtifactContent,
@@ -120,12 +121,19 @@ export function ArtifactFileDetail({
filepath: filepathFromProps,
threadId,
onClose,
allowVirtualMarkdownEdit = false,
}: {
className?: string;
filepath: string;
threadId: string;
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
onClose?: () => void;
/**
* 圆桌沙箱(Step2/Step3)用:流式结束后,把 `write-file:` 虚拟 md 预览切回真实
* artifacts API(可加载 + PATCH 保存),从而支持用户编辑(对齐主聊天页 md 可编辑)。
* 默认 false —— 主聊天页等其它调用方行为不变。
*/
allowVirtualMarkdownEdit?: boolean;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
@@ -140,10 +148,6 @@ export function ArtifactFileDetail({
}
return filepathFromProps;
}, [filepathFromProps, isWriteFileVirtual]);
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API。 */
const useWriteFileStreaming = useMemo(() => {
return isWriteFileVirtual && !isImageFile(filepath);
}, [filepath, isWriteFileVirtual]);
const isSkillFile = useMemo(() => {
return filepath.endsWith(".skill");
}, [filepath]);
@@ -152,6 +156,24 @@ export function ArtifactFileDetail({
const isSpreadsheetFile = useMemo(() => {
return /\.xlsx$/i.test(filepath);
}, [filepath]);
// 圆桌沙箱:write_file 虚拟预览的 md 在流式结束(thread 非 loading)后,切回真实
// artifacts API 加载并允许编辑/保存。流式进行中仍走内存 stub 只读预览;仅
// allowVirtualMarkdownEdit 开启的调用方(Step2/Step3 沙箱)生效,主聊天页不受影响。
const editVirtualMarkdown = useMemo(() => {
return (
allowVirtualMarkdownEdit &&
isWriteFileVirtual &&
!isImageFile(filepath) &&
!isMock &&
!thread.isLoading &&
checkCodeFile(filepath).language === "markdown"
);
}, [allowVirtualMarkdownEdit, isWriteFileVirtual, filepath, isMock, thread.isLoading]);
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API;
* 圆桌结束后可编辑的 md(editVirtualMarkdown)亦回到真实 API(可加载 + PATCH 保存)。 */
const useWriteFileStreaming = useMemo(() => {
return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown;
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
const { isCodeFile, language } = useMemo(() => {
if (useWriteFileStreaming) {
let nextLanguage = checkCodeFile(filepath).language;
@@ -172,7 +194,10 @@ export function ArtifactFileDetail({
}, [language]);
const { content, isApplyingEdit } = useArtifactContent({
threadId,
filepath: filepathFromProps,
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
// 不会被旧的内存 stub 值覆盖回退。
filepath: editVirtualMarkdown ? filepath : filepathFromProps,
enabled: isCodeFile && !useWriteFileStreaming,
});
@@ -512,7 +537,22 @@ export function ArtifactFileDetail({
}
}, [displayContent, filepath]);
const handleDownloadWriteFile = useCallback(() => {
/** HTML 产物在新窗口打开(结果绘制汇总/大屏多页都需要全屏看)。
* blob: URL 带 charset=utf-8(与预览/下载一致,防中文乱码);URL 延迟回收,
* 给新窗口足够的加载时间。 */
const handleOpenInNewWindow = useCallback(() => {
const source = displayContent ?? "";
if (!source.trim()) {
toast.error("文件内容为空,暂时无法在新窗口打开");
return;
}
const blob = new Blob([source], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}, [displayContent]);
const handleDownloadWriteFile = useCallback(() => {
const source = displayContent ?? "";
if (!source.trim()) {
toast.error("文件内容为空,暂时无法下载");
@@ -544,7 +584,7 @@ export function ArtifactFileDetail({
return (
<Artifact className={cn(className)}>
<ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3">
<ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<div className="bg-muted/65 inline-flex min-w-0 items-center rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8">
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
@@ -574,7 +614,7 @@ export function ArtifactFileDetail({
</ArtifactTitle>
</div>
</div>
<div className="flex w-full items-center justify-center md:w-auto md:flex-1">
<div className="flex w-full items-center justify-center md:w-auto md:flex-1">
{isSupportPreview && (
<div className="flex items-center gap-2">
<ToggleGroup
@@ -617,8 +657,8 @@ export function ArtifactFileDetail({
</div>
)}
</div>
<div className="flex w-full items-center justify-end gap-2 md:ml-auto md:w-auto">
<ArtifactActions className="bg-muted/45 max-w-full flex-wrap justify-end rounded-3xl border border-black/6 p-1 dark:border-white/8">
<div className="flex w-full items-center justify-end gap-2 md:ml-auto md:w-auto">
<ArtifactActions className="bg-muted/45 max-w-full flex-wrap justify-end rounded-3xl border border-black/6 p-1 dark:border-white/8">
{canPublishToKnowledge && (
<ArtifactAction
icon={isPublishingToKnowledge ? LoaderIcon : UploadCloudIcon}
@@ -677,6 +717,15 @@ export function ArtifactFileDetail({
onClick={handleDownloadAsWord}
/>
)}
{language === "html" && (
<ArtifactAction
icon={ExternalLinkIcon}
label="新窗口打开"
tooltip="在新窗口全屏打开 HTML"
disabled={!displayContent?.trim()}
onClick={handleOpenInNewWindow}
/>
)}
<ArtifactAction
icon={DownloadIcon}
label={t.common.download}
@@ -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>
);
}
@@ -134,10 +134,14 @@ export function InputBox({
agentSelector,
assistantId,
hideAdminRecommendedQuestions,
compact,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
status?: ChatStatus;
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
compact?: boolean;
disabled?: boolean;
context: Omit<
AgentThreadContext,
@@ -568,15 +572,23 @@ export function InputBox({
}}
/>
)}
<div className={cn(isNewThread && "gemini-gradient-border gemini-shadow")}>
<div className={cn(isNewThread && !compact && "gemini-gradient-border gemini-shadow")}>
<PromptInput
className={cn(
"transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-3xl",
// landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边
// 对话态:常规白卡 + 中性边框 + 轻阴影
isNewThread
? "rounded-3xl bg-transparent border-0 shadow-none ring-0"
: "rounded-3xl bg-background border border-border shadow-sm transition-shadow hover:shadow-md focus-within:border-ring/40 focus-within:shadow-md focus-within:ring-1 focus-within:ring-ring/20 dark:bg-slate-800 dark:border-slate-700",
"transition-all duration-300 ease-out",
// 紧凑落地态(CM 参考图):表单作为白卡,内部 input-group 透明无边框,
// 避免双层描边;圆角对齐 2xl。
compact && isNewThread
? "*:data-[slot='input-group']:rounded-2xl *:data-[slot='input-group']:border-0 *:data-[slot='input-group']:bg-transparent *:data-[slot='input-group']:shadow-none"
: "*:data-[slot='input-group']:rounded-3xl",
// 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边)
compact && isNewThread
? "rounded-2xl border border-gray-200/70 bg-white overflow-hidden shadow-[0_4.5px_22px_rgba(0,0,0,0.025)] transition-all duration-300 focus-within:border-[color-mix(in_srgb,var(--primary)_35%,transparent)] focus-within:ring-4 focus-within:ring-[color-mix(in_srgb,var(--primary)_5%,transparent)] focus-within:shadow-[0_10px_35px_color-mix(in_srgb,var(--primary)_4%,transparent)] dark:border-slate-700 dark:bg-slate-800"
: // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边
// 对话态:常规白卡 + 中性边框 + 轻阴影
isNewThread
? "rounded-3xl bg-transparent border-0 shadow-none ring-0"
: "rounded-3xl bg-background border border-border shadow-sm transition-shadow hover:shadow-md focus-within:border-ring/40 focus-within:shadow-md focus-within:ring-1 focus-within:ring-ring/20 dark:bg-slate-800 dark:border-slate-700",
className,
)}
disabled={disabled}
@@ -588,11 +600,14 @@ export function InputBox({
<PromptInputAttachments>
{(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments>
<PromptInputBody className="absolute top-0 right-0 left-0 z-3">
<PromptInputBody
className={cn(!compact && "absolute top-0 right-0 left-0 z-3")}
>
<PromptInputTextarea
className={cn(
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60",
isNewThread && "min-h-[110px]!",
compact && "min-h-[84px]! px-3 pt-3 pb-1 text-[14px]",
)}
disabled={disabled}
placeholder={t.inputBox.placeholder}
@@ -600,8 +615,15 @@ export function InputBox({
defaultValue={initialValue}
/>
</PromptInputBody>
<PromptInputFooter className="flex items-center border-t-0 px-2 pt-1 pb-1.5">
<PromptInputTools className="gap-1.5">
<PromptInputFooter
className={cn(
"flex items-center border-t-0 px-2 pt-1 pb-1.5",
compact && "gap-2 px-3 pt-1.5 pb-3",
)}
>
<PromptInputTools
className={cn("gap-1.5", compact && "flex-1 flex-wrap gap-2")}
>
{/* TODO: Add more connectors here
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className="px-2!" />
@@ -611,7 +633,13 @@ export function InputBox({
/>
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
<AddAttachmentsButton className="rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100" />
<AddAttachmentsButton
className={cn(
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact &&
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
)}
/>
<Tooltip
content={
context.writing_mode
@@ -622,9 +650,12 @@ export function InputBox({
<PromptInputButton
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
context.writing_mode
? "border border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleWritingModeToggle}
>
@@ -642,9 +673,12 @@ export function InputBox({
<PromptInputButton
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
memoryInjectionEnabled
? "border border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-950/40 dark:text-blue-200"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleMemoryInjectionToggle}
>
@@ -665,10 +699,13 @@ export function InputBox({
disabled={ragModeLocked || disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
ragModeLocked && "cursor-not-allowed opacity-80",
ragModeEnabled
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleRagModeToggle}
>
@@ -690,7 +727,20 @@ export function InputBox({
: "flash"
}
>
<PromptInputActionMenuTrigger className="gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100">
<PromptInputActionMenuTrigger
className={cn(
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
compact &&
(context.mode === "thinking"
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
: context.mode === "pro"
? "border-violet-200 bg-violet-50/60 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
: context.mode === "ultra"
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
: "border-transparent bg-muted/40 hover:bg-muted hover:text-foreground"),
)}
>
<div>
{context.mode === "flash" && <ZapIcon className="size-3" />}
{context.mode === "thinking" && (
@@ -852,7 +902,7 @@ export function InputBox({
</PromptInputActionMenu>
{supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className="gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100">
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
<div className="text-xs font-normal">
{t.inputBox.reasoningEffort}:
{context.reasoning_effort === "minimal" &&
@@ -967,12 +1017,13 @@ export function InputBox({
</PromptInputActionMenu>
)}
</PromptInputTools>
<PromptInputTools className="gap-1.5">
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
{agentSelector && (
<AgentSelector
selectedAgentId={agentSelector.selectedAgentId}
onAgentChange={agentSelector.onAgentChange}
disabled={disabled}
compact={compact}
/>
)}
{promptPrefix && (
@@ -1008,9 +1059,20 @@ export function InputBox({
onOpenChange={setModelDialogOpen}
>
<ModelSelectorTrigger asChild>
<PromptInputButton className="rounded-full border-0 bg-transparent px-3 shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100">
<PromptInputButton
className={cn(
"rounded-full border-0 bg-transparent px-3 shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact &&
"rounded-lg border border-transparent bg-gray-50/60 px-2.5! py-1! font-mono 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",
)}
>
<div className="flex min-w-0 flex-col items-start text-left">
<ModelSelectorName className="text-xs font-normal">
<ModelSelectorName
className={cn(
"text-xs font-normal",
compact && "text-[11px]",
)}
>
{selectedModel?.display_name}
</ModelSelectorName>
</div>
@@ -1071,7 +1133,11 @@ export function InputBox({
</DropdownMenu>
)}
<PromptInputSubmit
className="rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none"
className={cn(
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
compact &&
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--primary-foreground)] hover:bg-[color-mix(in_srgb,var(--primary)_88%,black)] disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--primary-foreground)] dark:hover:bg-[color-mix(in_srgb,var(--primary)_88%,black)] dark:disabled:bg-muted",
)}
disabled={disabled}
variant="outline"
status={status}
@@ -756,6 +756,9 @@ const WRITING_MODE_LABELS: Record<string, string> = {
loose: "可结合通用知识",
};
const MATERIAL_SOURCE_LABELS: Record<string, string> = {
general: "通用检索",
notebook: "我的空间",
// 旧值(历史会话回显兼容)
knowledge_base: "知识库",
skill: "技能检索",
};
@@ -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";
@@ -25,6 +26,7 @@ import { useLocalSettings } from "@/core/settings";
import {
useAppearanceSettings,
useCitationDisplaySettings,
useLayoutMode,
useUpdateAppearanceSettings,
useUpdateCitationDisplaySettings,
} from "@/core/system-settings/hooks";
@@ -42,9 +44,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";
@@ -55,17 +58,32 @@ export function AppearanceSettingsPage() {
const globalDefault = appearance?.default_theme ?? "light";
const globalSkillView = appearance?.default_skill_view ?? "list";
const globalAgentView = appearance?.default_agent_view ?? "card";
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
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 });
const label =
value === "light" ? "浅色" : value === "dark" ? "深色" : "浅蓝";
toast.success(`已将系统默认主题设置为${label}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
const handleSetDefaultLayoutMode = async (value: "full" | "compact") => {
if (value === globalLayoutMode) return;
try {
await updateAppearance.mutateAsync({ default_layout_mode: value });
toast.success(
value === "light"
? "已将系统默认主题设置为浅色"
: "已将系统默认主题设置为深色",
`已将系统默认界面模式设置为${value === "full" ? "全量模式" : "简洁模式"}`,
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
@@ -107,12 +125,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 +156,86 @@ 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={effectiveLayoutMode !== "compact" ? "default" : "outline"}
size="sm"
onClick={() => {
setLayoutMode("full");
onClose?.();
navigate("/page/workspace/chats/new");
}}
>
<PanelLeftIcon className="mr-1.5 size-4" />
全量模式
</Button>
<Button
type="button"
variant={effectiveLayoutMode === "compact" ? "default" : "outline"}
size="sm"
onClick={() => {
setLayoutMode("compact");
onClose?.();
navigate("/page/workspace/chats/new");
}}
>
<LayoutIcon className="mr-1.5 size-4" />
简洁模式
</Button>
</div>
</SettingsSection>
{isAdmin && (
<>
<Separator />
<SettingsSection
title="系统默认界面模式(管理员)"
description="给所有还没有手动切换过界面模式的用户的默认;已经自己切换过的用户保留其个人选择不受影响。"
>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={globalLayoutMode === "full" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetDefaultLayoutMode("full")}
disabled={updateAppearance.isPending}
>
<PanelLeftIcon className="mr-1.5 size-4" />
全量模式
</Button>
<Button
type="button"
variant={globalLayoutMode === "compact" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetDefaultLayoutMode("compact")}
disabled={updateAppearance.isPending}
>
<LayoutIcon className="mr-1.5 size-4" />
简洁模式
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:
{globalLayoutMode === "full" ? "全量模式" : "简洁模式"}
</p>
</div>
</SettingsSection>
</>
)}
{isAdmin && (
<>
<Separator />
@@ -165,9 +264,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 +483,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 +514,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>
+42 -4
View File
@@ -10,7 +10,7 @@ import { useTheme as useNextTheme } from 'next-themes';
import { useAppearanceSettings } from '@/core/system-settings/hooks';
// 主题类型
export type ThemeType = 'dark' | 'light';
export type ThemeType = 'dark' | 'light' | 'lightblue';
// 主题配置
export interface ThemeConfig {
@@ -82,6 +82,28 @@ const themes: Record<ThemeType, ThemeConfig> = {
overlay: 'black/30',
},
},
lightblue: {
type: 'lightblue',
name: '浅蓝主题',
colors: {
background: 'white',
backgroundSecondary: 'blue-50',
text: 'gray-800',
textSecondary: 'gray-500',
primary: 'blue-600',
primaryHover: 'blue-500',
border: 'blue-200',
accent: 'blue-700',
success: 'emerald-600',
warning: 'amber-500',
error: 'red-600',
card: 'white',
cardHover: 'blue-50',
input: 'white',
inputBorder: 'blue-200',
overlay: 'black/30',
},
},
};
const STORAGE_KEY = 'strategy-theme';
@@ -97,7 +119,9 @@ function readPersistedTheme(): ThemeType {
return 'light';
}
const saved = localStorage.getItem(STORAGE_KEY);
return saved === 'dark' ? 'dark' : 'light';
if (saved === 'dark') return 'dark';
if (saved === 'lightblue') return 'lightblue';
return 'light';
}
function hasExplicitChoice(): boolean {
@@ -151,7 +175,12 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const current = localStorage.getItem(STORAGE_KEY);
const legacy = localStorage.getItem('theme');
if (current && current !== 'light' && current !== 'dark') {
if (
current &&
current !== 'light' &&
current !== 'dark' &&
current !== 'lightblue'
) {
// Garbage value — wipe it so the boot-sync below can take over.
localStorage.removeItem(STORAGE_KEY);
}
@@ -185,7 +214,9 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const theme: ThemeType = mounted
? effectiveTheme === 'dark'
? 'dark'
: 'light'
: effectiveTheme === 'lightblue'
? 'lightblue'
: 'light'
: readPersistedTheme();
useEffect(() => {
@@ -196,11 +227,18 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
if (theme === 'dark') {
root.setAttribute('theme-mode', 'dark');
root.classList.add('dark');
root.removeAttribute('data-theme');
return;
}
root.removeAttribute('theme-mode');
root.classList.remove('dark');
if (theme === 'lightblue') {
root.setAttribute('data-theme', 'lightblue');
} else {
root.removeAttribute('data-theme');
}
}, [theme]);
const toggleTheme = () => {
+3 -2
View File
@@ -25,8 +25,9 @@ export class AgentNameCheckError extends Error {
export interface ListAgentsParams {
search?: string;
tagIds?: string[];
/** Pagination scope. Omit for the legacy combined-visibility list. */
scope?: "mine" | "square";
/** Pagination scope. Omit for the legacy combined-visibility list.
* "builtin" = 内置智能体 (user_id IS NULL), admin only — backend 403s non-admins. */
scope?: "mine" | "square" | "builtin";
/** 1-based page index. Providing this (or scope) enables server-side pagination. */
page?: number;
pageSize?: number;
+1 -1
View File
@@ -79,7 +79,7 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
/** Server-side paginated agent listing for one gallery tab (scope). Returns
* the page of agents plus the total count for the "共 N 个" header. */
export function useAgentsPaginated(params: {
scope: "mine" | "square";
scope: "mine" | "square" | "builtin";
search?: string;
tagIds?: string[];
sort?: AgentSort;
+12 -2
View File
@@ -133,8 +133,18 @@ export function useUpdateArtifactContent() {
return useMutation({
mutationFn: updateArtifactContent,
onSuccess: (content, variables) => {
queryClient.setQueryData(
["artifact", variables.filepath, variables.threadId, variables.isMock],
// useArtifactContent 的查询键带第 5 个元素 publicShareCode,这里用前缀
// 过滤(setQueriesData)匹配,避免精确键缺少 publicShareCode 写到幽灵缓存、
// 导致保存后内容被旧缓存重置回原值。
queryClient.setQueriesData(
{
queryKey: [
"artifact",
variables.filepath,
variables.threadId,
variables.isMock,
],
},
{
content,
url: undefined,
+31
View File
@@ -44,6 +44,37 @@ export function clearStoredAuth() {
window.localStorage.removeItem(AUTH_STORAGE_KEY);
}
/**
* 把登录用户名写入 localStorage.userInfo.userName(顶栏用户区据此显示)。
* 仅在尚未设置时写入,避免覆盖外部登录(yUserId)从 consumer 拿到的用户名。
*/
export function setStoredUserName(name: string | null | undefined) {
if (!isBrowser()) {
return;
}
const trimmed = name?.trim();
if (!trimmed) {
return;
}
let info: Record<string, unknown> = {};
try {
const raw = window.localStorage.getItem("userInfo");
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
info = parsed as Record<string, unknown>;
}
}
} catch {
info = {};
}
if (typeof info.userName === "string" && info.userName.trim()) {
return;
}
info.userName = trimmed;
window.localStorage.setItem("userInfo", JSON.stringify(info));
}
export function getAccessToken(): string | undefined {
const auth = getStoredAuth();
const token = auth?.access_token?.trim();
+36 -7
View File
@@ -31,15 +31,44 @@ export async function loginByYUserId(
{ yuserId: yUserId },
)) as UserDetailResponse;
const result = res.data;
if (!result?.id) {
throw new Error("无法根据 yUserId 获取用户信息。");
}
localStorage.setItem(EXTERNAL_USER_ID_STORAGE_KEY, result.id);
localStorage.setItem(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(result));
// consumer 返回 { accessToken, gateDepartName, typeName, userName }。
// 一并存入路由参数 yUserId,使「应用管理」里 source=yUserId 的路由参数能取到(与
// source=userName/gateDepartName/typeName 直接取接口值统一走 userInfo[source])。
// 注意:接口也会返回 accessToken,但 XK/PG 及 source=accessToken 要用路由透传的
// access_token,故登录流程随后会用 persistAccessToken 覆盖 userInfo.accessToken。
const result = res.data ?? {};
localStorage.setItem(
EXTERNAL_USER_INFO_STORAGE_KEY,
JSON.stringify({ ...result, yUserId }),
);
}
export function persistToolsToken(token: string): void {
localStorage.setItem(TOOLS_TOKEN_STORAGE_KEY, token);
}
/**
* 把内网登录路由参数 access_token 合并写入 userInfo.accessToken(覆盖接口返回的同名值)。
* 据此拼参的有:XK/PG 中任务 iframe(XK 用 accessToken、PG 用 access_token,二者均读
* external-links.getAccessToken() = userInfo.accessToken);以及「应用管理」里
* source=accessToken 的轻应用路由参数(resolveRouteParamValue 取 userInfo.accessToken)。
* 用合并而非覆盖整对象,避免抹掉 loginByYUserId 从 consumer 拿到的用户信息。
*/
export function persistAccessToken(token: string): void {
const trimmed = token?.trim();
if (!trimmed) return;
let info: Record<string, unknown> = {};
try {
const raw = localStorage.getItem(EXTERNAL_USER_INFO_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
info = parsed as Record<string, unknown>;
}
}
} catch {
info = {};
}
info.accessToken = trimmed;
localStorage.setItem(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(info));
}
@@ -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: "在不同语言之间切换。",
},
+102 -85
View File
@@ -1,37 +1,37 @@
/**
* ?????????????????
* 全局侧边栏菜单数据与权限过滤逻辑。
*
* ?? ``PageSidebar`` ??? ``PageSidebarDark`` ?????? ?? ?????
* adminOnly ??????????????UI ???icon ??/????/hover flyout?
* ?????????
* 浅色 ``PageSidebar`` 和深色 ``PageSidebarDark`` 共用这份数据 —— 菜单结构与
* adminOnly 权限策略对两种皮肤完全一致;UI 渲染(icon 显示/折叠样式/hover flyout)
* 在各自组件里实现。
*
* ????????????? sidebar ?????
* 修改菜单只需改这一处,两个 sidebar 自动同步。
*/
import type { ElementType } from "react";
import {
Activity,
BookOpen,
Bot,
Brain,
Briefcase,
CalendarClock,
CircleHelp,
ClipboardList,
Database,
FileBox,
FileText,
Folder,
Gauge,
Grid3X3,
HelpCircle,
History,
Layers,
Layout,
Library,
List,
ListChecks,
ListTodo,
MessageSquareCode,
Network,
NotebookPen,
Plus,
Radar,
Settings,
Sparkles,
Target,
@@ -51,80 +51,46 @@ export type MenuItem = {
export const sidebarMenuItems: MenuItem[] = [
{
id: "qa-center",
label: "????",
label: "问答中心",
icon: HelpCircle,
children: [
{ id: "general-qa", label: "????", icon: CircleHelp, path: "/page/workspace/chats/new" },
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{
id: "multi-agent-roundtable",
label: "??????",
label: "多智能体会商",
icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable",
},
// { id: "expert-qa", label: "????", icon: Brain, path: "/page/strategy/qa/expert-qa" },
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
{
id: "qa-zzjc",
label: "zzjc??",
label: "zzJC支持",
icon: Bot,
path: "/page/strategy/agents/6bf/chats/new",
},
{ id: "qa-zzxd", label: "zzXD??", icon: Layout, path: "/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new", },
{ id: "qa-research", label: "????", icon: FileText, path: "/page/canvas/ai-writing" },
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout, path: "/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new", },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
],
},
// {
// id: "light-app-center",
// label: "???",
// icon: Grid3X3,
// children: [
// {
// id: "light-app-note-management",
// label: "????",
// icon: NotebookPen,
// children: [
// {
// id: "light-app-new-note",
// label: "????",
// icon: Plus,
// path: "/page/strategy/light-app/new-note",
// },
// {
// id: "light-app-note-list",
// label: "????",
// icon: List,
// path: "/page/strategy/light-app/notes",
// },
// {
// id: "light-app-note-qa",
// label: "????",
// icon: MessageSquareCode,
// path: "/page/strategy/light-app/note-qa",
// },
// ],
// },
// { id: "light-app-square", label: "????", icon: Layout },
// { id: "light-app-records", label: "????", icon: History },
// ],
// },
{
id: "task-management",
label: "????",
label: "任务管理",
icon: Target,
children: [
{
id: "xk-center-task",
label: "XK???",
icon: Folder,
label: "XK中任务",
icon: ClipboardList,
children: [
{
id: "xk-task-jc",
label: "??JC",
label: "任务JC",
icon: ListTodo,
path: "/page/strategy/task-management/xk-task-jc",
},
{
id: "xk-task-dispatch",
label: "????",
label: "任务调度",
icon: ListTodo,
path: "/page/strategy/task-management/xk-task-dispatch",
},
@@ -132,8 +98,8 @@ export const sidebarMenuItems: MenuItem[] = [
},
{
id: "coh-center-task",
label: "COH???",
icon: Folder,
label: "COH中任务",
icon: Activity,
children: [
{
id: "coh-task-3qfx",
@@ -143,7 +109,7 @@ export const sidebarMenuItems: MenuItem[] = [
},
{
id: "coh-task-fx",
label: "??FX",
label: "任务FX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-fx",
},
@@ -157,12 +123,12 @@ export const sidebarMenuItems: MenuItem[] = [
},
{
id: "pg-center-task",
label: "PG???",
icon: Folder,
label: "PG中任务",
icon: Gauge,
children: [
{
id: "pg-task-pg",
label: "??PG",
label: "任务PG",
icon: ListTodo,
path: "/page/strategy/task-management/effect-task-pg",
},
@@ -174,7 +140,7 @@ export const sidebarMenuItems: MenuItem[] = [
},
{
id: "pg-task-info-pg",
label: "??PG",
label: "信息PG",
icon: ListTodo,
path: "/page/strategy/task-management/effect-info-pg",
},
@@ -182,24 +148,24 @@ export const sidebarMenuItems: MenuItem[] = [
},
{
id: "ty-center-task",
label: "TY???",
icon: Folder,
label: "TY中任务",
icon: Radar,
children: [
{
id: "ty-task-xdty",
label: "XDTY??",
label: "XDTY任务",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-mechanism",
},
{
id: "ty-task-plan",
label: "??TY??",
label: "方案TY任务",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-plan",
},
{
id: "ty-task-xxty",
label: "XXTY??",
label: "XXTY任务",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-zsty",
},
@@ -207,42 +173,62 @@ export const sidebarMenuItems: MenuItem[] = [
},
],
},
{
id: "light-app-center",
label: "应用中心",
icon: Grid3X3,
children: [
{
id: "light-app-management",
label: "应用管理",
icon: Layout,
path: "/page/strategy/light-app/management",
adminOnly: true,
},
{
id: "light-app-apps",
label: "应用列表",
icon: List,
path: "/page/strategy/light-app/apps",
},
],
},
{
id: "comprehensive-management",
label: "????",
label: "综合管理",
icon: Settings,
children: [
{ id: "mgmt-agents", label: "?????", icon: Bot, path: "/page/strategy/agents" },
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
{
id: "mgmt-business-chain-mapping",
label: "????",
label: "配置管理",
icon: Network,
path: "/page/strategy/business-chain-mapping",
},
{
id: "mgmt-business-chains",
label: "??????",
label: "业务链条配置",
icon: Layers,
path: "/page/strategy/business-chains",
},
{ id: "mgmt-skills", label: "????", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "????", icon: Brain, path: "/page/strategy/memory" },
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{
id: "mgmt-tasks",
label: "????",
label: "定时任务",
icon: ListChecks,
path: "/page/strategy/scheduled/tasks",
},
{
id: "mgmt-llm-metrics",
label: "???????",
label: "大模型调用统计",
icon: Gauge,
path: "/page/strategy/settings/llm-metrics",
adminOnly: true,
},
{
id: "mgmt-users",
label: "????",
label: "用户管理",
icon: Users,
path: "/page/strategy/admin/users",
adminOnly: true,
@@ -263,21 +249,21 @@ export const sidebarMenuItems: MenuItem[] = [
},
{
id: "mgmt-user-leaderboard",
label: "?????",
label: "用户排行榜",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
adminOnly: true,
},
{
id: "mgmt-admin-scheduled-tasks",
label: "??????",
label: "定时任务管理",
icon: CalendarClock,
path: "/page/strategy/admin/scheduled-tasks",
adminOnly: true,
},
{
id: "mgmt-user-activity",
label: "??????",
label: "用户活动审计",
icon: History,
path: "/page/strategy/admin/user-activity",
adminOnly: true,
@@ -286,24 +272,30 @@ export const sidebarMenuItems: MenuItem[] = [
},
{
id: "resource-management",
label: "????",
label: "资源管理",
icon: Database,
children: [
{
id: "resource-network-xwt",
label: "??xwt????",
label: "网络xwt资源管理",
icon: Network,
path: "/page/strategy/resource-management/resource-network-actor",
},
{
id: "resource-knowledge-native",
label: "????",
label: "问答梳理",
icon: BookOpen,
path: "/page/workspace/knowledge",
},
{
id: "resource-knowledge-base",
label: "知识库资源管理",
icon: Library,
path: "/page/strategy/resource-management/resource-knowledge-base",
},
{
id: "resource-evaluation-index",
label: "??????",
label: "作战资源管理",
icon: FileBox,
path: "/page/strategy/resource-management/resource-evaluation-index",
},
@@ -311,7 +303,7 @@ export const sidebarMenuItems: MenuItem[] = [
},
];
/** ??????? id ????????????????? */
/** 默认展开的菜单 id 集合(浅色版可折叠交互的初始状态) */
export const defaultExpandedIds: ReadonlySet<string> = new Set([
"qa-center",
"light-app-center",
@@ -321,7 +313,32 @@ export const defaultExpandedIds: ReadonlySet<string> = new Set([
"comprehensive-management",
]);
/** ? admin ???????? admin ??? ``adminOnly: true`` ?????? */
/** 该菜单项自身或任意后代的 path 是否等于当前路由 */
export function menuItemContainsPath(item: MenuItem, pathname: string): boolean {
if (item.path && item.path === pathname) {
return true;
}
return item.children?.some((child) => menuItemContainsPath(child, pathname)) ?? false;
}
/**
* 在「一级 → 二级 → 三级」菜单中,取当前路由所在的「激活二级项」的三级子项。
* 浅色侧边栏据此在二级右侧展开三级列;无三级则返回空数组(侧边栏保持窄栏)。
*/
export function getActiveThirdLevelItems(
items: MenuItem[],
pathname: string,
): MenuItem[] {
const activeTop =
items.find((item) => menuItemContainsPath(item, pathname)) ?? items[0];
const secondary = activeTop?.children ?? [];
const activeSecond = secondary.find(
(item) => (item.children?.length ?? 0) > 0 && menuItemContainsPath(item, pathname),
);
return activeSecond?.children ?? [];
}
/** 按 admin 角色过滤菜单:非 admin 不展示 ``adminOnly: true`` 的项及其子项 */
export function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
return items
.filter((item) => !item.adminOnly || isAdmin)
@@ -8,12 +8,15 @@
* 浅色 PageSidebar 与深色 PageSidebarDark 共用此 hook,保证两套皮肤同步。
*/
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
import { Layout } from "lucide-react";
import { getStoredAuth } from "@/core/auth";
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
import {
filterMenuItemsByRole,
getActiveThirdLevelItems,
sidebarMenuItems,
type MenuItem,
} from "./sidebar-menu";
@@ -61,3 +64,14 @@ export function useSidebarMenuItems(isAdmin: boolean): MenuItem[] {
[iframeApps, isAdmin],
);
}
/**
* 当前路由所在「激活二级项」的三级子项(浅色侧边栏右侧列用)。
* 供 PageRoutes(主内容左内边距随展开自适应)与浅色侧边栏共用同一份判定逻辑。
*/
export function useActiveThirdLevel(): MenuItem[] {
const isAdmin = getStoredAuth()?.system_role === "admin";
const items = useSidebarMenuItems(!!isAdmin);
const { pathname } = useLocation();
return useMemo(() => getActiveThirdLevelItems(items, pathname), [items, pathname]);
}
@@ -37,11 +37,17 @@ export interface PublicDraftShareSnapshot {
consensus_text: string;
/** 各席位最终交付. */
seat_deliveries: PublicDraftSeatDelivery[];
/** Self-contained Step 3 result report HTML (empty when no report exists). */
/** Self-contained Step 3 result report HTML — 经典 HTML 看板 (empty when none). */
html: string;
summary: string | null;
generated_at: string | null;
has_report: boolean;
/** 方案总结报告 (Markdown,roundtable-summary 产出). */
summary_report_md: string;
has_summary_report: boolean;
/** 大屏多页 (模式二) 结构化数据 (report-json 字符串);前端按主题渲染成自包含 HTML。 */
dashboard_json: string | null;
has_dashboard: boolean;
created_at: string | null;
}
+5
View File
@@ -62,6 +62,11 @@ export interface LocalSettings {
*/
skillViewMode?: "card" | "list";
agentViewMode?: "card" | "list";
/**
* Layout mode: "full" shows the page sidebar + header; "compact" hides them
* for a cleaner work area. Defaults to "full" (undefined treated as "full").
*/
layoutMode?: "full" | "compact";
};
context: Omit<
AgentThreadContext,
+4 -1
View File
@@ -135,19 +135,22 @@ 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 type LayoutMode = "full" | "compact";
export interface AppearanceSettings {
default_theme: ThemeMode;
default_skill_view: ViewMode;
default_agent_view: ViewMode;
default_layout_mode: LayoutMode;
}
export interface AppearanceUpdate {
default_theme?: ThemeMode;
default_skill_view?: ViewMode;
default_agent_view?: ViewMode;
default_layout_mode?: LayoutMode;
}
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
@@ -21,6 +21,7 @@ import {
type PrivateSquareUpdate,
type PromptPrefixUpdate,
type PublishSquaresUpdate,
type LayoutMode,
type ViewMode,
} from "./api";
@@ -143,6 +144,27 @@ export function useViewMode(
return [mode, setMode];
}
/**
* Effective interface layout mode (full chrome vs compact). A user's own choice
* (persisted in local settings) wins; otherwise the admin-configured system
* default applies; otherwise `undefined` (callers fall back to the session-level
* `sidebarEnabled` flag set by portal / yUserId login). `setMode` persists the
* user's override locally.
*/
export function useLayoutMode(): [
LayoutMode | undefined,
(mode: LayoutMode) => void,
] {
const [settings, setSettings] = useLocalSettings();
const { data: appearance } = useAppearanceSettings();
const userOverride = settings.display.layoutMode;
const adminDefault = appearance?.default_layout_mode;
const mode = userOverride ?? adminDefault;
const setMode = (next: LayoutMode) =>
setSettings("display", { layoutMode: next });
return [mode, setMode];
}
export function useCitationDisplaySettings() {
return useQuery({
queryKey: CITATION_DISPLAY_KEY,
-1
View File
@@ -5,7 +5,6 @@
*/
export const DESENSITIZE_MAP: Record<string, string> = {
态势: "TS",
你好: "nh",
滨海作战体系: "第一张网",
滨海作战部队: "第一张网",
'Taiwan Air Force Flight Units': "第二张网",
@@ -39,16 +39,15 @@ const INPUT_CLASS =
const DEFAULT_KEYWORD_COUNT = 1;
const DEFAULT_AUDIENCE = '普通读者';
type MaterialSource = 'knowledge_base' | 'skill' | 'notebook';
type MaterialSource = 'general' | 'notebook';
const MAX_INTENT_LENGTH = 500;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'knowledge_base', label: '知识库', desc: '默认 web/intranet 检索' },
{ value: 'skill', label: '技能检索', desc: '用已启用的技能编排检索' },
{ value: 'general', label: '通用检索', desc: '用素材收集专家配置的技能检索(默认:知识库检索)' },
{ 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';
@@ -279,14 +278,14 @@ export function AIWritingForm({
</div>
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>素材来源</label>
<label className={FIELD_LABEL_CLASS}>检索类型</label>
<Select
value={materialSource}
onValueChange={(v) => handleMaterialSourceChange(v as MaterialSource)}
disabled={disabled}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder="选择素材来源" />
<SelectValue placeholder="选择检索类型" />
</SelectTrigger>
<SelectContent>
{MATERIAL_SOURCE_OPTIONS.map((opt) => (
@@ -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} />
)}
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react';
import {
PauseCircle, CheckCircle2, RotateCcw, Search,
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, SendHorizonal,
Trash2, BookOpen, Sparkles,
Trash2, BookOpen,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -140,19 +140,13 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
async function handleReSearch() {
setSubmitting(true);
// 带输入 = 后端在素材收集专家配置的技能中匹配检索、新素材追加到列表;
// 无输入 = 重新生成检索词、覆盖式重检
await onSubmit({ action: 're_search', approvedMaterialIds: [], userQuery: userQuery.trim() || undefined });
setSubmitting(false);
}
async function handleEnableSkillSearch() {
setSubmitting(true);
await onSubmit({ action: 'enable_skill_search', userQuery: userQuery.trim() || undefined });
setSubmitting(false);
}
const hasQuery = userQuery.trim().length > 0;
// 当前素材是否来自技能检索:根据素材 sourceType 推断(覆盖式替换后全是 skill)
const isSkillResult = materials.length > 0 && materials.every((m) => m.sourceType === 'skill');
// 空数据态:未检索到任何素材,提示用户重新搜索或在无素材情况下继续
if (materials.length === 0) {
@@ -163,7 +157,7 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
<div>
<p className="text-xs font-medium">未检索到任何素材</p>
<p className="text-xs text-muted-foreground mt-0.5">
素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索、启用技能检索,或在无素材的情况下直接继续写作。
素材收集专家本次没有检索到相关数据。你可以在输入框描述想检索的内容(自动匹配已配置的技能),或在无素材的情况下直接继续写作。
</p>
</div>
</div>
@@ -189,16 +183,9 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
onClick={handleReSearch} disabled={disabled || submitting}
>
{hasQuery
? <><Search className="size-3.5" />按输入重新搜索</>
? <><Search className="size-3.5" />按输入检索</>
: <><RotateCcw className="size-3.5" />重新搜索</>}
</Button>
<Button
type="button" variant="outline" size="sm"
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
>
<Sparkles className="size-3.5" />技能检索
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={() => onSubmit({ action: 'confirm', approvedMaterialIds: [] })} disabled={disabled || submitting}
@@ -217,8 +204,8 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
<div>
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
已搜集到 {materials.length} 条{isSkillResult ? '技能素材' : '素材'}(默认全选)。
您可以取消勾选不相关的素材,或补充关键词追加搜索{isSkillResult ? '' : ',也可以启用技能检索从已启用的技能中获取素材'}。
已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,
或在下方描述想补充检索的内容——系统会匹配素材收集专家配置的技能检索,并把新素材追加进列表。
</p>
</div>
</div>
@@ -251,21 +238,12 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
>
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
</Button>
{!isSkillResult && (
<Button
type="button" variant="outline" size="sm"
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
>
<Sparkles className="size-3.5" />技能检索
</Button>
)}
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReSearch} disabled={disabled || submitting}
>
{hasQuery
? <><Search className="size-3.5" />按输入重新搜索</>
? <><Search className="size-3.5" />检索并追加素材</>
: <><RotateCcw className="size-3.5" />重新搜索</>}
</Button>
</div>
@@ -65,7 +65,7 @@ export function MaterialsToolbar({
{/* 单输入框 */}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">
补充检索(可选)— 写下你想检索的内容,然后点"重新搜索"或"技能检索"
补充检索(可选)— 描述想检索的内容,自动匹配已配置的技能,新素材追加到列表
</label>
<textarea
ref={textareaRef}
@@ -73,7 +73,7 @@ export function MaterialsToolbar({
value={userQuery}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? '输入文字后点击“重新搜索”或“技能检索”按钮…'}
placeholder={placeholder ?? '例如:检索新能源汽车销量数据。按 Enter 检索并追加…'}
disabled={disabled}
className="w-full resize-none rounded-md border border-input bg-background px-2.5 py-1.5 text-xs leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 min-h-[28px] max-h-24"
/>
@@ -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">
@@ -257,7 +257,8 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
if (!stream) return;
try {
// 所见即所得:用户在表单选了什么 materialSource,前端就原样传过去。
// 三个合法值:knowledge_base / skill / notebook,后端 state.py 接受任意 str。
// 两个合法值:general(通用检索,按素材收集专家配置的技能编排)/
// notebook(我的空间);旧值 knowledge_base / skill 后端按 general 兼容。
// 与 material_source 独立的 notebook_search_space_id / notebook_source_ids
// 控制是否真的去笔记后端拉素材(仅当用户选了"我的空间"模式时由表单填充)。
await stream.submit(
@@ -34,8 +34,8 @@ export interface WritingFormValue {
setAudience: (v: string) => void;
strictMode: boolean;
setStrictMode: (v: boolean) => void;
materialSource: 'knowledge_base' | 'skill' | 'notebook';
setMaterialSource: (v: 'knowledge_base' | 'skill' | 'notebook') => void;
materialSource: 'general' | 'notebook';
setMaterialSource: (v: 'general' | 'notebook') => void;
modelName: string;
setModelName: (v: string) => void;
/** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */
@@ -56,16 +56,16 @@ export function useWritingFormOptional(): WritingFormValue | null {
return useContext(WritingFormContext);
}
export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'knowledge_base' | 'skill' | 'notebook' }) {
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');
const [audience, setAudience] = useState('普通读者');
const [strictMode, setStrictMode] = useState(false);
const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill' | 'notebook'>(initialMaterialSource ?? 'knowledge_base');
const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general');
const [modelName, setModelName] = useState('');
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
@@ -79,8 +79,9 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
if (payload.target_audience?.trim()) setAudience(payload.target_audience.trim());
if (payload.writing_mode === 'strict') setStrictMode(true);
else if (payload.writing_mode === 'loose') setStrictMode(false);
if (payload.material_source === 'skill' || payload.material_source === 'knowledge_base') {
setMaterialSource(payload.material_source);
// 旧值 skill / knowledge_base 已合并为通用检索 general
if (payload.material_source === 'skill' || payload.material_source === 'knowledge_base' || payload.material_source === 'general') {
setMaterialSource('general');
}
if (payload.outline?.trim()) {
setOutlineText(payload.outline);
@@ -103,8 +103,12 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
case 'intervention_submitted': {
const ns = action.nextStatus;
// 点击瞬间标记技能检索 pending(动机 = 用户的 action 类型),让 SkillStepsCard 占位卡立即挂载
const enablingSkillSearch = action.record.action === 'enable_skill_search';
// 点击瞬间标记技能检索 pending,让 SkillStepsCard 占位卡立即挂载。
// 通用检索现在统一走技能编排:素材确认卡上任何回到 researching 的动作
// (重新搜索 / 按输入追加检索)都会产生 skill_search_step 流。
const enablingSkillSearch =
action.record.action === 'enable_skill_search' ||
(ns === 'researching' && action.record.pausePoint === 'material_confirm');
return {
...prev,
currentPause: null,
@@ -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>
@@ -220,7 +220,7 @@ export interface UserInterventionPayload {
approvedMaterialIds?: string[];
/** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */
extraKeywords?: string[];
/** PAUSE-1 素材确认:用户单输入框写的自然语言(技能名/来源/检索意图都行)。re_search / enable_skill_search 共用。 */
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端在素材收集专家配置的技能中匹配最相关的技能检索,新素材**追加**到列表。 */
userQuery?: string;
editedOutline?: AIOutline;
outlineFeedback?: string;
@@ -246,8 +246,8 @@ export interface AIWritingRequest {
keywordCount: number;
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
writingMode: "strict" | "loose";
/** 素材来源:knowledge_base=知识库 / skill=技能检索 / notebook=我的空间 */
materialSource: "knowledge_base" | "skill" | "notebook";
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/ notebook=我的空间 */
materialSource: "general" | "notebook";
/** 笔记来源(可选):空间 search_space_id */
noteSearchSpaceId?: number;
/** 笔记来源(可选):空间内指定文档的 document_id 列表,空数组表示检索整个空间 */
+1 -1
View File
@@ -180,7 +180,7 @@ export default function AgentChatPage() {
separate flex sibling OUTSIDE ChatBox, so the artifact preview
always opens between the chat and the detail panel — the detail
panel stays rightmost. */}
<div className="flex size-full min-h-0 overflow-hidden">
<div className="flex size-full min-h-0 overflow-hidden workspace-chat-bg">
<div className="min-h-0 min-w-0 flex-1">
<ChatBox threadId={threadId}>
{/* Main conversation column. `relative` hosts the floating
@@ -1,14 +1,23 @@
/**
* 配置管理页 —— 把固定业务码(3Q/6BF/7BF/8BF)绑定到「智能体」+「业务链」。
* 配置管理页 —— 把固定业务码绑定到「智能体」+「业务链」。
*
* 表格三列:业务(只读)/ 智能体 / 业务链。编辑模式下后两列为可搜索下拉:
* - 智能体来自 `/api/agents`
* - 业务链来自 `/api/roundtable-chains?scope=public`(只列公共链,保证全局共享时各账号都能解析)
* 智能体来自 `/api/agents`,业务链来自 `/api/roundtable-chains?scope=public`。
* 保存调 `PUT /api/business-mapping/{code}`。映射全局共享、无用户隔离。
*/
import { useEffect, useMemo, useState } from "react";
import { Loading, Select } from "tdesign-react";
import { Link2, Pencil, Save, Users, X } from "lucide-react";
import { motion } from "motion/react";
import { Select } from "tdesign-react";
import {
AlertCircle,
HelpCircle,
Info,
Link2,
Pencil,
Save,
Search,
Users,
X,
} from "lucide-react";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
@@ -21,11 +30,7 @@ import {
updateBusinessMapping,
type BusinessMapping,
} from "@/strategy-components/api/business-mapping";
interface DraftSelection {
agentId: string | null;
chainId: string | null;
}
import { cn } from "@/lib/utils";
export default function BusinessChainMappingPage() {
const { theme } = useTheme();
@@ -36,8 +41,13 @@ export default function BusinessChainMappingPage() {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [loading, setLoading] = useState(true);
const [editingCode, setEditingCode] = useState<string | null>(null);
const [draft, setDraft] = useState<DraftSelection>({ agentId: null, chainId: null });
const [draft, setDraft] = useState<{ agentId: string | null; chainId: string | null }>({
agentId: null,
chainId: null,
});
const [saving, setSaving] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "configured" | "pending">("all");
useEffect(() => {
let cancelled = false;
@@ -58,9 +68,7 @@ export default function BusinessChainMappingPage() {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
return () => { cancelled = true; };
}, []);
const agentOptions = useMemo(
@@ -72,6 +80,24 @@ export default function BusinessChainMappingPage() {
[chains],
);
const stats = useMemo(() => {
const total = rows.length;
const boundAgents = rows.filter((r) => r.agentId !== null).length;
return { total, boundAgents };
}, [rows]);
const filteredRows = useMemo(() => {
return rows.filter((r) => {
const q = searchQuery.toLowerCase();
const matchesSearch =
r.label.toLowerCase().includes(q) || r.businessCode.toLowerCase().includes(q);
const isConfigured = r.agentId !== null && r.chainId !== null;
if (statusFilter === "configured") return matchesSearch && isConfigured;
if (statusFilter === "pending") return matchesSearch && !isConfigured;
return matchesSearch;
});
}, [rows, searchQuery, statusFilter]);
const beginEdit = (row: BusinessMapping) => {
setEditingCode(row.businessCode);
setDraft({ agentId: row.agentId, chainId: row.chainId });
@@ -86,10 +112,10 @@ export default function BusinessChainMappingPage() {
setSaving(true);
try {
const agentName = draft.agentId
? agents.find((a) => a.id === draft.agentId)?.name ?? row.agentName ?? null
? (agents.find((a) => a.id === draft.agentId)?.name ?? row.agentName ?? null)
: null;
const chainTitle = draft.chainId
? chains.find((c) => c.id === draft.chainId)?.title ?? row.chainTitle ?? null
? (chains.find((c) => c.id === draft.chainId)?.title ?? row.chainTitle ?? null)
: null;
const updated = await updateBusinessMapping(row.businessCode, {
agentId: draft.agentId,
@@ -111,162 +137,416 @@ export default function BusinessChainMappingPage() {
}
};
/* ── 样式 ── */
const shellClass = isDark
? "h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100"
: "h-full overflow-y-auto bg-[linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]";
? "h-full overflow-y-auto text-slate-100 workspace-chat-bg"
: "h-full overflow-y-auto bg-white text-[#222]";
// 卡片:无边框,纯阴影
const cardClass = isDark
? "border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)] shadow-[0_16px_36px_rgba(3,10,22,0.2)]"
: "border border-[#E8E8E8] bg-white shadow-[0_14px_30px_rgba(245,34,45,0.06)]";
const headRowClass = isDark ? "text-slate-400" : "text-[#8C8C8C]";
const rowBorder = isDark ? "border-[rgba(67,89,124,0.32)]" : "border-[#F0F0F0]";
? "bg-[rgba(12,29,54,0.88)] shadow-[0_8px_32px_rgba(3,10,22,0.28)] backdrop-blur-sm"
: "bg-white shadow-[0_8px_32px_rgba(0,0,0,0.07)]";
// 行分隔:极淡,不带色
const rowDivider = isDark ? "border-white/[0.04]" : "border-black/[0.04]";
const mutedText = isDark ? "text-slate-500" : "text-[#A6A6A6]";
const codeBadge = isDark
? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]"
: "bg-[#FFF1F0] text-[#CF1322]";
const subText = isDark ? "text-slate-400" : "text-slate-500";
const filterTabs: { id: "all" | "configured" | "pending"; label: string }[] = [
{ id: "all", label: "全部业务" },
{ id: "configured", label: "已绑定" },
{ id: "pending", label: "待配置" },
];
return (
<div className={shellClass}>
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col gap-5 px-6 py-6 lg:px-8">
<div className="space-y-1">
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? "text-slate-50" : "text-[#222222]"}`}>
配置管理
</h1>
<p className={`text-sm ${isDark ? "text-slate-300/80" : "text-slate-500"}`}>
为每个业务绑定智能体与业务链。多智能体会商从对应业务自动取用此处配置的业务链。业务链仅可选择「公共」链条。
</p>
</div>
<div className={cn(shellClass, "[&_.t-input]:rounded-[5px] [&_.t-input__wrap]:rounded-[5px]")}>
<div className="mx-auto flex min-h-full max-w-7xl flex-col gap-6 px-6 py-7 lg:px-8">
<Loading loading={loading} showOverlay={false}>
<div className={`overflow-hidden rounded-3xl ${cardClass}`}>
<table className="w-full border-collapse text-sm">
<thead>
<tr className={`text-left text-xs uppercase tracking-widest ${headRowClass}`}>
<th className="px-6 py-4 font-semibold">业务</th>
<th className="px-6 py-4 font-semibold">智能体</th>
<th className="px-6 py-4 font-semibold">业务链</th>
<th className="px-6 py-4 text-right font-semibold">操作</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && !loading ? (
<tr>
<td colSpan={4} className={`px-6 py-12 text-center ${mutedText}`}>
暂无业务数据
</td>
</tr>
) : (
rows.map((row) => {
const editing = editingCode === row.businessCode;
return (
<tr key={row.businessCode} className={`border-t ${rowBorder}`}>
{/* 业务(只读) */}
<td className="px-6 py-4 align-middle">
<div className="flex items-center gap-2">
<span className={`text-[15px] font-semibold ${isDark ? "text-white" : "text-[#222222]"}`}>
{row.label}
</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-mono ${codeBadge}`}>
{row.businessCode}
</span>
</div>
</td>
{/* ── 页头 banner ── */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
className={cn("rounded-[5px] p-6 lg:p-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-6 relative overflow-hidden", cardClass)}
>
<div
className="absolute right-0 top-0 w-72 h-32 pointer-events-none"
style={{
background: isDark
? "radial-gradient(ellipse at top right, rgba(77,141,255,0.07), transparent)"
: "radial-gradient(ellipse at top right, rgba(245,34,45,0.04), transparent)",
}}
/>
{/* 智能体 */}
<td className="px-6 py-4 align-middle">
{editing ? (
<Select
className="min-w-[200px]"
value={draft.agentId ?? undefined}
options={agentOptions}
filterable
clearable
placeholder="搜索并选择智能体"
onChange={(v) => setDraft((d) => ({ ...d, agentId: (v as string) ?? null }))}
/>
) : (
<span className="inline-flex items-center gap-1.5">
<Users className={`h-3.5 w-3.5 ${mutedText}`} />
{row.agentName || row.agentId ? (
<span>{row.agentName || row.agentId}</span>
) : (
<span className={mutedText}>未配置</span>
)}
</span>
)}
</td>
{/* 业务链 */}
<td className="px-6 py-4 align-middle">
{editing ? (
<Select
className="min-w-[220px]"
value={draft.chainId ?? undefined}
options={chainOptions}
filterable
clearable
placeholder="搜索并选择业务链(公共)"
onChange={(v) => setDraft((d) => ({ ...d, chainId: (v as string) ?? null }))}
/>
) : (
<span className="inline-flex items-center gap-1.5">
<Link2 className={`h-3.5 w-3.5 ${mutedText}`} />
{row.chainTitle || row.chainId ? (
<span>{row.chainTitle || row.chainId}</span>
) : (
<span className={mutedText}>未配置</span>
)}
</span>
)}
</td>
{/* 操作 */}
<td className="px-6 py-4 text-right align-middle">
{editing ? (
<div className="inline-flex items-center gap-2">
<button
type="button"
disabled={saving}
onClick={() => handleSave(row)}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium disabled:opacity-50 ${
isDark ? "bg-[rgba(34,197,94,0.18)] text-[#86EFAC]" : "bg-[#EEFCF7] text-[#0F9D6C]"
}`}
>
<Save className="h-3.5 w-3.5" />
保存
</button>
<button
type="button"
disabled={saving}
onClick={cancelEdit}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium disabled:opacity-50 ${
isDark ? "bg-[rgba(110,136,176,0.18)] text-slate-300" : "bg-[#F5F5F5] text-[#575757]"
}`}
>
<X className="h-3.5 w-3.5" />
取消
</button>
</div>
) : (
<button
type="button"
onClick={() => beginEdit(row)}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium ${
isDark ? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]" : "bg-[#FFF1F0] text-[#CF1322]"
}`}
>
<Pencil className="h-3.5 w-3.5" />
编辑
</button>
)}
</td>
</tr>
);
})
<div className="space-y-3 relative z-10">
<div className="flex flex-wrap items-center gap-2.5">
<h2 className={cn("text-xl font-bold tracking-tight", isDark ? "text-slate-50" : "text-slate-900")}>
配置管理
</h2>
<span
className={cn(
"text-[10px] uppercase font-mono tracking-wider font-bold px-2.5 py-1 rounded-[5px] flex items-center gap-1.5",
isDark
? "bg-[rgba(77,141,255,0.12)] text-[#9CCBFF]"
: "bg-red-50/80 text-red-500",
)}
</tbody>
</table>
>
<span className={cn("w-1.5 h-1.5 rounded-full animate-pulse", isDark ? "bg-[#55A6FE]" : "bg-red-500")} />
智能会商神经中枢
</span>
<span
className={cn(
"text-[10px] font-mono tracking-wider px-2 py-0.5 rounded-[5px]",
isDark ? "text-slate-500 bg-white/5" : "text-slate-400 bg-slate-50",
)}
>
v1.2.5
</span>
</div>
<p className={cn("text-xs max-w-2xl leading-relaxed", subText)}>
业务流转与交叉验证的高级控制中心。在此注册高频业务模块并绑定专职执行智能体(Agent)及公共流转决策链(Chain),决策信息将基于该拓扑自动触发多智能体自诊断及会商组织。
</p>
<div className={cn("flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-1 text-[11px] font-medium", mutedText)}>
<span className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-green-500" />
会商引擎: 正常运行中
</span>
<span className="opacity-30">•</span>
<span>
全局配置项:{" "}
<strong className={isDark ? "text-slate-200 font-semibold" : "text-slate-700 font-semibold"}>
{stats.total} 项注册
</strong>
</span>
<span className="opacity-30">•</span>
<span>
指定专家节点:{" "}
<strong className={isDark ? "text-[#9CCBFF] font-semibold" : "text-red-500 font-semibold"}>
{stats.boundAgents} / {stats.total}
</strong>
</span>
</div>
</div>
</Loading>
</motion.div>
{/* ── 表格区 ── */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
className={cn("rounded-[5px] overflow-hidden", cardClass)}
>
{/* 搜索 + 筛选 */}
<div className={cn("px-6 py-5 border-b flex flex-col sm:flex-row sm:items-center justify-between gap-4", rowDivider)}>
<div className="relative w-full max-w-sm">
<Search className={cn("absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4", mutedText)} />
<input
type="text"
placeholder="搜索业务名称、ID..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
"w-full pl-10 pr-4 py-2.5 rounded-[5px] text-xs transition-all placeholder:text-slate-400 focus:outline-none",
isDark
? "text-slate-200 bg-white/5 focus:bg-white/8 focus:ring-1 focus:ring-[#4D8DFF]/30"
: "text-slate-700 bg-slate-50/60 hover:bg-slate-50 focus:bg-white focus:ring-1 focus:ring-red-400/30",
)}
/>
</div>
<div className={cn("flex items-center gap-1 p-1 rounded-[5px] self-end sm:self-auto shrink-0", isDark ? "bg-white/5" : "bg-slate-100/70")}>
{filterTabs.map((tab) => (
<button
key={tab.id}
onClick={() => setStatusFilter(tab.id)}
className={cn(
"px-3.5 py-1.5 rounded-[5px] text-xs font-semibold transition-all cursor-pointer",
statusFilter === tab.id
? isDark
? "bg-[rgba(77,141,255,0.18)] text-[#9CCBFF] shadow-sm"
: "bg-white text-gray-900 shadow-sm"
: isDark
? "text-slate-500 hover:text-slate-300"
: "text-gray-400 hover:text-gray-700",
)}
>
{tab.label}
</button>
))}
</div>
</div>
{/* 表格 */}
<div className="overflow-x-auto">
{loading ? (
<div className={cn("py-16 text-center text-sm", mutedText)}>加载中…</div>
) : (
<table className="w-full table-fixed text-left border-collapse">
<thead>
<tr className={cn("border-b", rowDivider, isDark ? "bg-white/[0.02]" : "bg-black/[0.02]")}>
<th className={cn("pl-8 pr-4 py-3.5 text-xs font-semibold uppercase tracking-wider w-[20%]", mutedText)}>业务</th>
<th className={cn("px-4 py-3.5 text-xs font-semibold uppercase tracking-wider w-[26%]", mutedText)}>智能体</th>
<th className={cn("px-4 py-3.5 text-xs font-semibold uppercase tracking-wider w-[26%]", mutedText)}>业务链</th>
<th className={cn("pl-4 pr-8 py-3.5 text-xs font-semibold uppercase tracking-wider w-[28%] text-right", mutedText)}>操作</th>
</tr>
</thead>
<tbody>
{filteredRows.length > 0 ? (
filteredRows.map((row) => {
const editing = editingCode === row.businessCode;
return (
<tr
key={row.businessCode}
className={cn(
"border-b last:border-0 transition-colors duration-150",
rowDivider,
!editing && (isDark ? "hover:bg-white/[0.025]" : "hover:bg-black/[0.015]"),
editing && (isDark ? "bg-[rgba(77,141,255,0.04)]" : "bg-red-50/30"),
)}
>
{/* 业务 */}
<td className="pl-8 pr-4 py-5 align-middle">
<div className="flex items-center gap-2.5">
<span className={cn("font-semibold tracking-tight text-sm", isDark ? "text-slate-100" : "text-gray-800")}>
{row.label}
</span>
<span
className={cn(
"text-[10px] font-mono font-bold px-2 py-0.5 rounded-[5px] leading-none",
isDark
? "bg-[rgba(77,141,255,0.12)] text-[#9CCBFF]"
: "bg-red-50 text-red-400",
)}
>
{row.businessCode}
</span>
</div>
</td>
{/* 智能体 */}
<td className="px-4 py-4 align-middle">
{editing ? (
<Select
className="min-w-[200px]"
value={draft.agentId ?? undefined}
options={agentOptions}
filterable
clearable
placeholder="搜索并选择智能体"
onChange={(v) =>
setDraft((d) => ({ ...d, agentId: (v as string) ?? null }))
}
/>
) : (
<div className="flex items-center gap-2.5">
<div
className={cn(
"p-1.5 rounded-[5px] transition-colors",
row.agentId
? isDark
? "bg-[rgba(77,141,255,0.1)] text-[#9CCBFF]"
: "bg-red-50 text-red-400"
: isDark
? "bg-white/5 text-slate-600"
: "bg-slate-50 text-gray-300",
)}
>
<Users className="w-3.5 h-3.5 shrink-0" />
</div>
{row.agentName || row.agentId ? (
<div className="space-y-0.5">
<span className={cn("text-xs font-semibold", isDark ? "text-slate-200" : "text-gray-800")}>
{row.agentName || row.agentId}
</span>
{row.agentId && agents.find((a) => a.id === row.agentId)?.description && (
<span className={cn("block text-[10px]", mutedText)}>
{(agents.find((a) => a.id === row.agentId)?.description ?? "").slice(0, 40)}
{(agents.find((a) => a.id === row.agentId)?.description?.length ?? 0) > 40 ? "…" : ""}
</span>
)}
</div>
) : (
<span className={cn("text-xs", mutedText)}>未配置</span>
)}
</div>
)}
</td>
{/* 业务链 */}
<td className="px-4 py-4 align-middle">
{editing ? (
<Select
className="min-w-[220px]"
value={draft.chainId ?? undefined}
options={chainOptions}
filterable
clearable
placeholder="搜索并选择业务链(公共)"
onChange={(v) =>
setDraft((d) => ({ ...d, chainId: (v as string) ?? null }))
}
/>
) : (
<div className="flex items-center gap-2.5">
<div
className={cn(
"p-1.5 rounded-[5px] transition-colors",
row.chainId
? isDark
? "bg-[rgba(34,197,94,0.08)] text-green-400"
: "bg-blue-50 text-blue-400"
: isDark
? "bg-white/5 text-slate-600"
: "bg-slate-50 text-gray-300",
)}
>
<Link2 className="w-3.5 h-3.5 shrink-0" />
</div>
{row.chainTitle || row.chainId ? (
<div className="space-y-0.5">
<span className={cn("text-xs font-semibold", isDark ? "text-slate-200" : "text-gray-800")}>
{row.chainTitle || row.chainId}
</span>
{row.chainId && (
<span className={cn("flex items-center gap-1 text-[10px] leading-none", mutedText)}>
<span
className={cn(
"px-1 rounded-[5px] text-[9px] font-medium",
isDark
? "bg-[rgba(34,197,94,0.08)] text-green-400"
: "bg-green-50 text-green-600",
)}
>
公共
</span>
节点数: {chains.find((c) => c.id === row.chainId)?.seats?.length ?? "—"}
</span>
)}
</div>
) : (
<span className={cn("text-xs", mutedText)}>未配置</span>
)}
</div>
)}
</td>
{/* 操作 */}
<td className="pl-4 pr-8 py-4 text-right align-middle whitespace-nowrap">
{editing ? (
<div className="inline-flex items-center gap-2 justify-end">
<button
type="button"
disabled={saving}
onClick={() => handleSave(row)}
className={cn(
"inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-all active:scale-95 disabled:opacity-50",
isDark
? "bg-[rgba(34,197,94,0.12)] text-green-400 hover:bg-[rgba(34,197,94,0.2)]"
: "bg-green-50 text-green-600 hover:bg-green-100",
)}
>
<Save className="h-3.5 w-3.5" />
保存
</button>
<button
type="button"
disabled={saving}
onClick={cancelEdit}
className={cn(
"inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-all active:scale-95 disabled:opacity-50",
isDark
? "bg-white/5 text-slate-400 hover:bg-white/10 hover:text-slate-200"
: "bg-slate-50 text-slate-500 hover:bg-slate-100",
)}
>
<X className="h-3.5 w-3.5" />
取消
</button>
</div>
) : (
<button
type="button"
onClick={() => beginEdit(row)}
title="编辑配置"
className={cn(
"inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-all duration-200 active:scale-95",
isDark
? "text-[#9CCBFF] bg-[rgba(77,141,255,0.12)] hover:bg-[rgba(77,141,255,0.18)]"
: "text-red-500 bg-red-50 hover:bg-red-100",
)}
>
<Pencil className="h-3.5 w-3.5" />
<span>编辑</span>
</button>
)}
</td>
</tr>
);
})
) : (
<tr>
<td colSpan={4} className="py-14 text-center">
<div className="flex flex-col items-center justify-center gap-2">
<AlertCircle className={cn("w-7 h-7", isDark ? "text-slate-700" : "text-gray-200")} />
<p className={cn("text-xs font-medium", mutedText)}>未找到符合筛选条件的业务配置</p>
<button
onClick={() => { setSearchQuery(""); setStatusFilter("all"); }}
className={cn("text-[11px] font-semibold mt-1 hover:underline", isDark ? "text-[#9CCBFF]" : "text-red-500")}
>
重置筛选条件
</button>
</div>
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
{/* 表格尾栏 */}
<div className={cn("px-6 py-4 border-t flex items-center justify-between text-xs font-medium", rowDivider, isDark ? "bg-white/[0.02]" : "bg-black/[0.015]", mutedText)}>
<span className="flex items-center gap-1.5">
<Info className="w-3.5 h-3.5" />
会商程序会在对应业务的业务链生效时开启多体交叉核验证。
</span>
<span className="font-mono text-[10px]">
显示 {filteredRows.length} / {rows.length} 条
</span>
</div>
</motion.div>
{/* ── 使用说明 ── */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
className={cn(
"rounded-[5px] p-5 flex gap-4 text-xs leading-relaxed",
isDark
? "bg-[rgba(245,158,11,0.05)] text-amber-300/70"
: "bg-amber-50/50 text-amber-800",
)}
>
<div className={cn("w-8 h-8 rounded-[5px] shrink-0 flex items-center justify-center", isDark ? "bg-[rgba(245,158,11,0.08)] text-amber-400" : "bg-amber-100/60 text-amber-500")}>
<HelpCircle className="w-4 h-4" />
</div>
<div>
<h4 className={cn("font-bold mb-0.5", isDark ? "text-amber-300" : "text-amber-900")}>如何配置业务流转?</h4>
<p className={isDark ? "text-amber-400/60" : "text-amber-700/80"}>
1. 将鼠标悬停在任一业务行右侧,点击出现的{" "}
<span className={cn("font-semibold", isDark ? "text-[#9CCBFF]" : "text-red-500")}>「编辑」</span>。
<br />
2. 在该行内直接搜索并选择 <strong>执行智能体</strong> 与 <strong>业务链</strong>,点击「保存」即时生效。
<br />
3. 若无需绑定,可搜索框清空后保存,置为「未配置」状态。
</p>
</div>
</motion.div>
</div>
</div>
);
+76 -11
View File
@@ -1,6 +1,6 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@@ -28,6 +28,12 @@ import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { Welcome } from "@/components/workspace/welcome";
import { CompactWelcome } from "@/components/workspace/compact-welcome";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
import { useSidebar } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { PanelLeftOpen } from "lucide-react";
import { useAgentSelector } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
@@ -39,6 +45,7 @@ import {
useThreadSettings,
} from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
@@ -85,7 +92,12 @@ 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();
// 有效界面模式:用户自选 > 管理员系统默认 > (下方再回退会话级 sidebarEnabled)
const [effectiveLayoutMode] = useLayoutMode();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
@@ -402,11 +414,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 = effectiveLayoutMode;
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 +452,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 +500,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 +535,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={
+10 -2
View File
@@ -9,7 +9,7 @@ import { useParams } from "react-router-dom";
import { useTheme } from "@/contexts/ThemeContext";
import { useLightApp } from "@/strategy-components/api/light-apps";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
import { buildLightAppUrl, type AppThemeColor } from "@/strategy-components/lib/light-app";
export default function LightAppIframeView() {
const { appId } = useParams<{ appId: string }>();
@@ -17,7 +17,15 @@ export default function LightAppIframeView() {
const isDark = theme === "dark";
const { data: app, isLoading, error } = useLightApp(appId);
const src = useMemo(() => (app ? buildLightAppUrl(app) : ""), [app]);
// 由 useTheme 的 theme 直接换算主题色并显式传入,切主题时 src 立即重建、
// 把新主题色传给被嵌套应用(不依赖 <html> 的 dark class,避免时序读旧值)。
// 深色=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],
);
const centerClass = `flex h-full w-full items-center justify-center text-sm ${
isDark ? "text-slate-400" : "text-slate-500"
+261 -51
View File
@@ -1,27 +1,76 @@
/**
* 应用列表页 —— 以卡片网格展示「新页跳转(window.open)」类型的轻应用。
*
* 支持搜索 + 分页。点击卡片用 buildLightAppUrl 构造最终地址(追加解析后的路由参数)
* 并 window.open 新开页跳转。仅展示已启用的应用;iframe 类型不在此处(它们注入侧边栏)。
* 只读启动器:搜索 + 分页;每张卡片预览「拼接后最终路由」(buildLightAppUrl 追加解析后的
* 登录态 / 主题路由参数),并提供「一键复制URL」「进入应用」(window.open 新开页) 两个动作。
* 增删改不在本页,走独立的「应用管理」页(admin);iframe 类型也不在此(它们注入侧边栏)。
*
* 配色(与「业务链条配置」页统一):
* - 浅色:品牌红 `#CF1322`(hover `#A8071A`)。
* - 深蓝:按《深蓝色 UI 规范》——页面 `#0C1836`、卡片 `#122347`、边框 `#1A2F5A`、
* 正文 `#C5CFE0`、次级 `#6E88B0`;主操作改用规范主色蓝 `#1677FF`(hover `#0958D9`)。
*
* 后端 LightApp 无「分类」字段,故不做分类筛选;卡片图标 / 主题色按 appId 哈希派生
* (`icon` 字段当前未用),仅作视觉区分,不承载语义。
*/
import { useEffect, useMemo, useState } from "react";
import { ExternalLink, Search } from "lucide-react";
import {
ArrowUpRight,
Bot,
Check,
Code,
Copy,
FileText,
Globe,
Layers,
Notebook,
Search,
SlidersHorizontal,
Terminal,
X,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
import { cn } from "@/lib/utils";
import { useTheme } from "@/contexts/ThemeContext";
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
import "./light-app-pages.css";
const PAGE_SIZE = 12;
export default function LightAppListPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
/** 卡片图标池(按 appId 哈希取一个,仅作视觉区分)。 */
const ICON_POOL: LucideIcon[] = [Bot, Notebook, Terminal, SlidersHorizontal, Globe, Code, Layers, FileText];
/** 卡片主题色池:浅色用对应色阶,深蓝统一落到 `#1A2F5A` 容器底 + 规范强调色。 */
const THEME_POOL = [
{ iconBg: "bg-rose-100 text-rose-600 dark:bg-[#1A2F5A] dark:text-[#FF7875]" },
{ iconBg: "bg-indigo-100 text-indigo-600 dark:bg-[#1A2F5A] dark:text-[#6388FF]" },
{ iconBg: "bg-emerald-100 text-emerald-600 dark:bg-[#1A2F5A] dark:text-[#52C41A]" },
{ iconBg: "bg-amber-100 text-amber-600 dark:bg-[#1A2F5A] dark:text-[#FAAD14]" },
{ iconBg: "bg-blue-100 text-blue-600 dark:bg-[#1A2F5A] dark:text-[#4096FF]" },
{ iconBg: "bg-purple-100 text-purple-600 dark:bg-[#1A2F5A] dark:text-[#9C7BFF]" },
] as const;
/** 稳定字符串哈希 → [0, mod) 下标。 */
function hashIndex(str: string, mod: number): number {
let h = 0;
for (let i = 0; i < str.length; i += 1) {
h = (h * 31 + str.charCodeAt(i)) >>> 0;
}
return mod > 0 ? h % mod : 0;
}
export default function LightAppListPage() {
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [copiedId, setCopiedId] = useState<string | null>(null);
const { theme } = useTheme();
const isLightBlue = theme === 'lightblue';
const { data: apps = [], isLoading } = useLightApps({
appType: "window_open",
enabledOnly: true,
@@ -47,67 +96,228 @@ export default function LightAppListPage() {
window.open(url, "_blank", "noopener,noreferrer");
};
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]";
const cardClass = isDark
? "group cursor-pointer rounded-2xl border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)] p-4 transition-all hover:-translate-y-0.5 hover:border-[rgba(77,141,255,0.6)] hover:shadow-[0_18px_38px_rgba(3,10,22,0.35)]"
: "group cursor-pointer rounded-2xl border border-[#E8E8E8] bg-white p-4 transition-all hover:-translate-y-0.5 hover:border-[#FFA39E] hover:shadow-[0_16px_32px_rgba(245,34,45,0.1)]";
const mutedText = isDark ? "text-slate-500" : "text-[#A6A6A6]";
const iconWrap = isDark
? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]"
: "bg-[#FFF1F0] text-[#CF1322]";
const copyUrl = async (app: LightApp) => {
const url = buildLightAppUrl(app);
if (!url) {
toast.error("该应用地址为空,无法复制");
return;
}
try {
await navigator.clipboard.writeText(url);
setCopiedId(app.appId);
setTimeout(() => setCopiedId((id) => (id === app.appId ? null : id)), 2000);
} catch {
toast.error("复制失败,请手动选择地址");
}
};
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="light-app-list-page h-full overflow-y-auto bg-zinc-50/60 font-sans text-zinc-900 antialiased dark:bg-[#0C1836] dark:text-[#C5CFE0]">
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col gap-6 px-4 py-7 sm:px-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"}`}>
点击卡片在新标签页打开应用。地址会自动附带配置的路由参数(登录态 / 主题)。
<div className="flex items-center gap-2.5">
<span className="relative flex size-3">
<span className={cn("absolute inline-flex size-full animate-ping rounded-full opacity-75", isLightBlue ? "bg-[#0062D9]" : "bg-[#CF1322]", "dark:bg-[#1677FF]")} />
<span className={cn("relative inline-flex size-3 rounded-full", isLightBlue ? "bg-[#0062D9]" : "bg-[#CF1322]", "dark:bg-[#1677FF]")} />
</span>
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
应用列表
</h1>
<span className={cn("rounded-[5px] border px-2 py-0.5 font-mono text-xs font-medium dark:border-[#1677FF]/30 dark:bg-[#1677FF]/15 dark:text-[#4096FF]", isLightBlue ? "border-[#0062D9]/15 bg-[#EAF4FF] text-[#0062D9]" : "border-[#CF1322]/15 bg-[#FFF1F0] text-[#CF1322]")}>
导航工作台
</span>
</div>
<p className="max-w-2xl text-sm leading-relaxed text-zinc-500 dark:text-[#6E88B0]">
点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。
</p>
</div>
<div className="relative max-w-sm">
<Search className={`pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 ${mutedText}`} />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索应用名称 / 描述"
className="pl-9"
/>
{/* 搜索 */}
<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
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="键入应用名称、目标地址、或描述,实时过滤检索…"
className={cn("w-full rounded-[5px] border border-zinc-200 bg-zinc-50/50 py-3 pl-10 pr-10 text-sm transition focus:bg-white focus:outline-none focus:ring-2 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", isLightBlue ? "focus:border-[#0062D9]/60 focus:ring-[#0062D9]/20" : "focus:border-[#CF1322]/60 focus:ring-[#CF1322]/20")}
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title="清除搜索"
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-400 transition hover:text-zinc-600 dark:hover:text-[#C5CFE0]"
>
<X className="size-4" />
</button>
)}
</div>
</div>
{/* 结果计数 */}
<div className="-mt-2 flex items-center justify-between">
<div className="text-xs font-medium text-zinc-500 dark:text-[#6E88B0]">
检索到最终结果:{" "}
<span className={cn("font-bold dark:text-[#4096FF]", isLightBlue ? "text-[#0062D9]" : "text-[#CF1322]")}>{apps.length}</span> 个关联应用
</div>
{search && (
<button
type="button"
onClick={() => setSearch("")}
className={cn("text-xs font-medium underline dark:text-[#4096FF] dark:hover:text-[#1677FF]", isLightBlue ? "text-[#0062D9] hover:text-[#0056C6]" : "text-[#CF1322] hover:text-[#A8071A]")}
>
重置过滤条件
</button>
)}
</div>
{/* 卡片网格 / 空态 */}
{apps.length === 0 ? (
<div className={`rounded-3xl border py-20 text-center ${isDark ? "border-[rgba(67,89,124,0.4)] text-slate-500" : "border-[#F0F0F0] text-[#A6A6A6]"}`}>
{isLoading ? "加载中…" : "暂无可用应用"}
<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>
<h4 className="mb-1 text-base font-medium text-zinc-800 dark:text-[#C5CFE0]">
{isLoading ? "正在加载应用…" : search ? "未找到匹配的应用" : "暂无可用应用"}
</h4>
<p className="mx-auto max-w-md text-sm text-zinc-400 dark:text-[#6E88B0]">
{isLoading
? "请稍候。"
: search
? "尝试更换关键词,或清除搜索查看全部应用。"
: "应用由管理员在「应用管理」中登记后会出现在这里。"}
</p>
</div>
) : (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{pageApps.map((app) => (
<button key={app.appId} type="button" onClick={() => openApp(app)} className={`${cardClass} text-left`}>
<div className="flex items-start gap-3">
<span className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl ${iconWrap}`}>
<ExternalLink className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<div className={`truncate text-[15px] font-semibold ${isDark ? "text-white" : "text-[#222222]"}`}>
{app.name}
</div>
<div className={`mt-1 line-clamp-2 text-xs leading-relaxed ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{app.description || app.url}
</div>
</div>
</div>
</button>
<AppCard
key={app.appId}
app={app}
copied={copiedId === app.appId}
onCopy={() => void copyUrl(app)}
onOpen={() => openApp(app)}
/>
))}
</div>
<Pagination page={page} pageSize={PAGE_SIZE} total={apps.length} onPageChange={setPage} className="pt-2" />
{apps.length > PAGE_SIZE && (
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={apps.length}
onPageChange={setPage}
className="pt-2 [&_button]:rounded-[5px]"
/>
)}
</>
)}
{/* 页脚 */}
<footer className="mt-auto pt-8 text-center text-xs text-zinc-400 dark:text-[#4A6491]">
应用导航工作台 • 优雅可靠的任务中转入口
</footer>
</div>
</div>
);
}
// ── 单张应用卡 ───────────────────────────────────────────────────────────────
function AppCard({
app,
copied,
onCopy,
onOpen,
}: {
app: LightApp;
copied: boolean;
onCopy: () => void;
onOpen: () => void;
}) {
const { theme } = useTheme();
const isLightBlue = theme === 'lightblue';
const Icon = ICON_POOL[hashIndex(app.appId, ICON_POOL.length)];
const cardTheme = THEME_POOL[hashIndex(app.appId, THEME_POOL.length)];
const parsedUrl = buildLightAppUrl(app);
const hasParams = (app.routeParams ?? []).some((p) => p.key) && parsedUrl !== app.url;
return (
<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-[5px] p-3 shadow-sm", cardTheme.iconBg)}>
<Icon className="size-5.5" />
</div>
</div>
<h4 className={cn("mb-2 line-clamp-1 text-base font-bold leading-snug text-zinc-900 transition-colors dark:text-[#E8EDF5] dark:group-hover:text-[#4096FF]", isLightBlue ? "group-hover:text-[#0062D9]" : "group-hover:text-[#CF1322]")}>
{app.name}
</h4>
<p className="mb-4 line-clamp-2 min-h-[2.5rem] text-xs leading-relaxed text-zinc-500 dark:text-[#6E88B0]">
{app.description || "暂无应用详细描述说明,点击进入下方链接查看详情。"}
</p>
</div>
{/* 下:URL 预览 + 动作 */}
<div className="mt-auto border-t border-zinc-100 pt-4 dark:border-[#1A2F5A]">
<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={cn("text-[9px] font-bold uppercase tracking-wider dark:text-[#4096FF]", isLightBlue ? "text-[#0062D9]" : "text-[#CF1322]")}>
自动拼接已激活参
</span>
</div>
<div className="line-clamp-1 select-all break-all font-mono text-[11px] font-medium text-zinc-600 dark:text-[#C5CFE0]">
{parsedUrl || "(地址为空)"}
</div>
{hasParams && (
<div className="mt-1 flex items-center gap-1 text-[9px] text-zinc-400 dark:text-[#6E88B0]">
<span className="inline-block size-1 rounded-full bg-amber-400 dark:bg-[#FAAD14]" />
<span>包含动态拼接参数</span>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={onCopy}
className={cn(
"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]",
)}
>
{copied ? (
<>
<Check className="size-3.5" />
<span>已复制!</span>
</>
) : (
<>
<Copy className="size-3.5" />
<span>一键复制URL</span>
</>
)}
</button>
<button
type="button"
onClick={onOpen}
className={cn("flex items-center justify-center gap-1.5 rounded-[5px] px-3 py-2 text-xs font-semibold text-white transition active:scale-[0.98] dark:bg-[#1677FF] dark:shadow-none dark:hover:bg-[#0958D9]", isLightBlue ? "bg-[#0062D9] shadow-[0_1px_3px_rgba(0,98,217,0.2)] hover:bg-[#0079FE]" : "bg-[#CF1322] shadow-[0_1px_3px_rgba(207,19,34,0.2)] hover:bg-[#A8071A]")}
>
<span>进入应用</span>
<ArrowUpRight className="size-3.5" />
</button>
</div>
</div>
</div>
);
File diff suppressed because it is too large. Load diff
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
export default function LightAppNoteQAPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const isLightBlue = theme === 'lightblue';
const [question, setQuestion] = useState("");
const handleSubmit = () => {
@@ -111,7 +112,9 @@ export default function LightAppNoteQAPage() {
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors",
isDark
? "bg-sky-500 text-white hover:bg-sky-400"
: "bg-[#F5222D] text-white hover:bg-[#CF1322]",
: isLightBlue
? "bg-[#0062D9] text-white hover:bg-[#0079FE]"
: "bg-[#F5222D] text-white hover:bg-[#CF1322]",
)}
aria-label="发送"
>
+12 -1
View File
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useTheme } from '@/contexts/ThemeContext';
import { loginByToken, loginByUsername } from '@/core/auth/api';
import { setStoredAuth } from '@/core/auth';
import { setStoredAuth, setStoredUserName } from '@/core/auth';
import {
persistExternalLoginParams,
readExternalLoginParams,
@@ -16,6 +16,7 @@ import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
import {
GENERAL_QA_ROUTE,
loginByYUserId,
persistAccessToken,
persistToolsToken,
} from '@/core/auth/yuser-login';
import {
@@ -53,6 +54,8 @@ export default function LoginPage() {
// 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
const toolToken = searchParams.get('token')?.trim() ?? '';
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
const accessToken = searchParams.get('access_token')?.trim() ?? '';
// 用户名直登口令门:开启后端开关时,口令从 ?password= 透传
const gatePassword = searchParams.get('password') ?? '';
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
@@ -114,6 +117,7 @@ export default function LoginPage() {
setErrorMessage('');
try {
let auth;
let loginUserName = '';
if (authToken) {
// token 换登录:后端用 authToken 调上游接口换出用户名再登录
auth = await loginByToken(authToken);
@@ -128,11 +132,17 @@ export default function LoginPage() {
}
auth = await loginByUsername(deerflowUsername, gatePassword);
loginUserName = deerflowUsername;
}
if (cancelled) {
return;
}
setStoredAuth(auth);
// 标准登录把用户名写入 userInfo,供顶栏用户区显示(已有则不覆盖)
setStoredUserName(loginUserName);
// 内网透传的 access_token 合并进 userInfo(在 loginByYUserId 覆盖之后),
// XK/PG 中任务 iframe 据此取 accessToken
persistAccessToken(accessToken);
persistSidebarEnabled(sidebarEnabled);
setStatus('success');
router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath);
@@ -157,6 +167,7 @@ export default function LoginPage() {
sidebarEnabled,
yUserId,
toolToken,
accessToken,
onlineTicket,
authToken,
gatePassword,
+21 -3
View File
@@ -5,8 +5,11 @@ import { PageSidebar } from "@/components/page-sidebar";
import { PageSidebarDark } from "@/components/page-sidebar-dark";
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { isWujieEnv } from "@/core/embed/wujie";
import { useTheme } from "@/contexts/ThemeContext";
import { useLocation } from "react-router-dom";
import { useActiveThirdLevel } from "@/core/page-layout/use-sidebar-menu";
import { useLayoutMode } from "@/core/system-settings/hooks";
import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes";
@@ -21,8 +24,20 @@ function PageLayout() {
const location = useLocation();
const { theme } = useTheme();
const isDark = theme === "dark";
// 浅色非嵌入:侧边栏为新版「二级导航图标卡片」窄栏(96px);wujie 嵌入仍走旧版手风琴(64/256px)。
const inWujie = useMemo(() => isWujieEnv(), []);
const lightNarrowSidebar = !isDark && !inWujie;
// 浅色窄栏在右侧展开「三级列」时整体变宽(96→236px),主内容左内边距随之过渡。
const thirdLevel = useActiveThirdLevel();
const lightExpanded = lightNarrowSidebar && thirdLevel.length > 0;
// 界面模式(外观设置):用户显式选「全量/简洁」时以其为准;否则跟随管理员系统默认
// (default_layout_mode);两者都没有才回退到会话级 sidebarEnabled(外部门户/yUserId
// 登录时置位)这一旧默认。这样无论是否走门户登录,「全量↔简洁」都能真正控制顶栏+侧边栏。
const [layoutMode] = useLayoutMode();
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 等价;无界等嵌套
@@ -53,8 +68,11 @@ function PageLayout() {
// 只剩左下角浮动按钮(fixed,叠在 main 上不影响布局)。
// 不加 transition:transition + sidebar 立刻 unmount 不同步会抽搐
? (collapsed ? "pl-0" : "pl-[118px]")
// 浅色仍保留过渡动画(pl-16 ↔ pl-64 平滑收展)
: `transition-[padding] duration-300 ${collapsed ? "pl-16" : "pl-64"}`
: lightNarrowSidebar
// 浅色新版窄栏:展开三级列时 96→236px,主内容左内边距随之过渡
? `transition-[padding] duration-300 ${lightExpanded ? "pl-[236px]" : "pl-[96px]"}`
// 浅色旧版手风琴(wujie 嵌入):pl-16 ↔ pl-64 平滑收展
: `transition-[padding] duration-300 ${collapsed ? "pl-16" : "pl-64"}`
}`}
>
<Routes>
@@ -8,9 +8,11 @@ import {
ChevronRight,
ExternalLink,
FileText,
LayoutDashboard,
Loader2,
ScrollText,
} from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import { useParams } from "react-router-dom";
import remarkGfm from "remark-gfm";
@@ -21,6 +23,12 @@ import {
} from "@/core/roundtable-draft-shares/api";
import { usePublicBackground } from "@/core/embed/background";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
import { adaptReportData } from "@/roundtable-planning/dashboard/data-context";
import { parseReportJson } from "@/roundtable-planning/lib/report-template";
/** 该分享页支持的三种视图:落地汇总 / 结果绘制(经典 HTML) / 大屏多页(模式二)。 */
export type RoundtableShareView = "landing" | "report" | "dashboard";
/** 各席位最终交付 —— 折叠手风琴(仿 HighFidelityReport 第二区块)。 */
function SeatDeliveries({ seats }: { seats: PublicDraftSeatDelivery[] }) {
@@ -81,20 +89,22 @@ function SeatDeliveries({ seats }: { seats: PublicDraftSeatDelivery[] }) {
* Public, read-only roundtable-result viewer.
*
* Reachable at `#/roundtable-share/<code>` with NO authentication — anyone with
* the link can open it. Two views, driven by the `report` prop / route:
* the link can open it. Three views, driven by the `view` prop / route:
*
* - landing (`#/roundtable-share/<code>`): a read-only result page mirroring the
* in-app Step 3「最终方案展示」— 总控最终结论 + 各席位最终交付 + a 「查看结果绘制」
* button that opens the full report in a NEW TAB (when one was generated).
* - report (`#/roundtable-share/<code>/report`): the Step 3 report (a
* in-app Step 3「最终方案展示」— 结果绘制/大屏多页入口 + 方案总结报告 + 总控最终
* 结论 + 各席位最终交付.
* - report (`#/roundtable-share/<code>/report`): the Step 3 经典 HTML 看板 (a
* self-contained HTML document) rendered full-screen inside a sandboxed
* `iframe` (no `allow-scripts`), so the agent-generated HTML can never execute
* code in the app origin.
* `iframe` (no `allow-scripts`).
* - dashboard (`#/roundtable-share/<code>/dashboard`): the Step 3 大屏多页 (模式二)
* — rendered on the fly from the stored structured data via `renderDashboardHtml`
* into the same sandboxed `iframe`.
*/
export default function PublicRoundtableSharePage({
report = false,
view = "landing",
}: {
report?: boolean;
view?: RoundtableShareView;
}) {
const { shareCode } = useParams<{ shareCode: string }>();
// 嵌套方可用 ?bg=/?text= 或 postMessage 覆盖背景/文字色。
@@ -108,6 +118,17 @@ export default function PublicRoundtableSharePage({
refetchOnWindowFocus: false,
});
// 大屏多页:用与应用内一致的 React 大屏(DashboardApp)渲染,而非自包含 HTML 模板。
// 把存储的结构化数据(report-json)解析并 adapt 成 DashboardApp 的入参;与
// Step3DashboardMode 的「应用渲染」、DashboardStandalonePage 的「新窗口打开」同源。
const dashboardData = useMemo(() => {
const json = data?.dashboard_json;
if (!json) return undefined;
const parsed = parseReportJson(json);
if (!parsed) return undefined;
return adaptReportData(parsed, data?.generated_at ?? "");
}, [data?.dashboard_json, data?.generated_at]);
if (isLoading) {
return (
<div className="bg-background text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm" style={pageBg}>
@@ -128,8 +149,24 @@ export default function PublicRoundtableSharePage({
);
}
// ── 报告页:整页沙箱 iframe 展示第 3 步结果绘制 ──────────────────────────────
if (report) {
// ── 大屏多页:整屏渲染应用内 React 大屏(DashboardApp),与应用渲染/新窗口打开一致 ──
if (view === "dashboard") {
return (
<div className="bg-background h-screen w-screen overflow-auto" style={pageBg}>
{dashboardData ? (
<DashboardApp data={dashboardData} />
) : (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-sm">
<p className="text-base font-medium text-foreground">暂无大屏多页</p>
<p>这次会商还没有生成第三步「大屏多页」的方案报告。</p>
</div>
)}
</div>
);
}
// ── 结果绘制(经典 HTML 看板):整页沙箱 iframe 展示自包含报告 ─────────────────
if (view === "report") {
return (
<div className="bg-background flex h-screen flex-col" style={pageBg}>
<header className="flex shrink-0 items-center gap-2 border-b px-6 py-3">
@@ -161,8 +198,10 @@ export default function PublicRoundtableSharePage({
);
}
// ── 落地页:总控最终结论 + 各席位最终交付 + 查看结果绘制(新标签打开报告页)─────
const reportUrl = `${window.location.origin}${window.location.pathname}#/roundtable-share/${shareCode}/report`;
// ── 落地页:结果绘制/大屏多页入口 + 方案总结报告 + 总控最终结论 + 各席位最终交付 ──
const shareBase = `${window.location.origin}${window.location.pathname}#/roundtable-share/${shareCode}`;
const reportUrl = `${shareBase}/report`;
const dashboardUrl = `${shareBase}/dashboard`;
return (
<div className="bg-[#fef6f6] flex h-screen flex-col text-slate-800" style={pageBg}>
@@ -182,23 +221,39 @@ export default function PublicRoundtableSharePage({
<main className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-6xl space-y-6 p-6">
{/* 1. 结果绘制 —— 新标签打开报告页 */}
{/* 1. 结果绘制 / 大屏多页 —— 新标签打开整页报告 */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-primary" />
结果绘制
</h4>
{data.has_report ? (
<a
href={reportUrl}
target="_blank"
rel="noopener noreferrer"
className="bg-primary text-white hover:bg-[var(--brand-900)] inline-flex items-center gap-2 rounded-xl px-5 py-2.5 text-sm font-semibold transition shadow-sm shadow-primary/10"
>
<BarChart3 className="size-4" />
查看结果绘制
<ExternalLink className="size-3.5 opacity-80" />
</a>
{data.has_report || data.has_dashboard ? (
<div className="flex flex-wrap gap-2.5">
{data.has_report && (
<a
href={reportUrl}
target="_blank"
rel="noopener noreferrer"
className="bg-primary text-white hover:bg-[var(--brand-900)] inline-flex items-center gap-2 rounded-xl px-5 py-2.5 text-sm font-semibold transition shadow-sm shadow-primary/10"
>
<BarChart3 className="size-4" />
查看结果绘制
<ExternalLink className="size-3.5 opacity-80" />
</a>
)}
{data.has_dashboard && (
<a
href={dashboardUrl}
target="_blank"
rel="noopener noreferrer"
className="bg-white text-primary border border-primary/30 hover:bg-primary/5 inline-flex items-center gap-2 rounded-xl px-5 py-2.5 text-sm font-semibold transition shadow-sm"
>
<LayoutDashboard className="size-4" />
查看大屏多页
<ExternalLink className="size-3.5 opacity-80" />
</a>
)}
</div>
) : (
<p className="text-[11px] text-slate-400 italic">
这次会商还没有生成第三步「结果绘制」的方案报告。
@@ -206,7 +261,22 @@ export default function PublicRoundtableSharePage({
)}
</section>
{/* 2. 总控最终结论 */}
{/* 2. 方案总结报告(Markdown) */}
{data.has_summary_report && (
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<ScrollText className="w-4 h-4 text-primary" />
方案总结报告
</h4>
<div className="text-xs text-slate-700 leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{data.summary_report_md}
</ReactMarkdown>
</div>
</section>
)}
{/* 3. 总控最终结论 */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-500 shrink-0" />
@@ -223,7 +293,7 @@ export default function PublicRoundtableSharePage({
)}
</section>
{/* 3. 各席位最终交付 */}
{/* 4. 各席位最终交付 */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<FileText className="w-4 h-4 text-primary" />
+2 -2
View File
@@ -80,11 +80,11 @@ export default function StrategyRoutes() {
<Route index element={<Navigate to="xk-task-jc" replace />} />
<Route
path="xk-task-jc"
element={<TaskXkFrom gopath="#/pageIndie/pageHome/pageSwrw" />}
element={<TaskXkFrom gopath="/#/pageIndie/pageHome/pageSwrw" />}
/>
<Route
path="xk-task-dispatch"
element={<TaskXkFrom gopath="#/pageIndie/pageJczl" />}
element={<TaskXkFrom gopath="/#/pageIndie/pageJczl" />}
/>
<Route
path="effect-task-pg"
+3 -1
View File
@@ -110,8 +110,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} />
@@ -0,0 +1,675 @@
@import "../../docs/ui规范-深蓝/tokens.css";
@import "./light-app-pages.css";
/**
* 应用管理页 —— 深蓝色主题适配(《深蓝色 UI 规范》)
* 浅色样式仍由组件内 Tailwind 承担;.dark 下本文件覆盖为 ZS-004 / ZS-002 令牌。
*/
.dark .light-app-management-page {
color: var(--color-text);
}
/* ── 页头 / 文案 ─────────────────────────────────────────────────────── */
.dark .light-app-management-page .lam-title {
color: var(--color-text-strong);
}
.dark .light-app-management-page .lam-desc {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-desc code {
background: rgba(22, 119, 255, 0.12);
color: var(--color-link);
}
.dark .light-app-management-page .lam-primary-btn {
background: var(--color-primary);
box-shadow: none;
}
.dark .light-app-management-page .lam-primary-btn:hover {
background: var(--color-primary-hover);
}
.dark .light-app-management-page .lam-primary-btn:active {
background: var(--color-primary-active);
}
/* ── 工具条 / 搜索 / 视图切换 ─────────────────────────────────────────── */
.dark .light-app-management-page .lam-toolbar {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-1);
}
.dark .light-app-management-page .lam-search-input {
background: var(--color-bg-page);
border-color: var(--color-border);
color: var(--color-text);
}
.dark .light-app-management-page .lam-search-input::placeholder {
color: var(--color-text-disabled);
}
.dark .light-app-management-page .lam-search-input:hover {
background: var(--color-bg-card);
border-color: var(--color-link);
}
.dark .light-app-management-page .lam-search-input:focus {
background: var(--color-bg-card);
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.16);
outline: none;
}
.dark .light-app-management-page .lam-view-toggle {
background: var(--color-bg-page);
border-color: var(--color-border);
}
.dark .light-app-management-page .lam-view-toggle-btn.is-active {
background: var(--color-bg-card);
color: var(--color-link);
box-shadow: var(--shadow-1);
}
.dark .light-app-management-page .lam-view-toggle-btn:not(.is-active) {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-view-toggle-btn:not(.is-active):hover {
color: var(--color-text);
}
.dark .light-app-management-page .lam-count {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-count strong {
color: var(--color-link);
}
/* ── 表格(§28)──────────────────────────────────────────────────────── */
.dark .light-app-management-page .lam-table-wrap {
background: var(--color-bg-page);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-1);
}
.dark .light-app-management-page .lam-table thead tr {
background: var(--color-bg-card);
border-color: var(--color-border);
color: var(--color-text);
}
.dark .light-app-management-page .lam-table tbody {
border-color: var(--color-border);
}
.dark .light-app-management-page .lam-table tbody tr {
border-color: var(--color-border);
}
.dark .light-app-management-page .lam-table tbody tr:hover {
background: rgba(22, 119, 255, 0.08);
}
.dark .light-app-management-page .lam-row-name {
color: var(--color-text);
}
.dark .light-app-management-page .lam-table tbody tr:hover .lam-row-name {
color: var(--color-link);
}
.dark .light-app-management-page .lam-tag-window {
border-color: rgba(245, 34, 45, 0.35);
background: var(--func-error-bg);
color: #ff7875;
}
.dark .light-app-management-page .lam-tag-iframe {
border-color: rgba(22, 119, 255, 0.35);
background: var(--func-info-bg);
color: var(--color-link);
}
.dark .light-app-management-page .lam-url {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-table tbody tr:hover .lam-url {
color: var(--color-text);
}
.dark .light-app-management-page .lam-url a {
color: inherit;
}
.dark .light-app-management-page .lam-url a:hover {
color: var(--color-link);
}
.dark .light-app-management-page .lam-mount-tag {
background: var(--func-info-bg);
color: var(--color-link);
}
.dark .light-app-management-page .lam-mount-dot {
background: var(--color-primary);
}
.dark .light-app-management-page .lam-param-btn {
border-color: var(--color-border);
background: var(--color-bg-page);
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-param-btn:hover {
background: rgba(22, 119, 255, 0.08);
color: var(--color-text);
}
.dark .light-app-management-page .lam-param-popover {
border-color: var(--color-border);
background: var(--color-bg-card);
box-shadow: var(--shadow-3);
}
.dark .light-app-management-page .lam-param-popover-head {
border-color: var(--color-border);
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-param-popover-close {
color: var(--color-link);
}
.dark .light-app-management-page .lam-param-item {
border-color: var(--color-border);
background: var(--color-bg-page);
}
.dark .light-app-management-page .lam-param-key {
color: #ff7875;
}
.dark .light-app-management-page .lam-param-val {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-status-on {
color: var(--func-success);
}
.dark .light-app-management-page .lam-status-on-dot {
background: var(--func-success);
}
.dark .light-app-management-page .lam-status-on-ping {
background: var(--func-success);
}
.dark .light-app-management-page .lam-status-off {
color: var(--color-text-disabled);
}
.dark .light-app-management-page .lam-status-off-dot {
background: var(--color-text-disabled);
}
.dark .light-app-management-page .lam-icon-btn {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-icon-btn:hover {
background: rgba(22, 119, 255, 0.08);
color: var(--color-link);
}
.dark .light-app-management-page .lam-icon-btn-danger:hover {
background: var(--func-error-bg);
color: var(--func-error);
}
.dark .light-app-management-page .lam-empty-icon {
background: var(--color-bg-page);
border: 1px solid var(--color-border);
color: var(--color-text-disabled);
}
.dark .light-app-management-page .lam-empty-title {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-empty-sub {
color: var(--color-text-disabled);
}
/* ── 卡片视图(§22 / §43)────────────────────────────────────────────── */
.dark .light-app-management-page .lam-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-1);
}
.dark .light-app-management-page .lam-card:hover {
box-shadow: var(--shadow-2);
}
.dark .light-app-management-page .lam-card-title {
color: var(--color-text-strong);
}
.dark .light-app-management-page .lam-card:hover .lam-card-title {
color: var(--color-link);
}
.dark .light-app-management-page .lam-card-desc {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-card-url-box {
background: var(--color-bg-page);
}
.dark .light-app-management-page .lam-card-param {
border-color: var(--color-border);
background: var(--color-bg-page);
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-card-mount {
background: var(--func-info-bg);
color: var(--color-link);
}
.dark .light-app-management-page .lam-card-footer {
border-color: var(--color-border);
background: rgba(12, 24, 54, 0.5);
}
/* ── 底部提示(§33 警告)────────────────────────────────────────────── */
.dark .light-app-management-page .lam-tip-box {
background: var(--func-warning-bg);
border: 1px solid rgba(250, 173, 20, 0.25);
box-shadow: none;
}
.dark .light-app-management-page .lam-tip-head {
border-color: rgba(250, 173, 20, 0.2);
color: var(--func-warning);
}
.dark .light-app-management-page .lam-tip-list {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-tip-list strong {
color: var(--color-text);
}
.dark .light-app-management-page .lam-tip-bullet {
color: var(--func-warning);
}
/* ── 弹窗(§21 Modal)────────────────────────────────────────────────── */
.dark .light-app-management-page .lam-dialog-overlay {
background: rgba(6, 14, 36, 0.6);
}
.dark .light-app-management-page .lam-dialog {
border-color: var(--color-border);
background: linear-gradient(180deg, var(--color-bg-card), var(--color-bg-page));
box-shadow: var(--shadow-3);
color: var(--color-text);
}
.dark .light-app-management-page .lam-dialog-header {
border-color: var(--color-border);
border-bottom: none;
padding-bottom: 0;
}
.dark .light-app-management-page .lam-dialog-header::after {
content: "";
display: block;
height: 1px;
margin-top: 16px;
margin-bottom: 0;
background: var(--gradient-title-line);
}
.dark .light-app-management-page .lam-dialog-icon {
background: var(--func-info-bg);
color: var(--color-link);
}
.dark .light-app-management-page .lam-dialog-title {
color: var(--color-text-strong);
}
.dark .light-app-management-page .lam-dialog-close {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-dialog-close:hover {
background: rgba(22, 119, 255, 0.08);
color: var(--color-link);
}
.dark .light-app-management-page .lam-form-label {
color: var(--color-text);
}
.dark .light-app-management-page .lam-form-label .lam-required {
color: var(--func-error);
}
.dark .light-app-management-page .lam-form-label svg {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-form-hint {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-dialog .lam-input,
.dark .light-app-management-page .lam-dialog input[type="text"] {
background: var(--color-bg-page);
border-color: var(--color-border);
color: var(--color-text);
}
.dark .light-app-management-page .lam-dialog .lam-input::placeholder,
.dark .light-app-management-page .lam-dialog input[type="text"]::placeholder {
color: var(--color-text-disabled);
}
.dark .light-app-management-page .lam-dialog .lam-input:focus,
.dark .light-app-management-page .lam-dialog input[type="text"]:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.16);
outline: none;
}
.dark .light-app-management-page .lam-param-panel {
border-color: var(--color-border);
background: var(--color-bg-page);
}
.dark .light-app-management-page .lam-param-row {
border-color: var(--color-border);
background: var(--color-bg-card);
}
.dark .light-app-management-page .lam-param-theme-box {
background: var(--color-bg-page);
}
.dark .light-app-management-page .lam-param-empty {
border-color: var(--color-border);
background: var(--color-bg-page);
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-add-param-btn {
border-color: var(--color-border-strong);
background: var(--color-bg-card);
color: var(--color-text);
}
.dark .light-app-management-page .lam-add-param-btn:hover {
border-color: var(--color-primary);
color: var(--color-link);
}
.dark .light-app-management-page .lam-dialog-footer {
border-color: var(--color-border);
}
.dark .light-app-management-page .lam-dialog-cancel {
border-color: var(--color-border-strong);
background: transparent;
color: var(--color-text);
}
.dark .light-app-management-page .lam-dialog-cancel:hover {
border-color: var(--color-link);
color: var(--color-link);
}
.dark .light-app-management-page .lam-dialog-save {
background: var(--color-primary);
box-shadow: none;
}
.dark .light-app-management-page .lam-dialog-save:hover {
background: var(--color-primary-hover);
}
.dark .light-app-management-page .lam-dialog-save:active {
background: var(--color-primary-active);
}
.dark .light-app-management-page .lam-switch-on {
background: var(--color-primary);
}
.dark .light-app-management-page .lam-switch-off {
background: var(--color-text-disabled);
}
/* ── TDesign Select(§15)──────────────────────────────────────────── */
.dark .light-app-management-page .t-select .t-input,
.dark .light-app-management-page .t-select .t-input__wrap {
background: var(--color-bg-page);
border-color: var(--color-border);
border-radius: var(--radius-input);
}
.dark .light-app-management-page .t-select .t-input__inner {
color: var(--color-text);
}
.dark .light-app-management-page .t-select .t-input__inner::placeholder {
color: var(--color-text-disabled);
}
.dark .light-app-management-page .t-select:hover .t-input,
.dark .light-app-management-page .t-select:hover .t-input__wrap {
border-color: var(--color-link);
}
.dark .light-app-management-page .t-select.t-is-focused .t-input,
.dark .light-app-management-page .t-select.t-is-focused .t-input__wrap {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.16);
}
.dark .light-app-management-page .t-select .t-fake-arrow {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .t-select__dropdown {
background: var(--color-bg-card);
border-color: var(--color-border);
box-shadow: var(--shadow-2);
}
.dark .light-app-management-page .t-select-option {
color: var(--color-text);
}
.dark .light-app-management-page .t-select-option:hover {
background: rgba(22, 119, 255, 0.08);
}
.dark .light-app-management-page .t-select-option.t-is-selected {
background: rgba(22, 119, 255, 0.16);
color: var(--color-link);
}
/* shadcn Input / Textarea in dialog */
.dark .light-app-management-page .lam-dialog [data-slot="input"],
.dark .light-app-management-page .lam-dialog textarea {
background: var(--color-bg-page);
border-color: var(--color-border);
color: var(--color-text);
}
.dark .light-app-management-page .lam-dialog [data-slot="input"]:focus-visible,
.dark .light-app-management-page .lam-dialog textarea:focus-visible {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.16);
}
.dark .light-app-management-page .lam-param-theme-box span {
color: var(--color-text-secondary);
}
/* TDesign 下拉挂载到 body 时的深蓝样式 */
.dark .lam-select-popup.t-popup {
background: var(--color-bg-card);
border-color: var(--color-border);
box-shadow: var(--shadow-2);
}
.dark .lam-select-popup .t-select-option {
color: var(--color-text);
}
.dark .lam-select-popup .t-select-option:hover {
background: rgba(22, 119, 255, 0.08);
}
.dark .lam-select-popup .t-select-option.t-is-selected {
background: rgba(22, 119, 255, 0.16);
color: var(--color-link);
}
.dark .light-app-management-page .lam-search-input + button,
.dark .light-app-management-page .lam-toolbar .absolute.text-neutral-400 {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-table .text-neutral-300 {
color: var(--color-text-disabled);
}
.dark .light-app-management-page .lam-card-url-box button {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-card-url-box button:hover {
background: rgba(22, 119, 255, 0.08);
color: var(--color-link);
}
/* ══════════════════════════════════════════════════════════════════════
浅蓝主题覆盖([data-theme="lightblue"])
══════════════════════════════════════════════════════════════════════ */
/* ── 注册应用按钮 ─────────────────────────────────────────────────────── */
[data-theme="lightblue"] .light-app-management-page .lam-primary-btn {
background-color: #0062D9;
box-shadow: 0 2px 6px rgba(0, 98, 217, 0.2);
}
[data-theme="lightblue"] .light-app-management-page .lam-primary-btn:hover {
background-color: #0079FE;
}
[data-theme="lightblue"] .light-app-management-page .lam-primary-btn:active {
background-color: #0056C6;
}
/* ── 搜索框 focus ────────────────────────────────────────────────────── */
[data-theme="lightblue"] .light-app-management-page .lam-search-input:focus {
border-color: rgba(0, 98, 217, 0.4);
box-shadow: 0 0 0 4px rgba(0, 98, 217, 0.1);
}
/* ── 视图切换激活态 ──────────────────────────────────────────────────── */
[data-theme="lightblue"] .light-app-management-page .lam-view-toggle-btn.is-active {
color: #0062D9;
}
/* ── 结果计数 ───────────────────────────────────────────────────────── */
[data-theme="lightblue"] .light-app-management-page .lam-count strong {
color: #0062D9;
}
/* ── 表格行名 hover ──────────────────────────────────────────────────── */
[data-theme="lightblue"] .light-app-management-page .lam-table tr:hover .lam-row-name {
color: #0062D9;
}
/* ── 参数弹窗 ───────────────────────────────────────────────────────── */
[data-theme="lightblue"] .light-app-management-page .lam-param-popover-close {
color: #0062D9;
}
[data-theme="lightblue"] .light-app-management-page .lam-param-key {
color: #0062D9;
}
/* ── 卡片标题 hover ──────────────────────────────────────────────────── */
[data-theme="lightblue"] .light-app-management-page .lam-card:hover .lam-card-title {
color: #0062D9;
}
/* ── 弹窗图标 / 关闭按钮 ─────────────────────────────────────────────── */
[data-theme="lightblue"] .light-app-management-page .lam-dialog-icon {
background-color: #EAF4FF;
color: #0062D9;
}
[data-theme="lightblue"] .light-app-management-page .lam-dialog-close:hover {
color: #0062D9;
}
/* ── 添加路由参数按钮 hover ───────────────────────────────────────────── */
[data-theme="lightblue"] .light-app-management-page .lam-add-param-btn:hover {
border-color: #0062D9;
color: #0062D9;
}
/* ── 表单输入框 focus ────────────────────────────────────────────────── */
[data-theme="lightblue"] .light-app-management-page .lam-input:focus,
[data-theme="lightblue"] .light-app-management-page .lam-dialog input[type="text"]:focus {
border-color: #0062D9;
box-shadow: 0 0 0 1px #0062D9;
}
[data-theme="lightblue"] .light-app-management-page .lam-dialog [data-slot="input"]:focus-visible,
[data-theme="lightblue"] .light-app-management-page .lam-dialog textarea:focus-visible {
border-color: #0062D9;
box-shadow: 0 0 0 2px rgba(0, 98, 217, 0.16);
}
/* ── TDesign Select focus / hover ────────────────────────────────────── */
[data-theme="lightblue"] .light-app-management-page .t-select:hover .t-input,
[data-theme="lightblue"] .light-app-management-page .t-select:hover .t-input__wrap {
border-color: rgba(0, 98, 217, 0.5);
}
[data-theme="lightblue"] .light-app-management-page .t-select.t-is-focused .t-input,
[data-theme="lightblue"] .light-app-management-page .t-select.t-is-focused .t-input__wrap {
border-color: #0062D9;
box-shadow: 0 0 0 2px rgba(0, 98, 217, 0.16);
}
/* ── 启用开关 ───────────────────────────────────────────────────────── */
[data-theme="lightblue"] .light-app-management-page .lam-switch-on {
background-color: #0062D9;
}
/* ── 保存按钮 ───────────────────────────────────────────────────────── */
[data-theme="lightblue"] .light-app-management-page .lam-dialog-save {
background-color: #0062D9;
box-shadow: 0 2px 8px rgba(0, 98, 217, 0.2);
}
[data-theme="lightblue"] .light-app-management-page .lam-dialog-save:hover {
background-color: #0079FE;
}
[data-theme="lightblue"] .light-app-management-page .lam-dialog-save:active {
background-color: #0056C6;
}
@@ -0,0 +1,18 @@
@import "../../docs/ui规范-深蓝/tokens.css";
/** 轻应用中心列表 / 管理页 —— 深色模式共用页面底图(与 workspace 深蓝主题一致) */
/* ── 浅蓝主题:页面底色轻微蓝染 ──────────────────────────────────────── */
[data-theme="lightblue"] .light-app-management-page,
[data-theme="lightblue"] .light-app-list-page {
background-color: #F9FCFF;
}
.dark .light-app-management-page,
.dark .light-app-list-page {
background-color: var(--color-bg-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;
}
@@ -72,6 +72,7 @@ interface ServerChain {
coordinator_prompt?: string | null;
is_public?: boolean;
is_mine?: boolean;
owner_name?: string | null;
created_at: string | null;
updated_at: string | null;
}
@@ -88,6 +89,7 @@ function toChain(s: ServerChain): BusinessChain {
isPublic: s.is_public ?? false,
// 后端缺省时按「我的」处理(向后兼容旧响应)。
isMine: s.is_mine ?? true,
ownerName: s.owner_name ?? undefined,
createdAt: s.created_at ?? undefined,
updatedAt: s.updated_at ?? undefined,
};
@@ -127,6 +127,12 @@ export interface DraftStep3Snapshot {
* blob, so the backend needs no schema change. Optional.
*/
summaryReport?: DraftStep3SummarySnapshot | null;
/**
* Step 3「大屏多页(模式二)」的结构化数据(report-json 字符串)。**只存数据不存
* HTML** —— 前端查看时用 renderDashboardHtml 按当前主题渲染,主题切换天然适配;
* 与经典 `html`(模式一)并存互不覆盖。后台挂起与手动并行绘制都写这里。Optional.
*/
dashboardJson?: string | null;
}
/**
@@ -475,9 +475,12 @@ export async function streamMultiAgent(
let frameSeq = 0;
const debugLabel = `[stream:${req.agentType}:${req.agentName}]`;
// Stream 帧日志(frame# / delta)仅 opt-in 开启。默认关闭,避免 Step 2 流式期间
// 刷屏(含 wujie 嵌入时子应用 devtools);需要排查 SSE 时在控制台执行:
// `window.__MULTI_AGENT_DEBUG__ = true`
const debugEnabled =
typeof window !== "undefined" &&
(window as unknown as { __MULTI_AGENT_DEBUG__?: boolean }).__MULTI_AGENT_DEBUG__ !== false;
(window as unknown as { __MULTI_AGENT_DEBUG__?: boolean }).__MULTI_AGENT_DEBUG__ === true;
const result: StreamMultiAgentResult = {
dispatched: [],
@@ -101,6 +101,25 @@ export async function startJob(payload: StartJobPayload): Promise<RoundtableJob>
return mapJob(await readJson<Record<string, unknown>>(res, "start job"));
}
/**
* 启动后台作业(``keepalive`` 版)—— 给 ``beforeunload``(刷新 / 关标签页)兜底用:
* keepalive fetch 在页面卸载后浏览器仍会把请求发完(payload ≤64KB),保证「正在
* 运行的研讨」在用户刷新 / 关页时也能转入后台挂起。fire-and-forget,不读响应;
* 重复触发由后端「同草稿未终态作业幂等返回」兜住,不会重复建作业。
*/
export function startJobKeepalive(payload: StartJobPayload): void {
try {
void apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toStartBody(payload)),
keepalive: true,
}).catch(() => {});
} catch {
/* 卸载途中同步异常也不能抛 */
}
}
/** 查作业快照。404 → null。 */
export async function getJob(jobId: string): Promise<RoundtableJob | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}`, { method: "GET" });
@@ -6,8 +6,8 @@
* - footer「配置」按钮 → 进入配置页(空白新建,等价于旧按钮的直接跳转)。
*
* 纯导航 / 浏览用途,不参与研讨席位选择(席位选择走 `BusinessChainPicker`)。
* 卡片席位名直接用落库快照(`seat.name`),无需实时校验,点「设置」进编辑页后
* 会看到实时数据。
* 卡片席位名优先用实时 agent 数据(`listAgents()`,与 `BusinessChainPicker` 一致),
* agent 改名后此弹窗也能同步显示;解析不到才回退到落库快照(`seat.name`)。
*/
import { Fragment, useCallback, useEffect, useState } from "react";
import {
@@ -16,6 +16,7 @@ import {
LayersIcon,
Loader2Icon,
SettingsIcon,
UserIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -29,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";
@@ -70,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);
@@ -170,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)}
@@ -217,16 +242,20 @@ export function BusinessChainListDialog({
function ChainRow({
chain,
nameById,
publishing,
onSettings,
onTogglePublic,
}: {
chain: BusinessChain;
nameById: Map<string, string>;
publishing: boolean;
onSettings: () => void;
onTogglePublic: () => void;
}) {
const isMine = chain.isMine !== false;
// 配置者用户名:后端返回 email,展示时取 @ 前的本地部分更像「用户名」。
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "";
return (
<div className="rounded-2xl border border-border bg-card p-5 transition-colors hover:border-primary/40">
{/* 标题行 + 公共徽标 + 发布/设置按钮 */}
@@ -274,19 +303,35 @@ function ChainRow({
</div>
</div>
{ownerName && (
<div
className="mt-1.5 flex items-center gap-1 text-xs text-muted-foreground"
title={chain.ownerName}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">配置人:{ownerName}</span>
</div>
)}
{chain.description && (
<div className="mt-1.5 line-clamp-2 text-sm text-muted-foreground">{chain.description}</div>
)}
{/* 席位编排:按 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));
@@ -309,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}
@@ -317,7 +364,7 @@ function ChainSeatsFlow({ chain }: { chain: BusinessChain }) {
chainTagClasses(getAvatarTypeFor(agentId, orderById.get(agentId) ?? 0)),
)}
>
{seat?.name || agentId}
{label}
</span>
);
};
@@ -0,0 +1,508 @@
/**
* 业务链条「精美流程图」(编辑器第一步右侧预览)。
*
* 把分层编排画成一张可交互的画布:
* - 列 = Stage(层间串行),列内节点 = 席位(同层并行);首列是「总控协调」。
* - 节点之间用**直角折线**(H→V→H 肘形)连接,末端带箭头。
* - 每个「步骤之间」的连线上挂一枚依赖标签 = 目标 Stage 的「本阶段目标」(用户填了才显示)。
* - 点击节点卡片 → `onNodeClick`(外层打开 `AgentDetailSheet` 抽屉看详情)。
* - 画布支持:鼠标滚轮缩放(以光标为锚点)、按住拖动平移、一键适应、全屏。
* - **全屏时**右下角显示缩略图(minimap):整图概览 + 当前视口框,点击可快速定位。
*
* 纯展示 / 交互组件,不持有业务数据;坐标布局在 `useMemo` 里一次算好,缩放平移只改
* 外层 transform,所以重绘极轻。席位配色复用 `getAvatarTypeFor` + `getAvatar`。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Crosshair,
Maximize2,
Minimize2,
Minus,
Plus,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
export interface ChainFlowSeat {
agent_id: string;
name: string;
description?: string;
}
export interface ChainFlowStage {
seats: ChainFlowSeat[];
/** 「本阶段目标」——作为进入本阶段那根连线的依赖标签;空 = 不显示。 */
goal?: string | null;
}
export interface ChainFlowDiagramProps {
stages: ChainFlowStage[];
/** 点击席位节点卡片(总控节点不触发)。 */
onNodeClick?: (agentId: string, name?: string) => void;
className?: string;
}
// ── 布局常量(世界坐标,单位 px)─────────────────────────────────────────────
const NODE_W = 206;
const NODE_H = 84;
const COL_GAP = 116;
const ROW_GAP = 22;
const PAD = 52;
const COORDINATOR_ID = "__coordinator__";
// 节点卡左侧描边(hex,内联上色避免被 border-border 覆盖)/ 角色行配色(按头像配色)。
// 参考「业务链条配置器」设计:彩色左描边 + 同色角色行。
const NODE_COLORS: Record<string, { hex: string; role: string }> = {
blue: { hex: "#3b82f6", role: "text-blue-600 dark:text-blue-300" },
purple: { hex: "#a855f7", role: "text-purple-600 dark:text-purple-300" },
emerald: { hex: "#10b981", role: "text-emerald-600 dark:text-emerald-300" },
amber: { hex: "#f59e0b", role: "text-amber-600 dark:text-amber-300" },
pink: { hex: "#ec4899", role: "text-pink-600 dark:text-pink-300" },
human: { hex: "#8b5cf6", role: "text-violet-600 dark:text-violet-300" },
};
const nodeColorOf = (t: string) =>
NODE_COLORS[t] ?? { hex: "#94a3b8", role: "text-muted-foreground" };
const MIN_SCALE = 0.25;
const MAX_SCALE = 2.6;
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
interface PlacedNode {
id: string;
name: string;
description?: string;
avatarType: string;
x: number;
y: number;
cy: number;
leftX: number;
rightX: number;
}
export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDiagramProps) {
// ── 列 / 节点 / 连线 / 标签布局 ──────────────────────────────────────────
const layout = useMemo(() => {
// 列:总控 + 各 Stage。
const rawCols: Array<{ kind: "coordinator" | "stage"; goal: string | null; nodes: Omit<PlacedNode, "x" | "y" | "cy" | "leftX" | "rightX">[] }> = [];
rawCols.push({
kind: "coordinator",
goal: null,
nodes: [{ id: COORDINATOR_ID, name: "总控协调", avatarType: "human" }],
});
let order = 0;
for (const st of stages) {
const nodes = st.seats.map((s) => ({
id: s.agent_id,
name: s.name || s.agent_id,
description: s.description,
avatarType: getAvatarTypeFor(s.agent_id, order++),
}));
rawCols.push({ kind: "stage", goal: (st.goal ?? "").trim() || null, nodes });
}
const heights = rawCols.map((c) => c.nodes.length * NODE_H + Math.max(0, c.nodes.length - 1) * ROW_GAP);
const contentH = Math.max(NODE_H, ...heights);
const worldW = PAD * 2 + rawCols.length * NODE_W + Math.max(0, rawCols.length - 1) * COL_GAP;
const worldH = PAD * 2 + contentH;
const centerY = PAD + contentH / 2;
const cols = rawCols.map((c, k) => {
const x = PAD + k * (NODE_W + COL_GAP);
const h = heights[k];
const yStart = PAD + (contentH - h) / 2;
const nodes: PlacedNode[] = c.nodes.map((n, i) => {
const y = yStart + i * (NODE_H + ROW_GAP);
return { ...n, x, y, cy: y + NODE_H / 2, leftX: x, rightX: x + NODE_W };
});
return { ...c, nodes };
});
const edges: Array<{ id: string; d: string }> = [];
const labels: Array<{ id: string; x: number; y: number; text: string }> = [];
for (let k = 0; k < cols.length - 1; k++) {
const a = cols[k];
const b = cols[k + 1];
const midX = (a.nodes[0].rightX + b.nodes[0].leftX) / 2;
for (const na of a.nodes) {
for (const nb of b.nodes) {
edges.push({
id: `${k}:${na.id}->${nb.id}`,
// 直角折线:右出 → 到中线 → 竖直 → 进左侧(尖角,不抹圆)。
d: `M ${na.rightX} ${na.cy} H ${midX} V ${nb.cy} H ${nb.leftX}`,
});
}
}
if (b.goal) labels.push({ id: `lbl-${k}`, x: midX, y: centerY, text: b.goal });
}
const allNodes = cols.flatMap((c) => c.nodes);
return { cols, edges, labels, worldW, worldH, allNodes };
}, [stages]);
// ── 视口 / 缩放平移状态 ──────────────────────────────────────────────────
const viewportRef = useRef<HTMLDivElement | null>(null);
const [vp, setVp] = useState({ w: 0, h: 0 });
const [view, setView] = useState({ scale: 1, tx: 0, ty: 0 });
const [fullscreen, setFullscreen] = useState(false);
const [grabbing, setGrabbing] = useState(false);
const panRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
// 视口尺寸跟踪(全屏切换会换挂载节点,故依赖 fullscreen 重新绑定)。
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const measure = () => setVp({ w: el.clientWidth, h: el.clientHeight });
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [fullscreen]);
// 适应视图:把整图缩放居中到视口内。
const fit = useCallback(() => {
if (!vp.w || !vp.h) return;
const s = clamp(Math.min(vp.w / layout.worldW, vp.h / layout.worldH) * 0.9, MIN_SCALE, 1.4);
setView({ scale: s, tx: (vp.w - layout.worldW * s) / 2, ty: (vp.h - layout.worldH * s) / 2 });
}, [vp.w, vp.h, layout.worldW, layout.worldH]);
// 视口尺寸 / 图结构 / 全屏变化时自动适应一次。
useEffect(() => {
fit();
}, [fit]);
// 全屏时 Esc 退出。
useEffect(() => {
if (!fullscreen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setFullscreen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [fullscreen]);
// 滚轮缩放(以光标为锚点)。native 监听以便 preventDefault。
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = el.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
setView((v) => {
const next = clamp(v.scale * (e.deltaY < 0 ? 1.12 : 1 / 1.12), MIN_SCALE, MAX_SCALE);
const k = next / v.scale;
return { scale: next, tx: mx - k * (mx - v.tx), ty: my - k * (my - v.ty) };
});
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [fullscreen]);
// 以视口中心为锚点的按钮缩放。
const zoomBy = useCallback(
(f: number) => {
setView((v) => {
const next = clamp(v.scale * f, MIN_SCALE, MAX_SCALE);
const k = next / v.scale;
const cx = vp.w / 2;
const cy = vp.h / 2;
return { scale: next, tx: cx - k * (cx - v.tx), ty: cy - k * (cy - v.ty) };
});
},
[vp.w, vp.h],
);
// ── 缩略图(minimap,仅全屏)─────────────────────────────────────────────
const MM_W = 196;
const MM_H = 132;
const mmScale = Math.min(MM_W / layout.worldW, MM_H / layout.worldH);
const mmW = layout.worldW * mmScale;
const mmH = layout.worldH * mmScale;
// 当前视口在世界坐标里的矩形(再乘 mmScale 投到缩略图)。
const viewRect = {
x: (-view.tx / view.scale) * mmScale,
y: (-view.ty / view.scale) * mmScale,
w: (vp.w / view.scale) * mmScale,
h: (vp.h / view.scale) * mmScale,
};
const onMinimapClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const wx = (e.clientX - rect.left) / mmScale;
const wy = (e.clientY - rect.top) / mmScale;
setView((v) => ({ ...v, tx: vp.w / 2 - wx * v.scale, ty: vp.h / 2 - wy * v.scale }));
},
[mmScale, vp.w, vp.h],
);
// ── 画布渲染 ─────────────────────────────────────────────────────────────
const canvas = (
<div
ref={viewportRef}
className={cn(
"relative h-full w-full overflow-hidden bg-white outline-none dark:bg-[#0C1836]",
grabbing ? "cursor-grabbing" : "cursor-grab",
)}
style={{
// 点阵背景:放大点尺寸(参考「业务链条配置器」设计稿,点更明显)。
backgroundImage:
"radial-gradient(circle, rgba(148,163,184,0.4) 1.3px, transparent 1.3px)",
backgroundSize: "22px 22px",
}}
onPointerDown={(e) => {
if (e.button !== 0) return;
panRef.current = { x: e.clientX, y: e.clientY, tx: view.tx, ty: view.ty };
setGrabbing(true);
e.currentTarget.setPointerCapture?.(e.pointerId);
}}
onPointerMove={(e) => {
const p = panRef.current;
if (!p) return;
setView((v) => ({ ...v, tx: p.tx + (e.clientX - p.x), ty: p.ty + (e.clientY - p.y) }));
}}
onPointerUp={() => {
panRef.current = null;
setGrabbing(false);
}}
onPointerCancel={() => {
panRef.current = null;
setGrabbing(false);
}}
>
{/* 世界层:所有节点 / 连线随 transform 一起缩放平移 */}
<div
className="absolute left-0 top-0 origin-top-left"
style={{
width: layout.worldW,
height: layout.worldH,
transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.scale})`,
}}
>
<svg
width={layout.worldW}
height={layout.worldH}
className="pointer-events-none absolute inset-0 overflow-visible text-slate-300 dark:text-[#3a4d72]"
>
<defs>
<marker
id="cfd-arrow"
markerWidth="9"
markerHeight="9"
refX="6.5"
refY="3"
orient="auto"
markerUnits="userSpaceOnUse"
>
<path d="M0,0 L6.5,3 L0,6 Z" fill="currentColor" />
</marker>
</defs>
{layout.edges.map((edge) => (
<path
key={edge.id}
d={edge.d}
fill="none"
stroke="currentColor"
strokeWidth={1.75}
markerEnd="url(#cfd-arrow)"
/>
))}
</svg>
{/* 节点卡片(参考「业务链条配置器」样式:左彩色描边 + 头像 + 名称 +
L-阶段徽标 + 同色角色行 + 底部 Ready 心跳条;图标仍用我方 getAvatar)。 */}
{layout.cols.flatMap((col, colIdx) =>
col.nodes.map((n) => {
const isCoordinator = n.id === COORDINATOR_ID;
const nc = nodeColorOf(n.avatarType);
return (
<div
key={n.id}
className={cn("absolute", !isCoordinator && "cursor-pointer")}
style={{ left: n.x, top: n.y, width: NODE_W, height: NODE_H }}
onPointerDown={(e) => e.stopPropagation()}
onClick={() => {
if (isCoordinator) return;
// 全屏时先退出(详情抽屉 z-50 会被 z-[100] 全屏层盖住),再打开抽屉。
if (fullscreen) setFullscreen(false);
onNodeClick?.(n.id, n.name);
}}
>
<div
className={cn(
"flex h-full w-full flex-col justify-between rounded-[5px] border bg-card p-3 shadow-[0_2px_10px_rgba(15,23,42,0.05)] transition",
isCoordinator
? "border-border bg-primary/[0.04]"
: "border-border hover:-translate-y-0.5 hover:shadow-[0_8px_22px_rgba(15,23,42,0.10)]",
)}
// 左侧 4px 彩色描边:内联上色,避免被 border-border 的四边色覆盖。
style={{ borderLeftColor: nc.hex, borderLeftWidth: 4 }}
>
{/* 上:头像 + 名称 + 阶段徽标 + 角色行 */}
<div className="flex items-start gap-2 overflow-hidden">
<div className="shrink-0">{getAvatar(n.avatarType, "h-7 w-7")}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-1">
<span className="truncate text-[11px] font-bold text-foreground">{n.name}</span>
<span className="shrink-0 rounded border border-border bg-muted px-1 py-0.5 text-[7.5px] font-bold uppercase leading-none text-muted-foreground">
{isCoordinator ? "核心" : `L-${colIdx}`}
</span>
</div>
<p className={cn("mt-0.5 truncate text-[9px] font-medium", nc.role)}>
{isCoordinator ? "按阶段派活 · 综合收口" : n.description || n.id}
</p>
</div>
</div>
{/* 下:身份 + Ready 心跳条 */}
<div className="flex items-center justify-between border-t border-border/60 pt-1 text-[8px] text-muted-foreground">
<span className="truncate">{isCoordinator ? "总控协调核心" : "智能体席位"}</span>
<span className="ml-1.5 flex shrink-0 items-center gap-1 rounded bg-muted px-1 py-0.5 font-medium text-primary">
<span className="size-1 rounded-full bg-emerald-500" />
Ready
</span>
</div>
</div>
</div>
);
}),
)}
{/* 依赖标签(步骤之间的连线上) */}
{layout.labels.map((l) => (
<div
key={l.id}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{ left: l.x, top: l.y, maxWidth: COL_GAP + 40 }}
>
<div
className="line-clamp-3 rounded-md border border-primary/30 bg-card px-2 py-1 text-center text-[10px] font-medium leading-snug text-primary shadow-sm"
title={l.text}
>
{l.text}
</div>
</div>
))}
</div>
{/* 左上状态条(参考设计的「拓扑画布」浮条)。全屏时让位给退出按钮。 */}
{!fullscreen && (
<div className="absolute left-3 top-3 flex items-center gap-2 rounded-full border border-border bg-card/90 px-3 py-1 text-[11px] font-medium text-muted-foreground shadow-sm backdrop-blur">
<span className="size-1.5 animate-pulse rounded-full bg-primary" />
<span className="font-semibold text-foreground">拓扑画布</span>
<span className="h-3 w-px bg-border" />
<span>节点 {layout.allNodes.length}</span>
</div>
)}
{/* 工具条:缩小 / 放大 / 适应 / 全屏 */}
<div className="absolute right-3 top-3 flex flex-col gap-1.5">
<ToolButton title="放大" onClick={() => zoomBy(1.2)}>
<Plus className="size-4" />
</ToolButton>
<ToolButton title="缩小" onClick={() => zoomBy(1 / 1.2)}>
<Minus className="size-4" />
</ToolButton>
<ToolButton title="适应窗口" onClick={fit}>
<Crosshair className="size-4" />
</ToolButton>
<ToolButton
title={fullscreen ? "退出全屏" : "全屏"}
onClick={() => setFullscreen((f) => !f)}
>
{fullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</ToolButton>
</div>
{/* 缩放比例徽标 */}
<div className="absolute bottom-3 left-3 select-none rounded-md border border-border bg-card/90 px-2 py-0.5 text-[10px] font-semibold tabular-nums text-muted-foreground shadow-sm">
{Math.round(view.scale * 100)}%
</div>
{/* 全屏退出按钮(左上) */}
{fullscreen && (
<button
type="button"
onClick={() => setFullscreen(false)}
title="退出全屏(Esc)"
className="absolute left-3 top-3 flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs font-medium text-muted-foreground shadow-sm transition hover:bg-muted hover:text-foreground"
>
<X className="size-4" />
退出全屏
</button>
)}
{/* 缩略图:仅全屏时右下角 */}
{fullscreen && (
<div className="absolute bottom-3 right-3 rounded-lg border border-border bg-card/95 p-1.5 shadow-lg backdrop-blur">
<div
className="relative cursor-pointer overflow-hidden rounded bg-muted/40"
style={{ width: mmW, height: mmH }}
onClick={onMinimapClick}
>
{/* 节点缩略块 */}
{layout.allNodes.map((n) => (
<div
key={n.id}
className={cn(
"absolute rounded-[1px]",
n.id === COORDINATOR_ID ? "bg-primary/70" : "bg-foreground/35",
)}
style={{
left: n.x * mmScale,
top: n.y * mmScale,
width: NODE_W * mmScale,
height: NODE_H * mmScale,
}}
/>
))}
{/* 当前视口框 */}
<div
className="absolute border-2 border-primary bg-primary/10"
style={{
left: clamp(viewRect.x, 0, mmW),
top: clamp(viewRect.y, 0, mmH),
width: clamp(viewRect.w, 4, mmW),
height: clamp(viewRect.h, 4, mmH),
}}
/>
</div>
</div>
)}
</div>
);
// 全屏:固定定位铺满视口(position:fixed 自带脱离文档流,无需 portal)。
if (fullscreen) {
return <div className="fixed inset-0 z-[100] bg-background">{canvas}</div>;
}
return <div className={cn("relative", className)}>{canvas}</div>;
}
// ── 工具按钮 ────────────────────────────────────────────────────────────────
function ToolButton({
title,
onClick,
children,
}: {
title: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
onClick={onClick}
className="flex size-8 items-center justify-center rounded-md border border-border bg-card text-muted-foreground shadow-sm transition hover:bg-muted hover:text-foreground active:scale-95"
>
{children}
</button>
);
}
@@ -48,6 +48,8 @@ export interface DagFlowChartProps {
coordinatorState?: ChainNodeState;
/** 共识里程碑状态(默认 pending)。 */
consensusState?: ChainNodeState;
/** 总结报告里程碑状态(Step3 第一段,roundtable-summary 写 md;默认 pending)。 */
summaryState?: ChainNodeState;
/** 结果绘制里程碑状态(默认 pending)。 */
reportState?: ChainNodeState;
/** 是否显示末尾里程碑(共识 / 结果绘制)。默认 true。 */
@@ -85,6 +87,7 @@ export function DagFlowChart({
seatStates,
coordinatorState = "pending",
consensusState = "pending",
summaryState = "pending",
reportState = "pending",
showMilestones = true,
showReportMilestone = true,
@@ -157,11 +160,22 @@ export function DagFlowChart({
{showReportMilestone && (
<>
<Connector
active={reportState === "active"}
done={reportState === "done"}
active={summaryState === "active" || reportState === "active"}
done={summaryState === "done" && reportState === "done"}
compact={compact}
/>
<MilestoneNode label="结果绘制" emoji="📊" state={reportState} compact={compact} />
{/* Step3 三产物并行纵列(总结/绘制/大屏,共识后三流齐跑) */}
<div
className={cn(
"flex shrink-0 flex-col items-center rounded-2xl border border-dashed border-primary/30 bg-primary/[0.04]",
compact ? "gap-1 px-1.5 pb-1.5 pt-1" : "gap-1.5 px-2 pb-2 pt-1.5",
)}
>
<span className="text-[9px] font-bold tracking-widest text-primary/70">并行生成</span>
<MilestoneNode label="总结报告" emoji="📝" state={summaryState} compact={compact} />
<MilestoneNode label="结果绘制" emoji="📊" state={reportState} compact={compact} />
<MilestoneNode label="大屏多页" emoji="🖥️" state={reportState} compact={compact} />
</div>
</>
)}
</>
@@ -0,0 +1,469 @@
/**
* Step2 消息列表右上角的「编排流程图小窗」—— 取代原全屏弹窗(2026-06-12)。
*
* 竖向流程图、直角连线(org-chart 风格):
* - 总控协调在最顶;
* - 每个 stage 一行:并行席位横向并列,由上方单点经「竖线 → 横向母线 → 各列竖线」
* 的**直角边**分叉连接(3 个并行席位就分 3 路,以此类推);
* - stage 之间再以直角边汇聚回中线,串行接入下一行;
* - 最后汇聚到 🤝 共识达成。
*
* 悬浮窗交互(2026-06-13,参考第三步结构化输出流程图 FlowGraphPanel 的画布体验):
* - **画布**:鼠标滚轮缩放(0.5–2.5,以光标为锚点)、按住拖动平移、双击复位;
* - **窗口**:拖 header 移动位置;拖左/右边缘调宽、下边缘调高、左下/右下角同时调宽高;
* - 位置/尺寸为会话内状态,不持久化。
*
* 与 `DagFlowChart`(横向,弹窗/总览用)共享 ChainNodeState / resolveSeat 语义,
* 但布局完全独立。沙箱打开时由 Step2Panel 自动关闭本窗(腾出注意力给沙箱),
* 「编排流程图」按钮可随时重开。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { CheckIcon, Loader2Icon, Network, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars";
import { chainTagClasses } from "../lib/business-chain";
import type { ChainNodeState } from "../lib/job-types";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
export interface DagFlowMiniPanelProps {
/** 分层计划的 stages(只取 agentIds)。 */
stages: ReadonlyArray<{ id?: string; agentIds: string[] }>;
/** agentId(小写)+ 全局序号 → {name, avatarType}。 */
resolveSeat?: (agentId: string, order: number) => { name: string; avatarType: string };
/** agentId(小写)→ 节点状态。 */
seatStates?: Record<string, ChainNodeState>;
coordinatorState?: ChainNodeState;
consensusState?: ChainNodeState;
/** 右上角 × 关闭。 */
onClose: () => void;
className?: string;
}
/** 连线颜色(浅色/深色主题都可见)。 */
const LINE = "bg-slate-300 dark:bg-border";
// 窗口尺寸边界(px)。
const MIN_W = 220;
const MAX_W = 640;
const MIN_H = 240;
const MAX_H = 760;
// 画布缩放边界。
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 2.5;
/**
* 直角分叉/汇聚连接段。
* `fanOut=true`:上方单点 → 竖线 → 横向母线 → 每列一条竖线落到下方 count 个节点;
* `fanOut=false`:count 个节点各出一条竖线 → 横向母线 → 中线竖线汇聚到下方单点。
* count<=1 时退化成一条居中竖线(串行段)。
*/
function OrthoConnector({ count, fanOut }: { count: number; fanOut: boolean }) {
if (count <= 1) {
return <div className={cn("mx-auto h-5 w-px", LINE)} />;
}
const busRow = (busAt: "top" | "bottom") => (
<div
className="grid w-full"
style={{ gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))` }}
>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="relative h-5">
{/* 横向母线:首列只画右半、末列只画左半、中间列贯通 —— 拼成一条首列中心到末列中心的横线 */}
<span
className={cn(
"absolute h-px",
LINE,
busAt === "top" ? "top-0" : "bottom-0",
i === 0 ? "left-1/2 right-0" : i === count - 1 ? "left-0 right-1/2" : "inset-x-0",
)}
/>
{/* 各列竖线(母线 ↔ 节点) */}
<span className={cn("absolute left-1/2 top-0 bottom-0 w-px -translate-x-1/2", LINE)} />
</div>
))}
</div>
);
return fanOut ? (
<div className="w-full">
{/* 单点竖线(来自上方节点)→ 母线在行顶 → 各列下落 */}
<div className={cn("mx-auto h-4 w-px", LINE)} />
{busRow("top")}
</div>
) : (
<div className="w-full">
{/* 各列上行 → 母线贴行底 → 中线竖线(汇向下方节点) */}
{busRow("bottom")}
<div className={cn("mx-auto h-4 w-px", LINE)} />
</div>
);
}
/** 小窗节点:头像 + 状态徽标 + 名称标签(竖排紧凑版,对齐 DagFlowChart 的状态语义)。 */
function MiniNode({
label,
avatarType,
state,
emoji,
}: {
label: string;
avatarType?: string;
state: ChainNodeState;
emoji?: string;
}) {
const tag = avatarType ? chainTagClasses(avatarType) : "";
return (
<div className="flex w-full min-w-0 flex-col items-center gap-1 text-center">
<div className="relative flex size-11 items-center justify-center">
{state === "active" && (
<span className="absolute inset-0 animate-ping rounded-full bg-primary/25" />
)}
<span
className={cn(
"relative flex size-11 items-center justify-center rounded-full border-2 bg-white text-lg transition-all dark:bg-card",
state === "active" && "border-primary shadow-[0_0_0_3px_rgba(22,119,255,0.12)]",
state === "done" && "border-emerald-400/70",
state === "pending" && "border-border opacity-45 grayscale",
)}
>
{emoji ?? (avatarType ? getAvatar(avatarType, "h-9 w-9") : null)}
{state === "done" && (
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-emerald-500 text-white shadow">
<CheckIcon className="size-2.5" />
</span>
)}
{state === "active" && (
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-primary text-primary-foreground shadow">
<Loader2Icon className="size-2.5 animate-spin" />
</span>
)}
</span>
</div>
<span
className={cn(
"max-w-full truncate rounded border px-1 py-px text-[10px] font-medium leading-tight",
state === "pending" || !tag ? "border-border bg-muted text-muted-foreground" : tag,
)}
title={label}
>
{label}
</span>
</div>
);
}
// ── 悬浮窗几何(位置 / 尺寸 / 画布变换)────────────────────────────────────────
interface WindowGeom {
/** null = 尚未拖过,沿用初始右上角锚定(right:16 / top:48)。 */
left: number | null;
top: number;
w: number;
h: number;
}
type ResizeMode = "move" | "e" | "w" | "s" | "se" | "sw";
export function DagFlowMiniPanel({
stages,
resolveSeat,
seatStates,
coordinatorState = "pending",
consensusState = "pending",
onClose,
className,
}: DagFlowMiniPanelProps) {
const resolve = (agentId: string, order: number) =>
resolveSeat?.(agentId, order) ?? {
name: agentId,
avatarType: getAvatarTypeFor(agentId, order),
};
const panelRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const [geom, setGeom] = useState<WindowGeom>({ left: null, top: 48, w: 256, h: 400 });
// 画布变换(平移 px + 缩放),transform-origin 固定 0 0。
const [view, setView] = useState({ tx: 0, ty: 0, scale: 1 });
const viewRef = useRef(view);
viewRef.current = view;
// ── 窗口移动 / 边缘缩放(pointer capture,统一一套手势状态机)────────────────
const gestureRef = useRef<{
mode: ResizeMode;
startX: number;
startY: number;
start: WindowGeom & { left: number };
} | null>(null);
const beginWindowGesture = useCallback(
(mode: ResizeMode) => (e: React.PointerEvent) => {
const el = panelRef.current;
if (!el) return;
e.preventDefault();
e.stopPropagation();
// 首次手势把右锚定折算成 left(offsetParent = 消息区 relative 容器)。
const left = geom.left ?? el.offsetLeft;
gestureRef.current = {
mode,
startX: e.clientX,
startY: e.clientY,
start: { ...geom, left },
};
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[geom],
);
const onWindowGestureMove = useCallback((e: React.PointerEvent) => {
const g = gestureRef.current;
if (!g) return;
e.preventDefault();
const dx = e.clientX - g.startX;
const dy = e.clientY - g.startY;
const s = g.start;
const clampW = (w: number) => Math.min(MAX_W, Math.max(MIN_W, w));
const clampH = (h: number) => Math.min(MAX_H, Math.max(MIN_H, h));
setGeom(() => {
switch (g.mode) {
case "move":
return { ...s, left: Math.max(0, s.left + dx), top: Math.max(0, s.top + dy) };
case "e":
return { ...s, w: clampW(s.w + dx) };
case "w": {
const w = clampW(s.w - dx);
return { ...s, w, left: Math.max(0, s.left + (s.w - w)) };
}
case "s":
return { ...s, h: clampH(s.h + dy) };
case "se":
return { ...s, w: clampW(s.w + dx), h: clampH(s.h + dy) };
case "sw": {
const w = clampW(s.w - dx);
return { ...s, w, left: Math.max(0, s.left + (s.w - w)), h: clampH(s.h + dy) };
}
default:
return s;
}
});
}, []);
const endWindowGesture = useCallback((e: React.PointerEvent) => {
if (!gestureRef.current) return;
gestureRef.current = null;
try {
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
} catch {
/* 已释放 */
}
}, []);
// ── 画布滚轮缩放(以光标为锚点)────────────────────────────────────────────
// React 17+ 在根上以 passive 注册 wheel,onWheel 里 preventDefault 无效 →
// 必须原生 addEventListener({passive:false}),否则缩放的同时页面会跟着滚。
useEffect(() => {
const vp = viewportRef.current;
if (!vp) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
e.stopPropagation();
const { tx, ty, scale } = viewRef.current;
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, scale * factor));
if (next === scale) return;
// 以光标为锚点:保持光标下的内容点不动。
const rect = vp.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const k = next / scale;
setView({ tx: cx - (cx - tx) * k, ty: cy - (cy - ty) * k, scale: next });
};
vp.addEventListener("wheel", onWheel, { passive: false });
return () => vp.removeEventListener("wheel", onWheel);
}, []);
// ── 画布拖动平移(与窗口手势互不干扰:画布手势只在 viewport 内启动)──────────
const panRef = useRef<{ startX: number; startY: number; tx: number; ty: number } | null>(null);
const [panning, setPanning] = useState(false);
const onCanvasDown = useCallback((e: React.PointerEvent) => {
// 只响应主键 / 触摸。
if (e.button !== 0) return;
const { tx, ty } = viewRef.current;
panRef.current = { startX: e.clientX, startY: e.clientY, tx, ty };
setPanning(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, []);
const onCanvasMove = useCallback((e: React.PointerEvent) => {
const p = panRef.current;
if (!p) return;
setView((v) => ({ ...v, tx: p.tx + (e.clientX - p.startX), ty: p.ty + (e.clientY - p.startY) }));
}, []);
const onCanvasUp = useCallback((e: React.PointerEvent) => {
if (!panRef.current) return;
panRef.current = null;
setPanning(false);
try {
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
} catch {
/* 已释放 */
}
}, []);
// 双击复位画布。
const resetView = useCallback(() => setView({ tx: 0, ty: 0, scale: 1 }), []);
let runningOrder = 0;
// 边缘 / 边角把手的公共类。
const handleBase = "absolute z-10 touch-none";
return (
<div
ref={panelRef}
className={cn(
"absolute flex flex-col overflow-hidden rounded-xl border border-slate-200 bg-white/95 shadow-xl backdrop-blur",
"dark:border-border dark:bg-card/95",
className,
)}
style={{
width: geom.w,
height: geom.h,
top: geom.top,
...(geom.left == null ? { right: 16 } : { left: geom.left }),
}}
>
{/* header:拖动移动窗口 */}
<header
onPointerDown={beginWindowGesture("move")}
onPointerMove={onWindowGestureMove}
onPointerUp={endWindowGesture}
onPointerCancel={endWindowGesture}
className="flex shrink-0 cursor-move select-none items-center justify-between gap-2 border-b border-slate-100 px-3 py-2 dark:border-border"
title="拖动移动小窗"
>
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-slate-600 dark:text-foreground/80">
<Network className="size-3.5 text-primary" />
编排流程图
<span className="rounded bg-slate-100 px-1 py-px font-mono text-[9px] text-slate-400 dark:bg-muted">
{Math.round(view.scale * 100)}%
</span>
</span>
<button
type="button"
onPointerDown={(e) => e.stopPropagation()}
onClick={onClose}
title="关闭流程图小窗"
className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-accent"
>
<X className="size-3.5" />
</button>
</header>
{/* 画布 viewport:滚轮缩放 / 拖动平移 / 双击复位 */}
<div
ref={viewportRef}
onPointerDown={onCanvasDown}
onPointerMove={onCanvasMove}
onPointerUp={onCanvasUp}
onPointerCancel={onCanvasUp}
onDoubleClick={resetView}
className={cn(
"relative min-h-0 flex-1 touch-none overflow-hidden",
panning ? "cursor-grabbing" : "cursor-grab",
)}
title="滚轮缩放 · 拖动平移 · 双击复位"
>
<div
className="w-full px-3 py-3"
style={{
transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.scale})`,
transformOrigin: "0 0",
}}
>
{/* 总控协调(顶) */}
<MiniNode label="总控协调" avatarType="human" state={coordinatorState} />
{stages.map((stage, stageIdx) => {
const count = stage.agentIds.length;
if (count === 0) return null;
return (
<div className="contents" key={stage.id ?? `stage-${stageIdx}`}>
{/* 直角分叉:上方单点 → 本行各席位 */}
<OrthoConnector count={count} fanOut />
<div
className="grid w-full gap-1"
style={{ gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))` }}
>
{stage.agentIds.map((agentId) => {
const order = runningOrder++;
const meta = resolve(agentId, order);
const state = seatStates?.[agentId.toLowerCase()] ?? "pending";
return (
<MiniNode
key={agentId}
label={meta.name}
avatarType={meta.avatarType}
state={state}
/>
);
})}
</div>
{/* 直角汇聚:本行各席位 → 中线(接下一行 / 共识) */}
<OrthoConnector count={count} fanOut={false} />
</div>
);
})}
{/* 共识达成(底) */}
<MiniNode label="共识达成" emoji="🤝" state={consensusState} />
</div>
</div>
{/* 边缘 / 边角缩放把手:左右边调宽、下边调高、左下/右下角同时调宽高 */}
<div
onPointerDown={beginWindowGesture("e")}
onPointerMove={onWindowGestureMove}
onPointerUp={endWindowGesture}
onPointerCancel={endWindowGesture}
className={cn(handleBase, "right-0 top-8 bottom-3 w-1.5 cursor-ew-resize")}
title="拖动调整宽度"
/>
<div
onPointerDown={beginWindowGesture("w")}
onPointerMove={onWindowGestureMove}
onPointerUp={endWindowGesture}
onPointerCancel={endWindowGesture}
className={cn(handleBase, "left-0 top-8 bottom-3 w-1.5 cursor-ew-resize")}
title="拖动调整宽度"
/>
<div
onPointerDown={beginWindowGesture("s")}
onPointerMove={onWindowGestureMove}
onPointerUp={endWindowGesture}
onPointerCancel={endWindowGesture}
className={cn(handleBase, "bottom-0 left-3 right-3 h-1.5 cursor-ns-resize")}
title="拖动调整高度"
/>
<div
onPointerDown={beginWindowGesture("se")}
onPointerMove={onWindowGestureMove}
onPointerUp={endWindowGesture}
onPointerCancel={endWindowGesture}
className={cn(handleBase, "bottom-0 right-0 size-3.5 cursor-nwse-resize")}
title="拖动整体缩放窗口"
>
{/* 角部抓手视觉提示 */}
<span className="absolute bottom-0.5 right-0.5 block size-2 rounded-br border-b-2 border-r-2 border-slate-300 dark:border-border" />
</div>
<div
onPointerDown={beginWindowGesture("sw")}
onPointerMove={onWindowGestureMove}
onPointerUp={endWindowGesture}
onPointerCancel={endWindowGesture}
className={cn(handleBase, "bottom-0 left-0 size-3.5 cursor-nesw-resize")}
title="拖动整体缩放窗口"
>
<span className="absolute bottom-0.5 left-0.5 block size-2 rounded-bl border-b-2 border-l-2 border-slate-300 dark:border-border" />
</div>
</div>
);
}
@@ -91,8 +91,11 @@ const PHASE_LABEL: Record<JobPhase, string> = {
leader_thinking: "总控决策派活中…",
dispatching: "席位发言中…",
consensus: "共识达成",
report_drawing: "结果绘制中…",
report_done: "绘制完成",
// Step3 三产物(总结报告 / 结果绘制 / 大屏数据)并行生成,两个阶段值都代表
// 「并行进行中」(后端 gather 并发推进、phase 交错落库,不区分先后)。
summary_writing: "Step3 并行生成中(总结/绘制/大屏)…",
report_drawing: "Step3 并行生成中(总结/绘制/大屏)…",
report_done: "Step3 完成",
};
export function DispatchChainModal({
@@ -138,14 +141,17 @@ export function DispatchChainModal({
const consensusDone =
consensusPercentage >= 100 ||
phase === "consensus" ||
phase === "summary_writing" ||
phase === "report_drawing" ||
phase === "report_done" ||
status === "done";
const consensusActive = phase === "consensus";
// 报告绘制里程碑
const reportActive = phase === "report_drawing";
const reportDone = phase === "report_done" || status === "done";
// Step3 三产物**并行**(2026-06-13:后端 gather 并发跑总结/绘制/大屏,phase 交错
// 落库不分先后)→ 统一成一组并行里程碑状态。
const step3Active = phase === "summary_writing" || phase === "report_drawing";
const step3Done = phase === "report_done" || status === "done";
const step3State: ChainNodeState = step3Done ? "done" : step3Active ? "active" : "pending";
// ── DAG(分层)流程图:mode==="dag" 且有 stages 时按 stage 分列绘制 ──────────────
const showDag = orchestrationMode === "dag" && (stages?.length ?? 0) > 0;
@@ -199,15 +205,17 @@ export function DispatchChainModal({
{/* 流程图:dag 模式按 stage 分层(DAG);否则线性总控→席位→共识→报告 */}
{showDag ? (
// 分层 DAG 流程图:总控 → ┌Stage┐(并行席位纵向并列) → … → 🤝共识。
// 后台本期不绘报告(Step3 禁用) → 不显示「结果绘制」里程碑。
// 分层 DAG 流程图:总控 → ┌Stage┐(并行席位纵向并列) → … → 🤝共识 → 📊结果绘制。
// 后台 Step3(总结报告 + 结果绘制)已恢复 → 显示「结果绘制」里程碑。
<DagFlowChart
stages={stages!}
resolveSeat={resolveDagSeat}
seatStates={dagSeatStates}
coordinatorState={coordinatorState}
consensusState={dagConsensusState}
showReportMilestone={false}
summaryState={step3State}
reportState={step3State}
showReportMilestone
className="shrink-0"
/>
) : (
@@ -246,13 +254,14 @@ export function DispatchChainModal({
}
/>
{/* 报告绘制里程碑 */}
<Connector active={reportActive} done={reportDone} />
<MilestoneNode
label="结果绘制"
emoji="📊"
state={reportActive ? "active" : reportDone ? "done" : "pending"}
/>
{/* Step3:总结报告 / 结果绘制 / 大屏多页 —— **并行**纵列(共识后三流齐跑) */}
<Connector active={step3Active} done={step3Done} />
<div className="flex shrink-0 flex-col items-center gap-1.5 rounded-2xl border border-dashed border-primary/30 bg-primary/[0.04] px-2 pb-2 pt-1.5">
<span className="text-[9px] font-bold tracking-widest text-primary/70">并行生成</span>
<MilestoneNode label="总结报告" emoji="📝" state={step3State} />
<MilestoneNode label="结果绘制" emoji="📊" state={step3State} />
<MilestoneNode label="大屏多页" emoji="🖥️" state={step3State} />
</div>
</div>
</div>
)}
@@ -334,15 +343,13 @@ export function DispatchChainModal({
停止
</Button>
)}
{/* 只对「运行中」任务隐藏入口——它正在后台真跑,进入会暂停前端编排、
与后台作业双跑或借此重跑。其它状态(排队 / 待澄清 / 已完成 / 已取消 /
出错)都不是正在跑的后台作业,可正常查看。 */}
{status !== "running" && (
<Button onClick={onView} disabled={!onView} className="gap-1.5">
<EyeIcon className="size-4" />
{status === "done" ? "查看报告" : "查看"}
</Button>
)}
{/* 「查看」对所有状态开放——运行中点击进入的是**只读 hydrate 视图**
(onView 先暂停本地编排再加载草稿,纯展示绝不 resume,不会与后台
作业双跑;草稿由后台逐轮回写,进入即可看到当前进度)。 */}
<Button onClick={onView} disabled={!onView} className="gap-1.5">
<EyeIcon className="size-4" />
{status === "done" ? "查看报告" : "查看"}
</Button>
</div>
</DialogFooter>
</DialogContent>
@@ -1,19 +1,30 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
CheckCircle,
ChevronDown,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Download,
Eye,
FileText,
Loader2,
RefreshCw,
Sparkles,
Users,
} from "lucide-react";
import { useArtifacts } from "@/components/workspace/artifacts";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { ArtifactFileDetail, useArtifacts } from "@/components/workspace/artifacts";
import { ThreadContext } from "@/components/workspace/messages/context";
import { apiFetch } from "@/core/api";
import type { AgentThreadState } from "@/core/threads";
import { cn } from "@/lib/utils";
import { getAvatar } from "@/roundtable-planning/components/Avatars";
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
import {
buildArtifactDownloadUrl,
listSessionArtifacts,
@@ -21,7 +32,6 @@ import {
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { Step3FileBrowseMode } from "@/roundtable-planning/components/Step3FileBrowseMode";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import {
Step3ReportMode,
@@ -32,7 +42,9 @@ import type {
DraftStep3Snapshot,
DraftStep3SummarySnapshot,
} from "@/roundtable-planning/api/drafts";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
import type { ReportIntent, UseStep3ReportReturn } from "@/roundtable-planning/hooks/useStep3Report";
import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
interface SeatDelivery {
/** Display name of the seat (e.g. "情报收集"). */
@@ -72,6 +84,10 @@ interface HighFidelityReportProps {
savedSummary?: DraftStep3SummarySnapshot | null;
/** 一次总结完成时回调,把结果交给上层存进草稿 step3.summaryReport。 */
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
/** 页面层常驻的三个 Step3 生成流(切走不断流,进入续看)。 */
summaryFlow: UseStep3SummaryReturn;
reportFlow: UseStep3ReportReturn;
dashboardFlow: UseStep3DashboardReturn;
/** Resets state and navigates back to Step 1. */
onReset: () => void;
openArtifactPreview: (threadId: string, path: string) => void;
@@ -116,6 +132,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
coordinatorPrompt,
savedSummary,
onSummarySaved,
summaryFlow,
reportFlow,
dashboardFlow,
onReset,
openArtifactPreview,
seatStubMessages,
@@ -132,7 +151,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
// 用户点「最终方案展示」切到静态页(summaryMode=false),再从静态页可进「结果绘制」HTML。
const [summaryMode, setSummaryMode] = useState(true);
const [drawMode, setDrawMode] = useState(false);
const [fileSandboxMode, setFileSandboxMode] = useState(false);
// 文件沙箱(2026-06-13 改内联):点文件卡片在右侧滑入沙箱面板、左侧席位栏收起
// (不再整页切走)。target 在关闭后保留,供退出过渡动画期间继续渲染。
const [fileSandboxOpen, setFileSandboxOpen] = useState(false);
const [fileSandboxTarget, setFileSandboxTarget] = useState<{
threadId: string;
file: SessionArtifactFile;
@@ -225,17 +246,77 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
};
}, [seatThreadEntries, reloadKey]);
const totalFileCount = useMemo(
() => artifactsBySeat.reduce((sum, s) => sum + s.files.length, 0),
[artifactsBySeat],
);
// ── 统一席位总览(2026-06-13 大改):交付内容 + 该席位生成的报告文件合并到一起 ──
// 以「有线程的席位」为主(带文件清单),无线程时回退到交付列表;key = displayName。
const seatOverviews = useMemo(() => {
const avatarByDisplay = new Map<string, string>();
for (const d of seatDeliveries) {
if (!avatarByDisplay.has(d.sender)) avatarByDisplay.set(d.sender, d.avatarType);
}
type Overview = {
key: string;
displayName: string;
avatarType: string;
delivery: string | null;
threadId: string | null;
files: SessionArtifactFile[];
loading: boolean;
error: string | null;
};
const out: Overview[] = [];
const seen = new Set<string>();
artifactsBySeat.forEach((s, i) => {
seen.add(s.displayName);
out.push({
key: s.displayName,
displayName: s.displayName,
avatarType:
avatarByDisplay.get(s.displayName) ?? getAvatarTypeFor(s.agentName, i),
delivery: deliveryByDisplay.get(s.displayName) ?? null,
threadId: s.threadId,
files: s.files,
loading: s.loading,
error: s.error,
});
});
// 没有线程映射(恢复的旧草稿等)时,纯交付席位也要出现在列表里。
seatDeliveries.forEach((d, i) => {
if (seen.has(d.sender)) return;
seen.add(d.sender);
out.push({
key: d.sender,
displayName: d.sender,
avatarType: d.avatarType || getAvatarTypeFor(d.sender, i),
delivery: d.content,
threadId: null,
files: [],
loading: false,
error: null,
});
});
return out;
}, [artifactsBySeat, seatDeliveries, deliveryByDisplay]);
// 只保留「有产出文件」的席位;加载中 / 出错的席位仍保留以展示其状态。
// 全部席位都没有文件(且都已加载完成、无错误)时,整块「生成的报告文件」隐藏。
const seatsWithFiles = useMemo(
() => artifactsBySeat.filter((s) => s.loading || s.error || s.files.length > 0),
[artifactsBySeat],
);
// 左侧席位栏(像第二步的参与角色栏):可收起成头像列;点卡片/头像 → 展开右侧
// 对应区块并平滑滚动过去(短暂高亮提示落点)。
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [focusedSeat, setFocusedSeat] = useState<string | null>(null);
const seatSectionRefs = useRef<Map<string, HTMLElement>>(new Map());
// 总控最终结论:支持展开/收起(默认展开);左侧总控头像点击 → 展开并滚动到这里。
const CONSENSUS_KEY = "__consensus__";
const [consensusExpanded, setConsensusExpanded] = useState(true);
const consensusSectionRef = useRef<HTMLElement | null>(null);
const focusConsensus = useCallback(() => {
setConsensusExpanded(true);
setFocusedSeat(CONSENSUS_KEY);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
consensusSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
window.setTimeout(() => setFocusedSeat((cur) => (cur === CONSENSUS_KEY ? null : cur)), 1800);
}, []);
const toggleSeat = useCallback((key: string) => {
setExpandedSeats((prev) => {
@@ -246,16 +327,94 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
});
}, []);
const handleViewInSandbox = useCallback((threadId: string, file: SessionArtifactFile) => {
setFileSandboxTarget({ threadId, file });
setFileSandboxMode(true);
const focusSeat = useCallback((key: string) => {
setExpandedSeats((prev) => new Set(prev).add(key));
setFocusedSeat(key);
// 等展开渲染完成后再滚动,落点更准。
requestAnimationFrame(() => {
requestAnimationFrame(() => {
seatSectionRefs.current
.get(key)
?.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
window.setTimeout(
() => setFocusedSeat((cur) => (cur === key ? null : cur)),
1800,
);
}, []);
const handleBackFromFileSandbox = useCallback(() => {
setFileSandboxMode(false);
setFileSandboxTarget(null);
deselectArtifact();
}, [deselectArtifact]);
// 文本类扩展名 → 拉文本走虚拟 stub 预览;其余(图片/PDF 等二进制)直接 iframe。
const TEXT_FILE_RE = /\.(md|markdown|html?|txt|csv|json|js|ts|tsx|py|xml|yml|yaml|log|mmd|css)$/i;
const [fileSandboxContent, setFileSandboxContent] = useState<string | null>(null);
const [fileSandboxIsText, setFileSandboxIsText] = useState(true);
const fileFetchSeqRef = useRef(0);
// 点文件卡片:右侧滑入内联沙箱 + 左侧席位栏收起(不消失),页面不切走。
// 内容**不走** threads artifacts hook(这些圆桌线程在那条路由上 404,曾表现为
// 预览区只显示 {"detail":"Not Found"})——改用与「下载」同源、已验证可用的
// buildArtifactDownloadUrl 自取文本,再以 write_file 虚拟 stub 喂给预览组件。
const handleViewInSandbox = useCallback((threadId: string, file: SessionArtifactFile) => {
setFileSandboxTarget({ threadId, file });
setFileSandboxOpen(true);
setSidebarCollapsed(true);
const isText = TEXT_FILE_RE.test(file.name);
setFileSandboxIsText(isText);
setFileSandboxContent(null);
if (!isText) return; // 二进制走 iframe(src 即下载路由),无需取文本
const seq = ++fileFetchSeqRef.current;
void apiFetch(buildArtifactDownloadUrl(threadId, file.path))
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
if (fileFetchSeqRef.current === seq) setFileSandboxContent(text);
})
.catch((err) => {
if (fileFetchSeqRef.current === seq) {
setFileSandboxContent(`> 读取文件失败:${(err as Error).message}`);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleCloseFileSandbox = useCallback(() => {
setFileSandboxOpen(false); // target 保留到过渡结束,面板滑出更顺
}, []);
// 虚拟 stub(write-file URL → 预览组件直接读 tool_call.args.content,零 HTTP)。
const fileSandboxStubPath = fileSandboxTarget
? fileSandboxTarget.file.path.startsWith("/")
? fileSandboxTarget.file.path
: `/${fileSandboxTarget.file.path}`
: "";
const fileSandboxVirtualUrl = fileSandboxTarget
? `write-file:${fileSandboxStubPath}?message_id=hfr-file-stub&tool_call_id=tc-hfr-0`
: "";
const fileSandboxThreadStub = useMemo(() => {
const messages =
fileSandboxTarget && fileSandboxContent != null
? [
{
id: "hfr-file-stub",
type: "ai",
content: "",
tool_calls: [
{
id: "tc-hfr-0",
name: "write_file",
args: { path: fileSandboxStubPath, content: fileSandboxContent },
},
],
},
]
: [];
return {
messages,
isLoading: false,
isThreadLoading: false,
values: { title: "", messages, artifacts: [] },
} as unknown as BaseStream<AgentThreadState>;
}, [fileSandboxTarget, fileSandboxContent, fileSandboxStubPath]);
const handleDownload = useCallback(
(threadId: string, file: SessionArtifactFile) => {
@@ -283,28 +442,12 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
setSelectedModel={setSelectedModel}
coordinatorPrompt={coordinatorPrompt}
savedSummary={savedSummary}
onSummarySaved={onSummarySaved}
summary={summaryFlow}
onViewStatic={() => setSummaryMode(false)}
/>
);
}
if (fileSandboxMode && fileSandboxTarget) {
return (
<Step3FileBrowseMode
consensusText={consensusText}
hasConsensus={hasConsensus}
consensusDocTitle={consensusDocTitle}
seatDeliveries={seatDeliveries}
artifactsBySeat={artifactsBySeat}
seatStubMessages={seatStubMessages}
openArtifactPreview={openArtifactPreview}
initialFile={fileSandboxTarget}
onBack={handleBackFromFileSandbox}
/>
);
}
if (drawMode) {
return (
<Step3ReportMode
@@ -320,28 +463,29 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
savedReport={savedReport}
onReportSaved={onReportSaved}
report={reportFlow}
dashboard={dashboardFlow}
onBack={() => setDrawMode(false)}
/>
);
}
return (
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto 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>
@@ -377,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" />
总结报告
@@ -394,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" />
刷新文件清单
@@ -402,179 +546,477 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
</div>
</div>
<div className="p-6 space-y-6">
{/* Section 1: Coordinator consensus conclusion */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<div className="mb-3 flex items-center justify-between gap-2">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest flex items-center gap-2 min-w-0">
<CheckCircle className="w-4 h-4 text-emerald-500 shrink-0" />
<span className="truncate">总控最终结论</span>
</h4>
<ConsensusDocxDownloadButton
markdown={consensusText}
title={consensusDocTitle}
className="shrink-0"
/>
</div>
{consensusText ? (
<div className="text-xs text-slate-700 leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText}
</ReactMarkdown>
</div>
) : (
<p className="text-[11px] text-slate-400 italic">
暂无总控结论。返回 Step 2 完成至少一轮研讨后再查看此页。
</p>
{/* 主体:左侧参与席位栏(像第二步) + 右侧滚动内容(2026-06-13 大改) */}
<div className="flex flex-1 min-h-0">
{/* ── 左侧参与席位栏:可收起成头像列;有交付内容的席位头像点亮 ── */}
<aside
className={cn(
"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",
)}
</section>
{/* Section 2: Per-seat deliveries (collapsible) */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<FileText className="w-4 h-4 text-primary" />
各席位最终交付
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{seatDeliveries.length}
</span>
</h4>
{seatDeliveries.length === 0 ? (
<p className="text-[11px] text-slate-400 italic">
本次研讨尚未产生子智能体交付。Leader 派活并收到子智能体回复后,该区块会自动填充。
</p>
) : (
<div className="space-y-2">
{seatDeliveries.map((seat, idx) => {
const key = `${seat.sender}-${idx}`;
const expanded = expandedSeats.has(key);
return (
<div key={key} className="border border-slate-150 rounded-xl overflow-hidden">
<button
type="button"
onClick={() => toggleSeat(key)}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
>
<div className="flex items-center gap-2 min-w-0">
{expanded ? (
<ChevronDown 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" />
)}
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span>
</div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{seat.content.length} 字
</span>
</button>
{expanded && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{seat.content || "(空)"}
</ReactMarkdown>
</div>
)}
</div>
);
})}
</div>
)}
</section>
{/* Section 3: Generated files per seat —— 点击「查看」进入沙箱分栏预览。
仅当存在「有产出文件 / 加载中 / 出错」的席位时才渲染;全部席位无文件则整块隐藏。 */}
{seatsWithFiles.length > 0 && (
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<Download className="w-4 h-4 text-primary" />
生成的报告文件
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{totalFileCount}
>
<div
className={cn(
"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 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 dark:bg-muted dark:text-muted-foreground">
{seatOverviews.length}
</span>
</span>
</h4>
)}
<button
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 dark:text-muted-foreground dark:hover:bg-accent dark:hover:text-foreground"
>
{sidebarCollapsed ? (
<ChevronsRight className="w-3.5 h-3.5" />
) : (
<ChevronsLeft className="w-3.5 h-3.5" />
)}
</button>
</div>
<div className="space-y-3">
{seatsWithFiles.map((seat) => (
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
<div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-2 py-2 space-y-1.5">
{/* 总控协调(顶置):点击 → 展开「总控最终结论」并滚动过去 */}
{sidebarCollapsed ? (
<button
type="button"
onClick={focusConsensus}
title={`总控协调${consensusText ? "(点击查看最终结论)" : "(暂无结论)"}`}
className="mx-auto block rounded-full transition hover:scale-105"
>
<span
className={cn(
"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",
)}
>
{getAvatar("human", "h-7 w-7")}
{!!consensusText && (
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-white bg-emerald-500" />
)}
</span>
</button>
) : (
<button
type="button"
onClick={focusConsensus}
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 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 dark:bg-secondary",
consensusText
? "border-emerald-400/70"
: "border-slate-200 opacity-45 grayscale",
)}
>
{getAvatar("human", "h-7 w-7")}
{!!consensusText && (
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-white bg-emerald-500" />
)}
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[11.5px] font-bold",
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 dark:text-muted-foreground">
{consensusText ? "最终结论" : "无结论"}
</span>
</span>
</button>
)}
{seatOverviews.map((seat) => {
const lit = !!seat.delivery?.trim();
return sidebarCollapsed ? (
// 收起态:只剩头像列;点头像同样展开右侧对应交付并滚过去。
<button
key={seat.key}
type="button"
onClick={() => focusSeat(seat.key)}
title={`${seat.displayName}${lit ? "(点击查看交付与文件)" : "(暂无交付)"}`}
className="mx-auto block rounded-full transition hover:scale-105"
>
<span
className={cn(
"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",
)}
>
{getAvatar(seat.avatarType, "h-7 w-7")}
{lit && (
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-white bg-emerald-500" />
)}
</span>
</button>
) : (
// 展开态:席位卡片(头像 + 名称 + 交付/文件徽标)。
<button
key={seat.key}
type="button"
onClick={() => focusSeat(seat.key)}
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 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 dark:bg-secondary",
lit
? "border-emerald-400/70"
: "border-slate-200 opacity-45 grayscale",
)}
>
{getAvatar(seat.avatarType, "h-7 w-7")}
{lit && (
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-white bg-emerald-500" />
)}
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[11.5px] font-bold",
lit ? "text-slate-800 dark:text-foreground" : "text-slate-400 dark:text-muted-foreground",
)}
>
{seat.displayName}
</span>
</div>
<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>
</span>
</button>
);
})}
{seatOverviews.length === 0 && !sidebarCollapsed && (
<p className="px-1 pt-2 text-[10px] italic text-slate-400 dark:text-muted-foreground">
暂无参与席位
</p>
)}
</div>
</aside>
{seat.loading ? (
<div className="flex items-center gap-2 px-3 py-3 text-[11px] text-slate-500">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
<span>正在查询 outputs 目录…</span>
</div>
) : seat.error ? (
<div className="px-3 py-3 text-[11px] text-rose-600">
读取失败:{seat.error}
</div>
) : seat.files.length === 0 ? (
<div className="px-3 py-3 text-[11px] text-slate-400 italic">
该席位未产生输出文件
{/* ── 右侧内容:总控结论 + 每席位一个区块(交付内容 + 报告文件卡片合并) ── */}
<div className="custom-scrollbar flex-1 min-h-0 overflow-y-auto p-6 space-y-5">
{/* Section 1: Coordinator consensus conclusion(可收起,带过渡动画) */}
<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 dark:bg-card dark:shadow-none",
focusedSeat === CONSENSUS_KEY
? "border-primary/50 ring-2 ring-primary/30"
: "border-slate-200 dark:border-border",
)}
>
<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)}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
title={consensusExpanded ? "收起结论" : "展开结论"}
>
{consensusExpanded ? (
<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 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 dark:text-foreground">
总控最终结论
</span>
</button>
<ConsensusDocxDownloadButton
markdown={consensusText}
title={consensusDocTitle}
className="shrink-0"
/>
</div>
<div
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-out",
consensusExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<div className="border-t border-slate-100 px-5 py-4 dark:border-border">
{consensusText ? (
<div className="text-xs text-slate-700 leading-relaxed dark:text-foreground/80">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText}
</ReactMarkdown>
</div>
) : (
<ul className="divide-y divide-slate-100">
{seat.files.map((f) => (
<li
key={f.path}
className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-slate-50/60"
>
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-medium text-slate-800 truncate" title={f.name}>
{f.name}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-slate-400">
{formatBytes(f.sizeBytes)}
</span>
<button
type="button"
onClick={() => handleViewInSandbox(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="在沙箱中查看"
>
<Eye className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleDownload(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="下载"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
</li>
))}
</ul>
<p className="text-[11px] text-slate-400 italic dark:text-muted-foreground">
暂无总控结论。返回 Step 2 完成至少一轮研讨后再查看此页。
</p>
)}
</div>
))}
</div>
</div>
</section>
)}
{/* 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">
如需重新立项,可清空当前研讨,回到任务理解阶段重新起草。
</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"
>
重新起草新规划
</button>
{/* 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)] dark:bg-card dark:border-border dark:shadow-none">
<p className="text-[11px] text-slate-400 italic dark:text-muted-foreground">
本次研讨尚未产生子智能体交付。Leader 派活并收到子智能体回复后,这里会按席位展示交付与产出文件。
</p>
</section>
) : (
seatOverviews.map((seat) => {
const expanded = expandedSeats.has(seat.key);
const lit = !!seat.delivery?.trim();
return (
<section
key={seat.key}
ref={(el) => {
if (el) seatSectionRefs.current.set(seat.key, el);
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 dark:bg-card dark:shadow-none",
focusedSeat === seat.key
? "border-primary/50 ring-2 ring-primary/30"
: "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 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 dark:text-muted-foreground" />
) : (
<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 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 dark:text-foreground">
{seat.displayName}
</span>
</div>
<span className="text-[10px] text-slate-400 font-mono shrink-0 dark:text-muted-foreground">
{lit ? `${seat.delivery!.length} 字` : "无交付"}
{seat.loading
? " · 文件加载中…"
: seat.files.length > 0
? ` · ${seat.files.length} 个文件`
: ""}
</span>
</button>
<div
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-out",
expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<div className="border-t border-slate-100 dark:border-border">
{/* 交付内容 */}
<div className="px-5 py-4 text-xs text-slate-700 leading-relaxed dark:text-foreground/80">
{lit ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{seat.delivery!}
</ReactMarkdown>
) : (
<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 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 dark:text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
<span>正在查询 outputs 目录…</span>
</div>
) : seat.error ? (
<div className="text-[11px] text-rose-600">读取失败:{seat.error}</div>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{seat.files.map((f) => (
<div
key={f.path}
role="button"
tabIndex={0}
onClick={() =>
seat.threadId && handleViewInSandbox(seat.threadId, f)
}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && seat.threadId) {
e.preventDefault();
handleViewInSandbox(seat.threadId, f);
}
}}
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 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">
<FileText className="w-3.5 h-3.5" />
</span>
<div className="min-w-0">
<span
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 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 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 dark:text-muted-foreground dark:hover:bg-accent"
title="下载"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
</section>
);
})
)}
{/* Footer */}
<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 dark:border-border dark:text-foreground dark:hover:bg-accent dark:hover:text-foreground"
>
重新起草新规划
</button>
</div>
</div>
{/* ── 内联文件沙箱(2026-06-13):点文件卡片从右侧滑入,左侧席位栏只收起不消失。
width 0↔46% 过渡 + 面板内容平移,关闭后 target 保留到动画结束。 ── */}
<div
className={cn(
"shrink-0 min-h-0 overflow-hidden transition-all duration-300 ease-in-out",
fileSandboxOpen && fileSandboxTarget ? "w-[46%]" : "w-0",
)}
>
{fileSandboxTarget && (
<div
className={cn(
"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 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 dark:text-muted-foreground dark:hover:bg-accent dark:hover:text-foreground"
title="关闭预览"
>
×
</button>
</div>
<iframe
title={fileSandboxTarget.file.name}
src={buildArtifactDownloadUrl(
fileSandboxTarget.threadId,
fileSandboxTarget.file.path,
)}
className="min-h-0 flex-1 w-full border-0 bg-white"
/>
</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 dark:border-border dark:bg-secondary dark:text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
正在读取文件内容…
</div>
) : (
<ThreadContext.Provider value={{ thread: fileSandboxThreadStub, isMock: false }}>
<ArtifactFileDetail
className="size-full"
filepath={fileSandboxVirtualUrl}
threadId={fileSandboxTarget.threadId}
onClose={handleCloseFileSandbox}
allowVirtualMarkdownEdit
/>
</ThreadContext.Provider>
)}
</div>
)}
</div>
</div>
</div>
);
};
@@ -2,7 +2,7 @@
* Step 2 主面板 —— 多智能体圆桌研讨。
*
* 包含 4 块:
* 1) 顶部 Headline + 推理模型下拉 + 挂起/恢复按钮
* 1) 顶部 Headline + 推理模型下拉 + 暂停/恢复按钮 + 后台挂起按钮(查看历史时隐藏)
* 2) Persona 抽屉(点击席位头像后展开,见 `PersonaDrawer.tsx`)
* 3) 对话流(初始化占位 + 气泡列表 + ChainOfThought 折叠卡 + markdown)
* 4) 底部澄清条 + 用户干预输入 + 「达成共识」入口
@@ -37,13 +37,6 @@ function formatFileSize(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
/** 后台挂起入口 —— 后端 _run_dag 已支持业务链分层并行,放出该入口。 */
const SHOW_BACKGROUND_SUSPEND = true;
@@ -65,7 +58,8 @@ import {
} from "@/roundtable-planning/components/MessageBubble";
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip";
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
import { DagFlowMiniPanel } from "@/roundtable-planning/components/DagFlowMiniPanel";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import {
@@ -155,6 +149,8 @@ export interface Step2PanelProps {
onProceedToStep3: () => void;
/** 「后台挂起」:把当前研讨转入后端自动跑到结果绘制完成。 */
onBackgroundSuspend: () => void;
/** 是否在「查看历史记录」(从草稿/后台作业加载)—— 为 true 时隐藏「后台挂起」按钮。 */
isViewingHistory?: boolean;
onTriggerToast: (msg: string) => void;
}
@@ -200,6 +196,7 @@ export function Step2Panel(props: Step2PanelProps) {
onSavePersona,
onProceedToStep3,
onBackgroundSuspend,
isViewingHistory,
onTriggerToast,
} = props;
@@ -275,8 +272,22 @@ export function Step2Panel(props: Step2PanelProps) {
}, [isParallelRunning, selectedParallelSeats, parallelBatch]);
// ─── Phase 7 DAG 流程图执行态总览(§7)──────────────────────────────────
const showDagFlow =
orchestrationMode === "dag" && (orchestrationPlan?.stages.length ?? 0) > 0;
// 优化7(2026-06-13):推荐模式总控判定的**并行批**也能看流程图——把当前并行批
// 构造成一个临时单 stage 计划喂给小窗(dag 模式仍用真实 orchestrationPlan)。
const effectivePlan = useMemo<OrchestrationPlan | null>(() => {
if (orchestrationMode === "dag" && (orchestrationPlan?.stages.length ?? 0) > 0) {
return orchestrationPlan;
}
if (isParallelRunning && parallelBatch.length > 1) {
return {
mode: "dag",
stages: [{ id: "recommend-parallel", agentIds: parallelBatch.map((s) => s.agentId) }],
finalSynthesis: true,
} as OrchestrationPlan;
}
return null;
}, [orchestrationMode, orchestrationPlan, isParallelRunning, parallelBatch]);
const showDagFlow = (effectivePlan?.stages.length ?? 0) > 0;
const resolveDagSeat = useCallback(
(agentId: string, order: number) => {
const meta = agentNameToRole[agentId.toLowerCase()];
@@ -287,9 +298,32 @@ export function Step2Panel(props: Step2PanelProps) {
},
[agentNameToRole],
);
// 总控 / 共识 / 报告里程碑状态:由节点状态 + 编排/共识标志派生。
// 有效节点状态(2026-06-13):live 编排时 dagNodeStates 实时推进;**查看聊天记录**
// (加载草稿 hydrate)时它是空的 → 从对话推导——已有「子智能体交付」内容的席位
// 点亮为 done,其余 pending。live 状态优先覆盖推导值(active 实时转圈不受影响)。
const effectiveDagNodeStates = useMemo(() => {
const merged: Record<string, ChainNodeState> = {};
const deliveredNames = new Set(
step2RoundtableDialogues
.filter(
(d) => d.confidence === "子智能体交付" && String(d.content ?? "").trim().length > 0,
)
.map((d) => String(d.sender ?? "")),
);
for (const [aid, meta] of Object.entries(agentNameToRole)) {
if (deliveredNames.has(meta.displayName)) merged[aid.toLowerCase()] = "done";
}
// 推荐并行批:running 席位点亮为 active、done 席位为 done(小窗实时转圈/打勾)。
for (const s of parallelBatch) {
merged[s.agentId.toLowerCase()] = s.status === "done" ? "done" : "active";
}
for (const [aid, st] of Object.entries(dagNodeStates)) merged[aid.toLowerCase()] = st;
return merged;
}, [dagNodeStates, step2RoundtableDialogues, agentNameToRole, parallelBatch]);
// 总控 / 共识 / 报告里程碑状态:由有效节点状态 + 编排/共识标志派生。
const { dagCoordinatorState, dagConsensusState } = useMemo(() => {
const states = Object.values(dagNodeStates);
const states = Object.values(effectiveDagNodeStates);
const anyActive = states.includes("active");
const anyStarted = states.some((s) => s !== "pending");
const allDone = states.length > 0 && states.every((s) => s === "done");
@@ -306,10 +340,32 @@ export function Step2Panel(props: Step2PanelProps) {
? "active"
: "pending";
return { dagCoordinatorState: coordinator, dagConsensusState: consensus };
}, [dagNodeStates, isOrchestrating, hasConsensus]);
}, [effectiveDagNodeStates, isOrchestrating, hasConsensus]);
// Phase 7:DAG 流程图弹窗开关(点按钮查看,不再内联占空间)。
// DAG 流程图小窗开关(2026-06-12:弹窗 → 消息列表右上角竖向小窗,直角连线)。
// DAG 编排一启动自动打开一次;沙箱打开时自动让位关闭;按钮可随时手动开/关。
const [dagFlowOpen, setDagFlowOpen] = useState(false);
const { open: artifactsOpenForFlow } = useArtifacts();
const dagFlowAutoOpenedRef = useRef(false);
useEffect(() => {
if (!showDagFlow) {
dagFlowAutoOpenedRef.current = false;
return;
}
if (!dagFlowAutoOpenedRef.current) {
dagFlowAutoOpenedRef.current = true;
// 沙箱已占着右侧就不抢(保持关闭,按钮可开)。
if (!artifactsOpenForFlow) setDagFlowOpen(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showDagFlow]);
// 沙箱打开(出现产物预览)→ 自动关闭小窗腾出注意力;**边沿触发**,
// 不会在沙箱保持打开期间反复压制用户手动重开的小窗。
const prevArtifactsOpenRef = useRef(artifactsOpenForFlow);
useEffect(() => {
if (artifactsOpenForFlow && !prevArtifactsOpenRef.current) setDagFlowOpen(false);
prevArtifactsOpenRef.current = artifactsOpenForFlow;
}, [artifactsOpenForFlow]);
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
const [pauseFlash, setPauseFlash] = useState(false);
@@ -409,6 +465,19 @@ export function Step2Panel(props: Step2PanelProps) {
暂停会商
</button>
)}
{/* 后台挂起:放在暂停/恢复按钮右侧。查看历史记录时隐藏(isViewingHistory),
重新从第一步进入第二步研讨时(isViewingHistory 为假)正常显示。 */}
{SHOW_BACKGROUND_SUSPEND && !isViewingHistory && (
<button
type="button"
onClick={onBackgroundSuspend}
title="把当前研讨转入后台自动跑到结果绘制完成;可离开页面,进度在历史记录里查看"
className="bg-slate-100 hover:bg-slate-200 text-slate-700 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-slate-200/60 shadow-sm dark:bg-secondary dark:text-foreground dark:border-border"
>
<Network className="w-3.5 h-3.5" />
后台挂起
</button>
)}
</div>
</div>
@@ -430,43 +499,37 @@ export function Step2Panel(props: Step2PanelProps) {
{/* Conversation log */}
<div className="relative flex-1 min-h-0 flex flex-col">
{/* Phase 7:dag 模式 —— 流程图改成「点按钮看弹窗」,不再内联占空间(§7)。 */}
{showDagFlow && orchestrationPlan && (
{/* 流程图小窗开关条(dag 编排 / 推荐并行批都显示)。 */}
{showDagFlow && effectivePlan && (
<div className="shrink-0 flex items-center justify-between gap-2 border-t border-slate-150 bg-slate-50/60 px-6 py-2">
<span className="text-xs font-medium text-slate-500">
分层编排执行中 · 共 {orchestrationPlan.stages.length} 个阶段
{orchestrationMode === "dag"
? `分层编排执行中 · 共 ${effectivePlan.stages.length} 个阶段`
: `并行执行中 · ${effectivePlan.stages[0]?.agentIds.length ?? 0} 个席位`}
</span>
<button
type="button"
onClick={() => setDagFlowOpen(true)}
onClick={() => setDagFlowOpen((v) => !v)}
className="inline-flex items-center gap-1.5 rounded-lg border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs font-semibold text-primary transition hover:bg-primary/10"
>
<Network className="size-3.5" />
查看编排流程图
{dagFlowOpen ? "收起流程图" : "编排流程图"}
</button>
</div>
)}
{showDagFlow && orchestrationPlan && (
<Dialog open={dagFlowOpen} onOpenChange={setDagFlowOpen}>
<DialogContent className="flex max-h-[88vh] w-[94vw] flex-col gap-0 sm:max-w-[1100px]">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<Network className="size-5 text-primary" />
分层编排流程图
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-auto">
<DagFlowChart
stages={orchestrationPlan.stages}
resolveSeat={resolveDagSeat}
seatStates={dagNodeStates}
coordinatorState={dagCoordinatorState}
consensusState={dagConsensusState}
showReportMilestone={false}
/>
</div>
</DialogContent>
</Dialog>
{/* 流程图小窗:消息列表右上角(开关条下方)悬浮,竖向直角连线流程图。
支持滚轮缩放/拖动平移/移动窗口/边缘缩放(组件内自管几何)。
沙箱打开时自动关闭(见上方互斥 effect),点按钮可随时重开。 */}
{showDagFlow && effectivePlan && dagFlowOpen && (
<DagFlowMiniPanel
stages={effectivePlan.stages}
resolveSeat={resolveDagSeat}
seatStates={effectiveDagNodeStates}
coordinatorState={dagCoordinatorState}
consensusState={dagConsensusState}
onClose={() => setDagFlowOpen(false)}
className="z-30"
/>
)}
{/* 并行批次头像条(§5.2):消息列表上方,点头像切换主区两列显示的席位。 */}
{isParallelRunning && (
@@ -718,21 +781,6 @@ export function Step2Panel(props: Step2PanelProps) {
干预并发送
</button>
{SHOW_BACKGROUND_SUSPEND && (
<>
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
<button
type="button"
onClick={onBackgroundSuspend}
title="把当前研讨转入后台自动跑到结果绘制完成;可离开页面,进度在历史记录里查看"
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-700 text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 cursor-pointer dark:bg-secondary dark:text-foreground dark:border-border"
>
后台挂起
</button>
</>
)}
<button
type="button"
onClick={onProceedToStep3}
@@ -115,6 +115,7 @@ export function Step2SandboxLayout({
filepath={selectedArtifact}
threadId={previewThreadId}
onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined}
allowVirtualMarkdownEdit
/>
) : (
<div className="relative flex size-full justify-center">
@@ -0,0 +1,576 @@
/**
* Step3「结果绘制 · 大屏多页」—— 两视图:
*
* ① 交互区 (view="chat"):左侧大屏绘制智能体的聊天记录 + 输入框(模型问答改页面),
* 右侧复用 `Step2SandboxLayout` 沙箱「代码预览」实时展示数据抽取过程(report-json
* 边出边以代码态显示)。生成完成自动切到 ②。
* ② 大屏页面 (view="page"):应用内直接渲染参考大屏 React 组件(`DashboardApp`,真实
* 数据经 `adaptReportData` 注入)。顶部按钮:重新绘制(回交互区并重画)、新窗口打开
* (把自包含 HTML 大屏在新标签页打开)、单页看板、返回报告。
*
* 数据抽取/聊天/沙箱链路全部复用既有 `useStep3Dashboard`(流式问答、stub 沙箱、续看),
* 只是把「完成后的产物」从 iframe HTML 预览换成应用内 React 大屏;HTML 沙箱那条路保留,
* 供「新窗口打开」与并行的经典模式使用。
*/
import {
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
} from "react";
import {
ArrowLeft,
Boxes,
CheckCircle,
Code2,
ExternalLink,
LayoutPanelTop,
Loader2,
Pause,
Pencil,
RefreshCw,
SendHorizontal,
Sparkles,
} from "lucide-react";
import { useLocalSettings } from "@/core/settings";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
import { adaptReportData } from "@/roundtable-planning/dashboard/data-context";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { DASHBOARD_VIEW_STORAGE_PREFIX } from "@/roundtable-planning/pages/DashboardStandalonePage";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
import {
parseReportJson,
renderDashboardHtml,
stripReportFence,
type DashboardTheme,
} from "@/roundtable-planning/lib/report-template";
import { useTheme } from "@/contexts/ThemeContext";
interface Step3DashboardModeProps {
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
savedReport?: DraftStep3Snapshot | null;
dashboard: UseStep3DashboardReturn;
/** 切回经典「单页看板」绘制模式。 */
onSwitchClassic: () => void;
/** 返回最终方案展示页。 */
onBack: () => void;
}
function formatNow(): string {
const d = new Date();
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
type DashView = "chat" | "page";
type RenderMode = "app" | "html";
export function Step3DashboardMode({
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
savedReport,
dashboard,
onSwitchClassic,
onBack,
}: Step3DashboardModeProps) {
const {
phase,
dialogues,
seatStubMessages,
activeArtifactThreadId,
html,
busy,
redraw,
ask,
abort,
setPreviewEnabled,
openDashboardPreview,
} = dashboard;
// 真实数据(report-json):优先本轮实时生成,其次草稿快照。应用渲染/网页渲染共用。
const report = useMemo(
() => dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null),
[dashboard.data, savedReport?.dashboardJson],
);
// 应用渲染用的大屏数据(不传则 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"));
// 大屏页面的两种渲染:
// - "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 (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(() => {
setPreviewEnabled(view === "chat");
return () => setPreviewEnabled(false);
}, [view, setPreviewEnabled]);
// 新窗口打开:跟随当前渲染方式——
// - 网页渲染:把自包含 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 堆积;再写本次数据。
for (let i = window.localStorage.length - 1; i >= 0; i -= 1) {
const k = window.localStorage.key(i);
if (k && k.startsWith(DASHBOARD_VIEW_STORAGE_PREFIX)) window.localStorage.removeItem(k);
}
if (dashboardData) window.localStorage.setItem(key, JSON.stringify(dashboardData));
} catch {
// localStorage 不可用(隐私模式/超额):忽略,新窗口将回落 DEMO 数据。
}
// HashRouter:在当前页基址上拼 hash 路由,带上数据键。
const base = `${window.location.origin}${window.location.pathname}`;
const url = `${base}#/roundtable-dashboard?k=${encodeURIComponent(key)}`;
window.open(url, "_blank", "noopener,noreferrer");
};
// 「编辑」:回到交互区接着上次的聊天记录继续问答修改(不丢弃上下文、不重画)。
const handleEdit = () => {
setView("chat");
};
const isStreaming =
phase === "creating" || phase === "streaming" || dialogues.some((d) => d.streaming === true);
return (
<div className="flex h-full min-h-0 flex-col text-slate-800">
{/* 顶栏 */}
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3 flex-wrap">
<div className="flex items-center gap-2.5">
<Sparkles className="w-4.5 h-4.5 text-primary" />
<h3 className="text-sm font-black text-slate-900 tracking-tight">结果绘制 · 大屏多页</h3>
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
{(phase === "creating" || phase === "streaming") && <Loader2 className="w-3 h-3 animate-spin" />}
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
{view === "chat" ? "交互绘制中" : "大屏页面"}
</span>
</div>
<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={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"
>
<Pencil className="w-3.5 h-3.5 text-slate-500" />
编辑
</button>
<button
type="button"
onClick={handleOpenNewWindow}
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" />
新窗口打开
</button>
</>
) : (
hasResult && (
<button
type="button"
onClick={() => setView("page")}
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"
>
<LayoutPanelTop className="w-3.5 h-3.5 text-slate-500" />
查看大屏
</button>
)
)}
<button
type="button"
onClick={onSwitchClassic}
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"
>
<Sparkles className="w-3.5 h-3.5 text-slate-500" />
单页看板
</button>
<button
type="button"
onClick={onBack}
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"
>
<ArrowLeft className="w-3.5 h-3.5 text-slate-500" />
返回报告
</button>
</div>
</div>
{/* 主体:交互区(聊天 + 抽取沙箱) / 大屏页面(React) */}
{view === "page" ? (
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">
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId}
isStreaming={isStreaming}
seatMessages={activeArtifactThreadId ? seatStubMessages[activeArtifactThreadId] : undefined}
>
<DashboardChatPanel
phase={phase}
dialogues={dialogues}
busy={busy}
error={dashboard.error}
onAsk={ask}
onRedraw={redraw}
onPause={abort}
onOpenPreview={openDashboardPreview}
hasResult={hasResult}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
/>
</Step2SandboxLayout>
</section>
</div>
)}
</div>
);
}
// ── 交互区聊天面板(沙箱的左 60%;右 40% 是抽取过程代码预览) ──────────────────────────
function DashboardChatPanel({
phase,
dialogues,
busy,
error,
onAsk,
onRedraw,
onPause,
onOpenPreview,
hasResult,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
}: {
phase: UseStep3DashboardReturn["phase"];
dialogues: UseStep3DashboardReturn["dialogues"];
busy: boolean;
error: string | null;
onAsk: (message: string) => void;
onRedraw: () => void;
onPause: () => void;
onOpenPreview: () => void;
hasResult: boolean;
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
}) {
const inputLocked = phase === "creating";
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
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();
if (!t || inputLocked) return;
setText("");
onAsk(t);
};
return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">
{/* 面板头:智能体名 + 模型选择 + 暂停/重画 */}
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
📊
</div>
<div className="leading-tight min-w-0">
<div className="text-xs font-bold text-slate-800 truncate">大屏绘制智能体</div>
<div className="text-[10px] text-slate-400 truncate">提炼研讨成果为大屏结构化数据(右侧实时显示抽取过程)</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<select
id="step3-dashboard-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={busy || modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: busy
? "绘制中不可切换;完成后或重新绘制时生效"
: "切换绘制使用的推理模型(下次绘制生效)"
}
className="max-w-[160px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name ? `${m.display_name} (${m.name})` : m.name}
</option>
))}
</select>
{busy ? (
<button
type="button"
onClick={onPause}
title="中止当前绘制"
className="bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<Pause className="w-3.5 h-3.5" />
中止
</button>
) : (
<button
type="button"
onClick={onRedraw}
title="丢弃上下文,从头重新绘制"
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新绘制
</button>
)}
</div>
</div>
{/* 聊天记录 */}
<div className="relative flex-1 min-h-0 flex flex-col">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4 bg-white">
{phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
正在初始化绘制线程…
</div>
)}
{displayDialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
/>
))}
{busy && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" />
右侧沙箱实时显示数据抽取过程,完成后自动切到大屏页面
</div>
)}
{phase === "error" && (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
<div className="font-bold mb-1">绘制失败</div>
<div className="leading-relaxed break-all">{error}</div>
<button
type="button"
onClick={onRedraw}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-rose-700 underline hover:text-rose-800"
>
<RefreshCw className="w-3 h-3" />
重试
</button>
</div>
)}
{phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
数据已就绪,已切换到大屏页面。
{hasResult && (
<button
type="button"
onClick={onOpenPreview}
className="ml-1 inline-flex items-center gap-1 text-[11px] font-bold text-primary underline hover:opacity-80"
>
在沙箱打开 HTML 预览
</button>
)}
</div>
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
{/* 问答修改输入框 */}
<form onSubmit={handleSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
disabled={inputLocked}
rows={1}
placeholder={
busy
? "随时输入修改要求——发送会打断当前绘制并按新要求重出数据"
: "输入修改要求,例如:把风险分布换成柱状图,新增一个执行路线席位"
}
className="min-h-8 max-h-28 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="submit"
disabled={inputLocked || text.trim().length === 0}
title={inputLocked ? "正在初始化线程,请稍候" : "发送修改要求"}
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
>
{inputLocked ? <Loader2 className="h-4 w-4 animate-spin" /> : <SendHorizontal className="h-4 w-4" />}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -14,8 +14,11 @@ import type {
} from "@/roundtable-planning/api/drafts";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
import type { ReportIntent, UseStep3ReportReturn } from "@/roundtable-planning/hooks/useStep3Report";
import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import { stripReasoning } from "@/roundtable-planning/lib/reasoning";
export interface Step3PanelProps {
threadIds: ThreadIdMap | null;
@@ -42,6 +45,10 @@ export interface Step3PanelProps {
/** 草稿里已存的上次总结结果 + 完成回调 —— 透传给「总结报告」做持久化。 */
savedSummary?: DraftStep3SummarySnapshot | null;
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
/** 页面层常驻的三个 Step3 生成流(2026-06-13 提升:切走不断流,进入续看)。 */
summaryFlow: UseStep3SummaryReturn;
reportFlow: UseStep3ReportReturn;
dashboardFlow: UseStep3DashboardReturn;
onReset: () => void;
/** Step 2 沙箱预览产出文件(与第二步相同)。 */
openArtifactPreview: (threadId: string, path: string) => void;
@@ -66,6 +73,9 @@ export function Step3Panel(props: Step3PanelProps) {
coordinatorPrompt,
savedSummary,
onSummarySaved,
summaryFlow,
reportFlow,
dashboardFlow,
onReset,
openArtifactPreview,
seatStubMessages,
@@ -76,7 +86,9 @@ export function Step3Panel(props: Step3PanelProps) {
.map((d) => ({
sender: String(d.sender ?? ""),
avatarType: String(d.avatarType ?? "blue"),
content: String(d.content ?? ""),
// 交付内容只保留模型生成的主体输出,剥离 `<think>...</think>` 推理过程
// —— 既用于渲染各席位交付卡片,也会喂给总结/报告模型,避免推理噪声污染。
content: stripReasoning(String(d.content ?? "")),
}));
const seatNameMap = Object.fromEntries(
@@ -101,6 +113,9 @@ export function Step3Panel(props: Step3PanelProps) {
coordinatorPrompt={coordinatorPrompt}
savedSummary={savedSummary}
onSummarySaved={onSummarySaved}
summaryFlow={summaryFlow}
reportFlow={reportFlow}
dashboardFlow={dashboardFlow}
onReset={onReset}
openArtifactPreview={openArtifactPreview}
seatStubMessages={seatStubMessages}
@@ -1,4 +1,4 @@
/**
/**
* Step 3「结果绘制」模式。
*
* 布局对齐第 2 步:
@@ -18,6 +18,7 @@ import {
Download,
Eye,
FileText,
LayoutDashboard,
ListOrdered,
Loader2,
PanelLeftClose,
@@ -45,11 +46,14 @@ import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { Step3DashboardMode } from "@/roundtable-planning/components/Step3DashboardMode";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import {
useStep3Report,
type ReportIntent,
import { isDashboardHtml } from "@/roundtable-planning/lib/report-template";
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
import type {
ReportIntent,
UseStep3ReportReturn,
} from "@/roundtable-planning/hooks/useStep3Report";
interface SeatDelivery {
@@ -77,8 +81,10 @@ interface Step3ReportModeProps {
setSelectedModel: (model: string) => void;
/** 草稿里已存的上次绘制结果 —— 有则进入时恢复展示,不重新绘制。 */
savedReport?: DraftStep3Snapshot | null;
/** 一次绘制完成时回调,把结果交给上层存进草稿 step3。 */
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
/** 页面层常驻的经典绘制流(2026-06-13 提升:切走不断流,进入续看)。 */
report: UseStep3ReportReturn;
/** 页面层常驻的大屏多页流(模式二)。 */
dashboard: UseStep3DashboardReturn;
onBack: () => void;
}
@@ -97,7 +103,8 @@ export function Step3ReportMode({
modelsLoading,
setSelectedModel,
savedReport,
onReportSaved,
report,
dashboard,
onBack,
}: Step3ReportModeProps) {
const reportSeatDeliveries = useMemo(
@@ -112,6 +119,7 @@ export function Step3ReportMode({
return `圆桌总控结论-${short}`;
}, [intentReady?.objective]);
// 生成流来自页面层常驻 hook(切走不断流,进入本视图续看进行中过程/结果)。
const {
phase,
error,
@@ -125,17 +133,9 @@ export function Step3ReportMode({
pause,
interject,
themeMismatch,
abort,
openArtifactFromStep,
openArtifactPreview,
} = useStep3Report({
intentReady,
seatDeliveries: reportSeatDeliveries,
consensusText,
selectedModel,
savedReport,
onReportSaved,
});
} = report;
const [modal, setModal] = useState<SourceModal>(null);
@@ -151,18 +151,31 @@ export function Step3ReportMode({
}
}, [artifactsOpen]);
// 进入即自动开跑;卸载(含返回普通视图)中断在途请求。
// 不加"永久闸门"——否则 StrictMode 的 mount→unmount→remount 会在 cleanup 里 abort
// 掉首次 run,remount 又因闸门跳过重启,导致永远不绘制。run() 自带 runningRef 去重,
// remount 重新 start() 即可(开发期至多多建一个空线程,生产无 StrictMode 只跑一次)。
// 若草稿里已存上次绘制结果(savedReport)→ 直接恢复展示,不重新跑模型;用户可点「重新绘制」覆盖。
// ── 绘制模式(2026-06-13):classic = 经典单页看板(模型自由写整页 HTML);
// dashboard = 大屏多页(模型只出 report-json,模板渲染)。两条流现在**并行常驻**
// (页面进入第三步即三流齐跑),进入本页默认落经典视图;仅旧版草稿「只有大屏 HTML
// 存在 html 槽位(带 marker)」时直接落大屏。
const [dashboardMode, setDashboardMode] = useState(() => isDashboardHtml(savedReport?.html));
// 预览开关:经典绘制视图在前台(非大屏模式)时本流才允许自动打开沙箱(并行流防抢);
// 进入时若已有产物/进行中会立即接管沙箱续看。
useEffect(() => {
if (savedReport?.html) {
if (dashboardMode) return;
report.setPreviewEnabled(true);
return () => report.setPreviewEnabled(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardMode]);
// 续看语义(2026-06-13):hook 常驻页面层,进入第三步时页面已统一编排(总结 +
// 经典绘制并行启动)。这里只兜底"从未启动过"(idle)的场景,**卸载不再 abort**——
// 切回总结报告 / 回第二步,绘制流照跑,回头继续看。
useEffect(() => {
if (dashboardMode || phase !== "idle") return;
if (savedReport?.html && !isDashboardHtml(savedReport.html)) {
hydrateSaved(savedReport);
} else {
start();
}
return () => abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -188,6 +201,33 @@ export function Step3ReportMode({
? "已暂停"
: "待绘制";
// ── 模式二:大屏多页(模板渲染)。经典模式的所有 hooks 已在上方调用完毕,
// 这里早退渲染不违反 hooks 规则。 ──
if (dashboardMode) {
return (
<Step3DashboardMode
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
savedReport={savedReport}
dashboard={dashboard}
onSwitchClassic={() => {
// 切回经典:**不打断**大屏流(切走不断流);经典从未启动则补一次。
setDashboardMode(false);
if (phase === "idle") {
if (savedReport?.html && !isDashboardHtml(savedReport.html)) {
hydrateSaved(savedReport);
} else {
start();
}
}
}}
onBack={onBack}
/>
);
}
return (
<div className="flex h-full min-h-0 flex-col text-slate-800">
{/* 顶栏 */}
@@ -224,6 +264,15 @@ export function Step3ReportMode({
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新绘制
</button>
<button
type="button"
onClick={() => setDashboardMode(true)}
title="大屏多页模式:模型只输出结构化数据、模板渲染总览页 + 每席位一页(更快,支持席位间跳转)。经典绘制若在进行中不会被打断,切回可续看。"
className="bg-primary text-white hover:bg-[var(--brand-900)] text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition shadow-sm shadow-primary/10"
>
<LayoutDashboard className="w-3.5 h-3.5" />
大屏多页
</button>
<button
type="button"
onClick={onBack}
@@ -463,14 +512,14 @@ function AgentProgressPanel({
modelsLoading,
setSelectedModel,
}: {
phase: ReturnType<typeof useStep3Report>["phase"];
dialogues: ReturnType<typeof useStep3Report>["dialogues"];
phase: UseStep3ReportReturn["phase"];
dialogues: UseStep3ReportReturn["dialogues"];
error: string | null;
themeMismatch: boolean;
onRetry: () => void;
onRevisionSubmit: (revisionInstruction: string) => void;
onArtifactStepClick: ReturnType<typeof useStep3Report>["openArtifactFromStep"];
onArtifactPreview: ReturnType<typeof useStep3Report>["openArtifactPreview"];
onArtifactStepClick: UseStep3ReportReturn["openArtifactFromStep"];
onArtifactPreview: UseStep3ReportReturn["openArtifactPreview"];
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
@@ -1,4 +1,4 @@
/**
/**
* Step 3「总结报告」模式 —— 第 3 步**主页**(进第三步默认自动跑这个)。
*
* 由内置的「方案总结报告智能体」(`roundtable-summary`) 把各席位交付 + 总控共识
@@ -56,7 +56,7 @@ import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMo
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
import { useStep3Summary, type UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
interface SeatDelivery {
@@ -81,8 +81,8 @@ interface Step3SummaryModeProps {
coordinatorPrompt?: string | null;
/** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */
savedSummary?: DraftStep3SummarySnapshot | null;
/** 一次总结完成时回调,把结果交给上层存进草稿 step3.summaryReport。 */
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
/** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */
summary: UseStep3SummaryReturn;
/** 切到「最终方案展示」静态页(次要入口)。 */
onViewStatic: () => void;
}
@@ -103,13 +103,10 @@ export function Step3SummaryMode({
setSelectedModel,
coordinatorPrompt,
savedSummary,
onSummarySaved,
summary,
onViewStatic,
}: Step3SummaryModeProps) {
const summarySeatDeliveries = useMemo(
() => seatDeliveries.map((d) => ({ sender: d.sender, content: d.content })),
[seatDeliveries],
);
void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容
const consensusDocTitle = useMemo(() => {
const objective = intentReady?.objective?.trim();
@@ -118,6 +115,7 @@ export function Step3SummaryMode({
return `圆桌总控结论-${short}`;
}, [intentReady?.objective]);
// 生成流来自页面层常驻 hook(切走不断流,进入本视图续看进行中过程/结果)。
const {
phase,
error,
@@ -131,18 +129,9 @@ export function Step3SummaryMode({
redraw,
ask,
pause,
abort,
openArtifactFromStep,
openArtifactPreview,
} = useStep3Summary({
intentReady,
seatDeliveries: summarySeatDeliveries,
consensusText,
selectedModel,
coordinatorPrompt,
savedSummary,
onSummarySaved,
});
} = summary;
const [modal, setModal] = useState<SourceModal>(null);
@@ -224,15 +213,24 @@ export function Step3SummaryMode({
}
}, [rightPanelVisible]);
// 进入即自动开跑;草稿里已存结果则恢复展示(流程图模式的快照可能只有 closing 没有 md)。
// 卸载中断在途请求(理由同 Step3ReportMode)。
// 预览开关:本视图在前台时本流才允许自动打开沙箱(并行流防抢);进入时若已有
// 产物/进行中会立即接管沙箱续看。
useEffect(() => {
if (savedSummary && (savedSummary.md.trim() || savedSummary.closing?.trim())) {
summary.setPreviewEnabled(true);
return () => summary.setPreviewEnabled(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 续看语义(2026-06-13):hook 常驻页面层,页面在进入第三步时已统一编排启动。
// 这里只做"从未启动过"的兜底(idle 才动手),**卸载不再 abort** —— 用户切去
// 结果绘制 / 回第二步,总结流照跑,回头继续看进行中过程或结果。
useEffect(() => {
if (phase !== "idle") return;
if (savedSummary && (savedSummary.md.trim() || (savedSummary.closing ?? "").trim())) {
hydrateSaved(savedSummary);
} else {
start();
}
return () => abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -557,8 +555,8 @@ function AgentProgressPanel({
modelsLoading,
setSelectedModel,
}: {
phase: ReturnType<typeof useStep3Summary>["phase"];
dialogues: ReturnType<typeof useStep3Summary>["dialogues"];
phase: UseStep3SummaryReturn["phase"];
dialogues: UseStep3SummaryReturn["dialogues"];
error: string | null;
busy: boolean;
/** 沙箱里是否已有 md 报告 —— 决定提示语(纯流程图轮没有 .md 可预览/下载)。 */
@@ -566,8 +564,8 @@ function AgentProgressPanel({
onRetry: () => void;
onAsk: (message: string, files?: File[]) => void;
onPause: () => void;
onArtifactStepClick: ReturnType<typeof useStep3Summary>["openArtifactFromStep"];
onArtifactPreview: ReturnType<typeof useStep3Summary>["openArtifactPreview"];
onArtifactStepClick: UseStep3SummaryReturn["openArtifactFromStep"];
onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"];
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
@@ -0,0 +1,170 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*
* 大屏主视图(源自参考 App.tsx)。2026-06-13 改造:
* - 接受 `data` prop(圆桌真实数据,adaptReportData 产出),用 DashboardDataProvider 注入;
* - 标题/副标题/导航 Tab 由数据动态生成(原为写死的 6 个固定席位);
* - 不传 data 时回落参考 demo(DEMO_DATA)。
*/
import './dashboard.css';
import { useEffect, useState } from 'react';
import { TabType } from './types';
import {
DashboardDataProvider,
DEMO_DATA,
type DashboardDataValue,
} from './data-context';
import { MetricsHeader } from './components/MetricsHeader';
import { ConsensusPanel } from './components/ConsensusPanel';
import { BrandScores } from './components/BrandScores';
import { RiskDonut } from './components/RiskDonut';
import { SeatDeliveryOverview } from './components/SeatDeliveryOverview';
import { Roadmap } from './components/Roadmap';
import { AnalyticsCenter } from './components/AnalyticsCenter';
import { DetailView } from './components/DetailView';
import { motion, AnimatePresence } from 'motion/react';
import {
Compass, LayoutGrid, Users, Megaphone, ShieldAlert, FileText,
Clock, Activity,
} from 'lucide-react';
const TAB_ICONS = [LayoutGrid, Users, Megaphone, ShieldAlert, FileText, Activity];
export function DashboardApp({ data = DEMO_DATA }: { data?: DashboardDataValue }) {
const [activeTab, setActiveTab] = useState<TabType>('overview');
// 数据切换(切历史记录/重绘 → data 引用变化)时复位到总览:避免停留在上一份数据
// 钻入的某个席位详情页,而新数据里无同名席位导致「加载席位数据失败」。
useEffect(() => {
setActiveTab('overview');
}, [data]);
// 导航 Tab:总览 + 各席位(由数据动态生成,原参考为写死的固定 6 项)。
const tabs: TabType[] = ['overview', ...data.SEAT_CARDS.map((s) => s.id)];
const getTabLabel = (tab: TabType) =>
tab === 'overview' ? '总览' : data.SEAT_CARDS.find((c) => c.id === tab)?.title ?? tab;
const getTabIcon = (tab: TabType) => {
const cls = 'w-4 h-4';
if (tab === 'overview') return <Compass className={cls} />;
const idx = data.SEAT_CARDS.findIndex((c) => c.id === tab);
const Ico = TAB_ICONS[(idx >= 0 ? idx : 0) % TAB_ICONS.length];
return <Ico className={cls} />;
};
return (
<DashboardDataProvider value={data}>
<div className="dash-root min-h-screen bg-white dark:bg-[var(--dash-page)] font-sans text-slate-800 dark:text-[var(--dash-text)] relative pb-16 [background-image:radial-gradient(#f1f5f9_1.2px,transparent_1.2px)] dark:[background-image:radial-gradient(#1a2f5a_1.2px,transparent_1.2px)] [background-size:20px_20px]">
{/* 顶部彩条 */}
<div className="h-1 bg-gradient-to-r from-rose-600 via-amber-500 to-sky-600 dark:from-[var(--dash-primary)] dark:via-[var(--dash-accent)] dark:to-[#4096ff] w-full" />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-5 space-y-6">
{/* Header */}
<header className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 pb-4 border-b border-rose-100 dark:border-[var(--dash-border)]">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-xl sm:text-2xl font-black tracking-tight font-display flex items-center gap-1.5 text-rose-900 dark:text-[var(--dash-text-strong)]">
{data.meta.title}
</h1>
</div>
{data.meta.subtitle && (
<p className="text-xs sm:text-sm text-slate-500 dark:text-[var(--dash-text-sec)] font-sans tracking-wide">
{data.meta.subtitle}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2.5 self-start lg:self-center text-xs text-slate-500 dark:text-[var(--dash-text-sec)]">
<div className="flex items-center gap-1.5 font-mono px-3 py-1.5 bg-white dark:bg-[var(--dash-card)] border border-slate-200 dark:border-[var(--dash-border)] shadow-sm rounded-sm">
<Clock className="w-3.5 h-3.5 text-rose-600 dark:text-[var(--dash-primary)] animate-pulse" />
<span>生成于 {data.meta.generatedAt}</span>
</div>
</div>
</header>
{/* 导航 Tab */}
<nav className="flex items-center justify-start overflow-x-auto pb-1 -mx-4 px-4 sm:mx-0 sm:px-0">
<div className="flex space-x-1.5 bg-slate-100/80 dark:bg-[var(--dash-elev)] backdrop-blur-xs p-1 border border-slate-200 dark:border-[var(--dash-border)] rounded-md shrink-0">
{tabs.map((tab) => {
const active = activeTab === tab;
return (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-sm transition-all duration-200 cursor-pointer whitespace-nowrap ${
active
? 'bg-white dark:bg-[var(--dash-card)] text-rose-700 dark:text-[var(--dash-primary)] shadow-sm border border-rose-100 dark:border-[var(--dash-primary)] font-bold scale-[1.01]'
: 'text-slate-600 dark:text-[var(--dash-text-sec)] hover:text-slate-900 dark:hover:text-[var(--dash-text-strong)] hover:bg-slate-50 dark:hover:bg-[var(--dash-card)]'
}`}
>
{getTabIcon(tab)}
<span>{getTabLabel(tab)}</span>
</button>
);
})}
</div>
</nav>
{/* Body */}
<AnimatePresence mode="wait">
{activeTab === 'overview' ? (
<motion.div
key="overview-dashboard"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.25 }}
className="space-y-6"
>
<MetricsHeader />
{/* 上半部分:总控最终共识 + 多维核心竞争力排行(核心风险分布下移到数据研判中心之后) */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5">
<div className="lg:col-span-7 h-full">
<ConsensusPanel onExploreClick={() => setActiveTab(tabs[tabs.length - 1] ?? 'overview')} />
</div>
<div className="lg:col-span-5 h-full">
<BrandScores />
</div>
</div>
<AnalyticsCenter />
{/* 核心风险分类分布:移到数据研判中心下方,整行展示 */}
<RiskDonut />
<SeatDeliveryOverview onSeatSelect={setActiveTab} activeSeatId={activeTab} />
<Roadmap />
</motion.div>
) : (
<motion.div
key="seat-details"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.25 }}
>
<DetailView
activeTab={activeTab}
onBackToOverview={() => setActiveTab('overview')}
onNavigate={setActiveTab}
/>
</motion.div>
)}
</AnimatePresence>
<footer className="pt-8 border-t border-slate-200/60 dark:border-[var(--dash-border)] mt-12 text-center text-xs text-slate-400 dark:text-[var(--dash-text-dim)] font-sans flex flex-col sm:flex-row items-center justify-between gap-4">
<span>圆桌多智能体研讨 · 方案大屏</span>
<div className="flex gap-4 font-mono text-[10px] tracking-wider uppercase">
<span>Engineering Grid: True</span>
<span>Roundtable Console</span>
</div>
</footer>
</div>
</div>
</DashboardDataProvider>
);
}
@@ -0,0 +1,291 @@
/**
* 数据研判中心 —— 读 context.CHARTS(最多 3 个),按 type 通用渲染(line/bar/donut/radar)。
* 参考项目原是写死的 3 张新能源图表;这里改成数据驱动,SVG 数学沿用参考/report-template
* 的画法(折线带渐变面积、环形分段、雷达多边形、水平条),雷达/环形的标签走 HTML 旁注
* 避免 SVG 文字溢出。
*/
import { useState } from "react";
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";
// ── 折线(趋势):单序列,归一化到画布 ──────────────────────────────────────────────
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;
const padL = 36;
const padR = 16;
const padT = 16;
const padB = 30;
const data = items.slice(0, 10);
const max = Math.max(...data.map((i) => i.value), 1);
const min = Math.min(...data.map((i) => i.value), 0);
const span = max - min || 1;
const x = (i: number) => padL + (i / Math.max(1, data.length - 1)) * (W - padL - padR);
const y = (v: number) => padT + (1 - (v - min) / span) * (H - padT - padB);
const pts = data.map((it, i) => `${x(i).toFixed(1)},${y(it.value).toFixed(1)}`).join(" ");
return (
<div className="relative w-full h-[180px] bg-slate-50/30 dark:bg-[var(--dash-inset)] rounded border border-slate-100 dark:border-[var(--dash-border)] p-1 flex items-center justify-center">
<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={pal.gridFaint} strokeWidth="1.2" strokeDasharray="2 2" />;
})}
<polygon
points={`${padL},${(H - padB).toFixed(1)} ${pts} ${(W - padR).toFixed(1)},${(H - padB).toFixed(1)}`}
fill={`${PRIMARY}14`}
/>
<polyline points={pts} fill="none" stroke={PRIMARY} strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
{data.map((it, i) => {
const isH = hover === i;
return (
<g key={i}>
{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={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" fill={isH ? pal.axisStrong : pal.axisText} className="text-[8.5px] font-mono">
{it.label}
</text>
</g>
);
})}
</svg>
<AnimatePresence>
{hover !== null && (
<motion.div
initial={{ opacity: 0, scale: 0.93, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.93, y: 10 }}
className="absolute bg-slate-950/90 text-white rounded p-2 text-[10px] space-y-1 z-20 pointer-events-none border border-slate-700 shadow-lg left-1/2 -translate-x-1/2 bottom-12"
>
<div className="font-bold text-amber-400 border-b border-slate-800 pb-1">{data[hover].label}</div>
<div className="font-mono font-bold text-sky-400">{data[hover].value}</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// ── 环形(占比) ───────────────────────────────────────────────────────────────────
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;
const R = 50;
const SW = 14;
const C = 2 * Math.PI * R;
let acc = 0;
const segs = data.map((it, idx) => {
const len = (Math.max(0, it.value) / total) * C;
const seg = { len, offset: -acc, color: pal.accents[idx % pal.accents.length], idx };
acc += len;
return seg;
});
return (
<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={pal.grid} strokeWidth={SW} transform="rotate(-90 70 70)" />
{segs.map((s) => (
<circle
key={s.idx}
cx="70"
cy="70"
r={R}
fill="none"
stroke={s.color}
strokeWidth={hover === s.idx ? SW + 3 : SW}
strokeDasharray={`${s.len.toFixed(2)} ${(C - s.len).toFixed(2)}`}
strokeDashoffset={s.offset.toFixed(2)}
transform="rotate(-90 70 70)"
className="cursor-pointer transition-all duration-300"
onMouseEnter={() => setHover(s.idx)}
onMouseLeave={() => setHover(null)}
/>
))}
<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" fill={pal.axisText} className="text-[9px]">
类
</text>
</svg>
</div>
<div className="flex-1 space-y-2 max-h-[170px] overflow-y-auto pr-1">
{data.map((item, idx) => (
<div
key={item.label}
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 ? 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: 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>
</div>
))}
</div>
</div>
);
}
// ── 雷达(多维):标签 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;
const cy = 100;
const R = 65;
const max = Math.max(...data.map((i) => i.value), 1);
const pt = (i: number, r: number): [number, number] => {
const a = (Math.PI * 2 * i) / n - Math.PI / 2;
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
};
const ptS = (i: number, r: number) => pt(i, r).map((v) => v.toFixed(1)).join(",");
return (
<div className="flex items-center gap-2">
<div className="relative w-40 h-40 flex items-center justify-center p-1 bg-slate-50/40 dark:bg-[var(--dash-inset)] rounded border border-slate-100 dark:border-[var(--dash-border)] shrink-0">
<svg viewBox="0 0 200 200" className="w-40 h-40">
{[0.25, 0.5, 0.75, 1].map((k, i) => (
<polygon
key={i}
points={Array.from({ length: n }, (_, idx) => ptS(idx, R * k)).join(" ")}
fill="none"
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={pal.grid} strokeWidth="1" />;
})}
<polygon
points={data.map((it, i) => ptS(i, (Math.max(0, it.value) / max) * R)).join(" ")}
fill={`${PRIMARY}26`}
stroke={PRIMARY}
strokeWidth="2"
/>
{data.map((it, i) => {
const [px, py] = pt(i, (Math.max(0, it.value) / max) * R);
return <circle key={i} cx={px.toFixed(1)} cy={py.toFixed(1)} r="2.6" fill={PRIMARY} />;
})}
</svg>
</div>
<div className="flex-1 space-y-1 text-[10px]">
{data.map((it, i) => (
<div key={i} className="flex justify-between items-center gap-1 border-b border-slate-50 dark:border-[var(--dash-border)] pb-0.5">
<span className="text-slate-500 dark:text-[var(--dash-text-sec)] truncate">{it.label}</span>
<b className="font-mono text-slate-800 dark:text-[var(--dash-text)]">{it.value}</b>
</div>
))}
</div>
</div>
);
}
// ── 水平条(排名) ──────────────────────────────────────────────────────────────────
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 (
<div className="space-y-2.5 py-1">
{data.map((it, i) => (
<div key={i} className="space-y-1">
<div className="flex justify-between text-[10.5px]">
<span className="text-slate-600 dark:text-[var(--dash-text-sec)] truncate">{it.label}</span>
<b className="font-mono text-slate-900 dark:text-[var(--dash-text-strong)]">{it.value}</b>
</div>
<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: pal.accents[i % pal.accents.length] }}
initial={{ width: 0 }}
animate={{ width: `${(it.value / max) * 100}%` }}
transition={{ duration: 0.7, ease: "easeOut" }}
/>
</div>
</div>
))}
</div>
);
}
function ChartCard({ chart }: { chart: DashChart }) {
const Icon = chart.type === "line" ? TrendingUp : chart.type === "donut" ? PieChart : chart.type === "radar" ? Award : BarChart3;
return (
<DraftingCard className="flex flex-col">
<div className="space-y-4">
<div className="flex items-center gap-2 border-b border-slate-100 dark:border-[var(--dash-border)] pb-2.5">
<div className="p-1.5 bg-rose-50/80 dark:bg-[var(--dash-primary-soft)] border border-rose-100 dark:border-[var(--dash-border)] rounded-sm text-rose-600 dark:text-[var(--dash-primary)]">
<Icon className="w-3.5 h-3.5" />
</div>
<h4 className="text-xs font-black text-slate-800 dark:text-[var(--dash-text)] tracking-wide font-sans">{chart.title || "图表"}</h4>
</div>
{chart.type === "line" ? (
<LineChartSvg items={chart.items} />
) : chart.type === "donut" ? (
<DonutChartSvg items={chart.items} />
) : chart.type === "radar" ? (
<RadarChartSvg items={chart.items} />
) : (
<BarChartSvg items={chart.items} />
)}
</div>
</DraftingCard>
);
}
export function AnalyticsCenter() {
const { CHARTS } = useDashboardData();
if (CHARTS.length === 0) return null;
return (
<div className="space-y-5">
<div className="flex items-center justify-between pb-1 border-b border-rose-100/90 dark:border-[var(--dash-border)]">
<h3 className="text-base font-extrabold text-slate-800 dark:text-[var(--dash-text)] tracking-tight font-sans flex items-center gap-2">
<Activity className="w-5 h-5 text-rose-600 dark:text-[var(--dash-primary)] animate-pulse" />
数据研判中心
<span className="text-xs font-normal text-slate-400 dark:text-[var(--dash-text-dim)] font-sans hidden sm:inline">多维度数据可视化研判</span>
</h3>
<span className="text-[10px] px-2 py-0.5 bg-slate-50 dark:bg-[var(--dash-elev)] border border-slate-200 dark:border-[var(--dash-border)] text-slate-500 dark:text-[var(--dash-text-sec)] font-mono rounded">
STATUS: REAL-TIME CONSOLE
</span>
</div>
<div className={`grid grid-cols-1 gap-5 ${CHARTS.length >= 3 ? "lg:grid-cols-3" : CHARTS.length === 2 ? "lg:grid-cols-2" : ""}`}>
{CHARTS.map((chart, i) => (
<ChartCard key={i} chart={chart} />
))}
</div>
</div>
);
}
@@ -0,0 +1,253 @@
/**
* 多维核心竞争力排行榜 —— 读 context.BRAND_SCORES(真实数据由 bar/radar 图表或各席位
* 达成度推导)。marketShare/strengths/vehicles 真实数据常缺省,按存在性条件渲染:
* - 仅当有任一 brand 带 marketShare 时显示“综合评分/市场份额”切换与份额列;
* - 展开抽屉里 strengths/vehicles 为空则给占位说明。
*/
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";
import { Trophy, ChevronRight, Zap, Car } from "lucide-react";
interface BrandScoresProps {
onBrandSelect?: (brand: BrandScore) => void;
}
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);
const [viewType, setViewType] = useState<"score" | "share">("score");
if (BRAND_SCORES.length === 0) {
return (
<DraftingCard className="h-full flex items-center justify-center" id="brand-scores-card">
<span className="text-xs text-slate-400 dark:text-[var(--dash-text-dim)]">暂无可对比的竞争力排行数据。</span>
</DraftingCard>
);
}
const handleBrandClick = (brand: BrandScore) => {
setSelectedBrand(selectedBrand?.name === brand.name ? null : brand);
onBrandSelect?.(brand);
};
const effectiveView = hasShare ? viewType : "score";
const maxShare = Math.max(...BRAND_SCORES.map((b) => shareNum(b.marketShare)), 1);
const sortedBrands = [...BRAND_SCORES].sort((a, b) =>
effectiveView === "score" ? b.score - a.score : shareNum(b.marketShare) - shareNum(a.marketShare),
);
const getRankBadge = (rankIndex: number) => {
if (rankIndex === 0)
return (
<div className="relative flex items-center justify-center w-7 h-7 rounded-sm bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 font-mono text-xs font-bold border border-amber-200 dark:border-amber-500/30 ring-2 ring-amber-100/50">
01
<Trophy className="absolute -top-1 -right-1 w-2.5 h-2.5 text-amber-500 fill-amber-500" />
</div>
);
if (rankIndex === 1)
return (
<div className="flex items-center justify-center w-7 h-7 rounded-sm bg-slate-50 dark:bg-[var(--dash-elev)] text-slate-600 dark:text-[var(--dash-text-sec)] font-mono text-xs font-bold border border-slate-200 dark:border-[var(--dash-border)] ring-2 ring-slate-100/50">
02
</div>
);
if (rankIndex === 2)
return (
<div className="flex items-center justify-center w-7 h-7 rounded-sm bg-rose-50 dark:bg-rose-500/10 text-rose-700 dark:text-rose-300 font-mono text-xs font-bold border border-rose-200 dark:border-rose-500/30 ring-2 ring-rose-100/30">
03
</div>
);
return (
<div className="flex items-center justify-center w-7 h-7 rounded-sm bg-slate-50/80 dark:bg-[var(--dash-elev)] text-slate-400 dark:text-[var(--dash-text-dim)] font-mono text-xs font-medium border border-slate-200 dark:border-[var(--dash-border)]">
{String(rankIndex + 1).padStart(2, "0")}
</div>
);
};
return (
<DraftingCard className="h-full flex flex-col justify-between" id="brand-scores-card">
<div>
<div className="flex flex-col md:flex-row md:items-center justify-between pb-4 border-b border-rose-100/85 dark:border-[var(--dash-border)] mb-5 gap-3">
<div className="flex items-center gap-2">
<div className="p-1 px-2 bg-rose-50 dark:bg-[var(--dash-primary-soft)] border border-rose-200 dark:border-[var(--dash-primary)] rounded text-rose-700 dark:text-[var(--dash-primary)]">
<Trophy className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-black text-slate-900 dark:text-[var(--dash-text-strong)] tracking-tight font-sans">多维核心竞争力排行榜</h3>
<p className="text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)] font-sans tracking-wide">多角度研讨加权估算指数</p>
</div>
</div>
{hasShare && (
<div className="flex bg-slate-50 dark:bg-[var(--dash-elev)] border border-slate-200/60 dark:border-[var(--dash-border)] p-0.5 rounded-sm select-none shrink-0 self-start md:self-auto">
{(["score", "share"] as const).map((vt) => (
<button
key={vt}
onClick={() => setViewType(vt)}
className={`px-2.5 py-1 text-[11px] font-bold rounded-xs cursor-pointer transition-all ${
viewType === vt ? "bg-white dark:bg-[var(--dash-card)] text-rose-700 dark:text-[var(--dash-primary)] border border-slate-200/40 dark:border-[var(--dash-border)]" : "text-slate-500 dark:text-[var(--dash-text-sec)] hover:text-slate-800 dark:hover:text-[var(--dash-text)]"
}`}
>
{vt === "score" ? "综合评分" : "市场份额"}
</button>
))}
</div>
)}
</div>
<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 (
<div
key={brand.name}
className={`group rounded-sm border transition-all duration-300 p-2.5 cursor-pointer relative ${
isSelected
? "bg-gradient-to-b from-rose-50/15 to-white dark:from-[var(--dash-primary-soft)] dark:to-[var(--dash-card)] border-rose-300 dark:border-[var(--dash-primary)] shadow-sm ring-1 ring-rose-200/40 dark:ring-[var(--dash-primary)]/30"
: "bg-white dark:bg-[var(--dash-card)] border-slate-200 dark:border-[var(--dash-border)] hover:bg-slate-50/40 dark:hover:bg-[var(--dash-elev)] hover:border-slate-300 dark:hover:border-[var(--dash-border-soft)]"
}`}
onClick={() => handleBrandClick(brand)}
onMouseEnter={() => setHoveredBrand(brand.name)}
onMouseLeave={() => setHoveredBrand(null)}
>
{isSelected && <div className="absolute left-0 top-0 bottom-0 w-[3px] bg-rose-600 dark:bg-[var(--dash-primary)] rounded-l-xs" />}
<div className="flex items-center gap-3">
{getRankBadge(index)}
<div
className="flex items-center justify-center w-8 h-8 rounded-sm font-extrabold text-xs select-none shrink-0 border"
style={{ backgroundColor: `${c}15`, color: c, borderColor: `${c}40` }}
>
{brand.name.substring(0, 1)}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-1 text-xs">
<span className="font-extrabold text-slate-800 dark:text-[var(--dash-text)] flex items-center gap-1.5 group-hover:text-rose-950 dark:group-hover:text-[var(--dash-primary)] transition-colors truncate">
{brand.name}
{brand.score >= 85 && (
<span className="text-[9px] bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 text-amber-700 dark:text-amber-300 px-1 rounded-xs font-mono shrink-0">
LEADING
</span>
)}
</span>
<div className="flex items-center gap-1.5 shrink-0">
{hasShare && (
<span className="text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)] font-mono">
{effectiveView === "score" ? `份额: ${brand.marketShare ?? "—"}` : `指数: ${brand.score}`}
</span>
)}
<span className="font-bold text-slate-900 dark:text-[var(--dash-text-strong)] font-mono text-sm tracking-tight">
{effectiveView === "score" ? brand.score : brand.marketShare}
</span>
</div>
</div>
<div className="w-full h-2.5 bg-slate-100 dark:bg-[var(--dash-inset)] rounded-sm overflow-hidden relative flex items-center p-0.5 border border-slate-200/50 dark:border-[var(--dash-border)]">
<div className="absolute inset-0 flex justify-between pointer-events-none px-1">
{[...Array(10)].map((_, i) => (
<div key={i} className="w-[1.2px] h-full bg-slate-200/80 dark:bg-[var(--dash-border-soft)]/80 last:hidden" />
))}
</div>
<motion.div
className="h-full rounded-xs relative"
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" }}
>
<div className="absolute inset-x-0 top-0 h-[40%] bg-white/10 rounded-sm" />
</motion.div>
</div>
</div>
<ChevronRight
className={`w-3.5 h-3.5 text-slate-400 dark:text-[var(--dash-text-dim)] group-hover:text-rose-600 dark:group-hover:text-[var(--dash-primary)] transition-all ${
isSelected ? "rotate-90 text-rose-500 dark:text-[var(--dash-primary)]" : ""
}`}
/>
</div>
<AnimatePresence>
{isSelected && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="pt-3.5 mt-2.5 border-t border-slate-100 dark:border-[var(--dash-border)] grid grid-cols-1 md:grid-cols-2 gap-3.5">
<div className="bg-slate-50/80 dark:bg-[var(--dash-elev)] p-2.5 rounded-sm border border-slate-200/50 dark:border-[var(--dash-border)]">
<div className="flex items-center gap-1.5 mb-2">
<Zap className="w-3.5 h-3.5 text-amber-500" />
<span className="text-[10px] font-bold text-slate-700 dark:text-[var(--dash-text)] tracking-wider">竞争优势 / 亮点</span>
</div>
{brand.strengths && brand.strengths.length > 0 ? (
<ul className="space-y-1.5">
{brand.strengths.map((str, i) => (
<li key={i} className="text-[11px] text-slate-600 dark:text-[var(--dash-text-sec)] flex items-start gap-1">
<span className="text-emerald-500 dark:text-emerald-300 font-bold shrink-0 mt-0.5">✓</span>
<span className="leading-tight">{str}</span>
</li>
))}
</ul>
) : (
<span className="text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)] italic">综合指数 {brand.score} 分</span>
)}
</div>
<div className="bg-slate-50/80 dark:bg-[var(--dash-elev)] p-2.5 rounded-sm border border-slate-200/50 dark:border-[var(--dash-border)] flex flex-col justify-between">
<div>
<div className="flex items-center gap-1.5 mb-2">
<Car className="w-3.5 h-3.5 text-sky-500 dark:text-sky-300" />
<span className="text-[10px] font-bold text-slate-700 dark:text-[var(--dash-text)] tracking-wider">代表性产物 / 车型</span>
</div>
{brand.vehicles && brand.vehicles.length > 0 ? (
<div className="flex flex-wrap gap-1.5 mt-1">
{brand.vehicles.map((v, i) => (
<span
key={i}
className="text-[10px] px-2 py-0.5 bg-white dark:bg-[var(--dash-card)] border border-slate-200 dark:border-[var(--dash-border)] text-slate-700 dark:text-[var(--dash-text)] rounded-sm font-semibold"
>
{v}
</span>
))}
</div>
) : (
<span className="text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)] italic block mt-1">详情见对应席位专页。</span>
)}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
</div>
<div className="mt-6 border-t border-dashed border-slate-200/85 dark:border-[var(--dash-border-soft)] pt-3 text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)] flex items-center justify-between gap-2">
<span className="italic">* 排序基于多维综合指标评议。</span>
<span className="px-1.5 py-0.5 bg-slate-50 dark:bg-[var(--dash-elev)] border border-slate-200 dark:border-[var(--dash-border)] rounded-xs font-mono text-[9px] text-slate-500 dark:text-[var(--dash-text-sec)]">
AUTO CO-RANK
</span>
</div>
</DraftingCard>
);
}
@@ -0,0 +1,67 @@
/**
* 总控最终共识 —— 读 context.consensus(headline 进玫瑰红竖条摘要,points 列表,
* 每条按序号循环配图标/色,替换参考里写死的 5 条新能源结论)。
*/
import { useDashboardData } from "../data-context";
import { DraftingCard } from "./DraftingCard";
import { Target, CheckCircle2, AlertCircle, HelpCircle, Activity } from "lucide-react";
interface ConsensusPanelProps {
onExploreClick?: () => void;
}
const POINT_ICONS = [Target, Activity, CheckCircle2, AlertCircle, HelpCircle];
// 浅色玫瑰红系;深色按《深蓝规范》§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();
return (
<DraftingCard className="h-full flex flex-col justify-between" id="consensus-panel">
<div>
<div className="flex items-center gap-2 pb-4 border-b border-rose-100 dark:border-[var(--dash-border)]">
<div className="w-2.5 h-2.5 bg-rose-600 dark:bg-[var(--dash-primary)] rounded-xs animate-pulse" />
<h2 className="text-lg font-bold text-slate-800 dark:text-[var(--dash-text)] tracking-tight font-sans">总控最终共识</h2>
</div>
{consensus.headline && (
<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>
)}
<ul className="mt-5 space-y-4">
{consensus.points.map((pt, idx) => {
const Icon = POINT_ICONS[idx % POINT_ICONS.length];
return (
<li key={idx} className="flex gap-3 text-xs leading-relaxed group">
<Icon className={`w-4.5 h-4.5 mt-0.5 shrink-0 ${POINT_COLORS[idx % POINT_COLORS.length]}`} />
<div className="text-slate-600 dark:text-[var(--dash-text-sec)] group-hover:text-slate-900 dark:group-hover:text-[var(--dash-text-strong)] transition-colors">{pt}</div>
</li>
);
})}
</ul>
</div>
<div className="mt-6 pt-4 border-t border-slate-100 dark:border-[var(--dash-border)] flex justify-between items-center bg-slate-50/50 dark:bg-[var(--dash-elev)] -mx-5 -mb-5 px-5 py-4 rounded-b-sm">
<span className="text-[11px] text-slate-400 dark:text-[var(--dash-text-dim)] font-mono tracking-wider uppercase">
Roundtable Final Consensus
</span>
<button
onClick={onExploreClick}
className="text-xs text-rose-600 dark:text-[var(--dash-primary)] hover:text-rose-700 dark:hover:text-[var(--dash-primary)] font-semibold cursor-pointer flex items-center gap-1 group transition-all"
>
查看研讨详情
<span className="group-hover:translate-x-1 transition-transform">→</span>
</button>
</div>
</DraftingCard>
);
}
@@ -0,0 +1,206 @@
/**
* 席位专页 —— 读 context.SEAT_CARDS 按 activeTab 取当前席位,数据驱动渲染:
* - 左栏:使命概述 + 结构化小节(sections);
* - 右栏:席位评定量化得分(metrics)+ 风险(risks)+ 席位依赖双向 chips(dependsOn /
* 反查谁承接本席位)+ 协同席位推荐。
* 参考项目原本是每个固定 tab 写死一套交互沙盘,真实数据下改为通用结构化展示。
*/
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";
import { Settings, ShieldAlert } from "lucide-react";
interface DetailViewProps {
activeTab: TabType;
onBackToOverview?: () => void;
/** 跳到其它席位(依赖 chips / 协同推荐)。缺省回落到返回总览。 */
onNavigate?: (id: TabType) => void;
}
// 浅色语义底;深色按《深蓝规范》语义色(高 #FF4D4F / 中 #FAAD14 / 低 #52C41A)低透叠底,
// 文字取同色更亮的浅调保证深蓝底上可读。
const RISK_STYLE = {
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?.());
if (!seat) {
return <div className="p-8 text-center text-slate-500">加载席位数据失败,请返回总览页。</div>;
}
const idx = SEAT_CARDS.findIndex((s) => s.id === seat.id);
const color = pal.accents[(idx >= 0 ? idx : 0) % pal.accents.length];
// 主体展示样式:总结席位固定「发布声明」,其余按「非总结席位序号」轮转分配,
// 让相邻席位专页换不同款式(框架矩阵 / 洞察卡阵 / 双栏对比 / 清单核对)。
const analystIdx = SEAT_CARDS.filter((s) => !s.isSummary).findIndex((s) => s.id === seat.id);
const variant: ShowcaseVariant = seat.isSummary
? "release"
: SHOWCASE_VARIANTS[(analystIdx >= 0 ? analystIdx : 0) % SHOWCASE_VARIANTS.length];
// 依赖双向:本席位依赖谁 / 谁承接本席位。
const byId = new Map(SEAT_CARDS.map((s) => [s.id, s] as const));
const deps = seat.dependsOn.map((d) => byId.get(d)).filter((s): s is SeatCard => !!s);
const supports = SEAT_CARDS.filter((s) => s.dependsOn.includes(seat.id));
const recommended = SEAT_CARDS.filter((s) => s.id !== seat.id).slice(0, 3);
return (
<div className="space-y-6">
{/* 顶栏 */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-3 border-b border-slate-200 dark:border-[var(--dash-border)]">
<div className="flex items-center gap-3">
<button
onClick={onBackToOverview}
className="text-xs text-slate-400 hover:text-rose-600 transition-colors font-medium cursor-pointer border border-slate-200 bg-white px-2.5 py-1.5 rounded-sm dark:text-[var(--dash-text-dim)] dark:hover:text-[var(--dash-primary)] dark:border-[var(--dash-border)] dark:bg-[var(--dash-card)]"
>
← 返回总览研讨看盘
</button>
<div className="h-4 w-[1px] bg-slate-200 hidden sm:block dark:bg-[var(--dash-border)]" />
<div>
<div className="flex items-center gap-2">
<h2 className="text-base font-bold text-slate-900 tracking-tight font-sans dark:text-[var(--dash-text-strong)]">{seat.title} · 席位专页</h2>
{seat.completeness > 0 && (
<span className="text-[10px] font-bold text-emerald-600 bg-emerald-50 border border-emerald-200 px-1.5 rounded-sm font-sans dark:text-emerald-300 dark:bg-emerald-500/10 dark:border-emerald-500/30">
达成度: {seat.completeness}%
</span>
)}
</div>
{seat.analyst && <span className="text-xs text-slate-400 font-sans block mt-0.5 dark:text-[var(--dash-text-dim)]">席任分析官:{seat.analyst}</span>}
</div>
</div>
<span className="text-xs font-mono text-slate-400 uppercase tracking-widest hidden md:inline dark:text-[var(--dash-text-dim)]">
Seat node: {String(seat.id).toUpperCase()}
</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左栏:使命概述 + 按变体定制的主体展示区(发布声明变体自带声明书,不重复概述卡) */}
<div className="lg:col-span-2 space-y-6">
{variant !== "release" && (
<DraftingCard hoverEffect={false}>
<div className="flex items-center gap-2 pb-3.5 border-b border-rose-100 mb-4 dark:border-[var(--dash-border)]">
<Settings className="w-4 h-4 text-rose-600 animate-spin-slow dark:text-[var(--dash-primary)]" />
<h3 className="text-xs font-bold text-slate-800 uppercase tracking-wider dark:text-[var(--dash-text)]">此席位核心交付 · 使命概述</h3>
</div>
<p className="text-xs text-slate-600 leading-relaxed font-sans bg-slate-50 p-3.5 rounded-sm border border-slate-200/40 dark:text-[var(--dash-text-sec)] dark:bg-[var(--dash-elev)] dark:border-[var(--dash-border)]">
{seat.description || "(无摘要)"}
</p>
</DraftingCard>
)}
<SeatShowcase key={seat.id} seat={seat} variant={variant} color={color} />
</div>
{/* 右栏:得分 + 风险 + 依赖 + 推荐 */}
<div className="space-y-6">
{seat.metrics.length > 0 && (
<DraftingCard hoverEffect={false}>
<div className="pb-3 border-b border-rose-100 mb-4 dark:border-[var(--dash-border)]">
<h4 className="text-xs font-bold text-slate-800 uppercase tracking-wider dark:text-[var(--dash-text)]">席位评定量化得分</h4>
</div>
<div className="space-y-4">
{seat.metrics.map((met, i) => (
<div key={i} className="p-4 bg-slate-50 border border-slate-100 rounded-md dark:bg-[var(--dash-elev)] dark:border-[var(--dash-border)]">
<div className="flex justify-between items-baseline mb-2">
<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 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)]">
<div className="h-full rounded-full" style={{ width: `${met.value}%`, backgroundColor: color }} />
</div>
</div>
))}
</div>
</DraftingCard>
)}
{seat.risks.length > 0 && (
<DraftingCard hoverEffect={false} className="border-amber-200 bg-amber-50/5 dark:border-amber-500/30 dark:bg-amber-500/5">
<div className="flex items-center gap-1.5 pb-3 border-b border-amber-100 mb-4 dark:border-amber-500/30">
<ShieldAlert className="w-4 h-4 text-amber-600 dark:text-amber-300" />
<h4 className="text-xs font-bold text-amber-800 uppercase tracking-wider dark:text-amber-300">此专项下属风险与关注点</h4>
</div>
<div className="space-y-3">
{seat.risks.map((risk) => (
<div key={risk.id} className={`p-3 rounded border text-xs text-left leading-relaxed ${RISK_STYLE[risk.level]}`}>
<div className="flex items-center gap-1.5 mb-1 font-bold">
<span
className={`w-1.5 h-1.5 rounded-full ${
risk.level === "high" ? "bg-red-500 animate-ping" : risk.level === "low" ? "bg-emerald-500" : "bg-amber-500"
}`}
/>
<span>{RISK_LABEL[risk.level]}</span>
</div>
{risk.text}
</div>
))}
</div>
</DraftingCard>
)}
{(deps.length > 0 || supports.length > 0) && (
<DraftingCard hoverEffect={false}>
<div className="pb-3 border-b border-rose-100 mb-4 dark:border-[var(--dash-border)]">
<h4 className="text-xs font-bold text-slate-800 uppercase tracking-wider dark:text-[var(--dash-text)]">席位依赖关系</h4>
</div>
<div className="flex flex-wrap gap-2">
{deps.map((d) => (
<button
key={d.id}
onClick={() => go(d.id)}
className="text-[11px] font-semibold px-2.5 py-1 border border-dashed border-rose-400 text-rose-600 rounded-sm hover:bg-rose-50 transition-colors dark:border-[var(--dash-primary)] dark:text-[var(--dash-primary)] dark:hover:bg-[var(--dash-primary-soft)]"
>
依赖 → {d.title}
</button>
))}
{supports.map((s) => (
<button
key={s.id}
onClick={() => go(s.id)}
className="text-[11px] font-semibold px-2.5 py-1 border border-dashed border-sky-400 text-sky-600 rounded-sm hover:bg-sky-50 transition-colors dark:border-sky-500/50 dark:text-sky-300 dark:hover:bg-sky-500/10"
>
{s.title} ← 承接本席位
</button>
))}
</div>
</DraftingCard>
)}
{recommended.length > 0 && (
<DraftingCard hoverEffect={false} className="bg-slate-50/50 dark:bg-[var(--dash-elev)]">
<div className="pb-2.5 border-b border-slate-200 mb-3.5 dark:border-[var(--dash-border)]">
<h4 className="text-xs font-bold text-slate-700 uppercase tracking-wider dark:text-[var(--dash-text)]">研讨协同席位推荐</h4>
</div>
<div className="space-y-2">
{recommended.map((other) => (
<button
key={other.id}
onClick={() => go(other.id)}
className="w-full p-2 bg-white hover:bg-slate-50 border border-slate-200 rounded cursor-pointer flex justify-between items-center text-xs text-slate-700 hover:text-rose-600 hover:border-slate-300 transition-all dark:bg-[var(--dash-card)] dark:hover:bg-[var(--dash-elev)] dark:border-[var(--dash-border)] dark:text-[var(--dash-text)] dark:hover:text-[var(--dash-primary)]"
>
<span className="font-semibold truncate">{other.title}</span>
<span className="text-[10px] text-slate-400 shrink-0 dark:text-[var(--dash-text-dim)]">点击切出 →</span>
</button>
))}
</div>
</DraftingCard>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,40 @@
/**
* 蓝图工程图风卡片(源自参考项目,原样保留):白底 + 四角玫瑰红角标 + 极淡绘图网格 overlay。
*/
import type React from "react";
interface DraftingCardProps {
children: React.ReactNode;
className?: string;
id?: string;
hoverEffect?: boolean;
onClick?: () => void;
}
export function DraftingCard({
children,
className = "",
id,
hoverEffect = true,
onClick,
}: DraftingCardProps) {
return (
<div
id={id || undefined}
onClick={onClick}
className={`relative bg-white dark:bg-[var(--dash-card)] rounded-sm p-5 border border-slate-200/80 dark:border-[var(--dash-border)] transition-all duration-300 shadow-[0_3px_10px_rgba(15,23,42,0.04)] dark:shadow-[0_2px_8px_rgba(6,14,36,0.5)] ${
hoverEffect
? "hover:shadow-[0_8px_24px_rgba(15,23,42,0.08)] dark:hover:shadow-[0_4px_16px_rgba(22,119,255,0.24)] hover:-translate-y-[2px] hover:border-slate-300 dark:hover:border-[var(--dash-border-soft)]"
: ""
} ${className}`}
>
{/* 左上角标(浅色品牌正红 #F5222D / 深色主蓝,取色见 dashboard.css 的 .dash-corner) */}
<div className="dash-corner absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 -mt-[1px] -ml-[1px]" />
{/* 右下角标 */}
<div className="dash-corner absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 -mb-[1px] -mr-[1px]" />
{/* 极淡绘图网格 overlay */}
<div className="absolute inset-0 bg-grid-slate-100 [mask-image:linear-gradient(0deg,white,rgba(255,255,255,0.6))] pointer-events-none rounded-sm opacity-30" />
<div className="relative z-10">{children}</div>
</div>
);
}
@@ -0,0 +1,61 @@
/**
* 顶部 KPI 带 —— 读 context.METRIC_DATA(参考原本写死 6 个 + 按 id 配图标,
* 改成按序号循环取图标 + 用十六进制色 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";
interface MetricsHeaderProps {
onCardClick?: (metricId: string) => void;
}
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 (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{METRIC_DATA.map((metric, idx) => {
const Icon = ICONS[idx % ICONS.length];
return (
<DraftingCard
key={metric.id}
className="flex flex-col justify-between py-4 px-5 min-h-[115px] cursor-pointer"
onClick={() => onCardClick?.(metric.id)}
>
<div className="flex justify-between items-start gap-2">
<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: pal.accents[idx % pal.accents.length] }} />
</div>
<div className="mt-2 flex items-baseline gap-1">
<span className="text-3xl font-bold text-slate-800 dark:text-[var(--dash-text)] tracking-tight font-sans">
{metric.value}
</span>
{metric.unit && <span className="text-xs text-slate-400 dark:text-[var(--dash-text-dim)] font-medium ml-0.5">{metric.unit}</span>}
{metric.trend && (
<span
className={`inline-flex items-center text-xs font-semibold px-1 rounded-sm ml-2 ${
metric.trend.type === "up"
? "text-emerald-600 bg-emerald-50 dark:text-emerald-300 dark:bg-emerald-500/10"
: "text-rose-600 bg-rose-50 dark:text-rose-300 dark:bg-rose-500/10"
}`}
>
{metric.trend.type === "up" ? "▲" : "▼"}
</span>
)}
</div>
</DraftingCard>
);
})}
</div>
);
}
@@ -0,0 +1,118 @@
/**
* 核心风险分类分布 —— 读 context.RISK_CATEGORIES(真实数据由各席位风险按 高/中/低
* 聚合)+ riskTotal(镂空中心大数字)。无风险时给友好空态。
*/
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";
interface RiskDonutProps {
onCategoryHover?: (category: RiskCategory | null) => void;
}
export function RiskDonut({ onCategoryHover }: RiskDonutProps) {
const { RISK_CATEGORIES, riskTotal } = useDashboardData();
const pal = useDashPalette();
const [activeCategory, setActiveCategory] = useState<RiskCategory | null>(null);
const handleMouseEnter = (cat: RiskCategory) => {
setActiveCategory(cat);
onCategoryHover?.(cat);
};
const handleMouseLeave = () => {
setActiveCategory(null);
onCategoryHover?.(null);
};
const empty = RISK_CATEGORIES.length === 0;
return (
<DraftingCard className="h-full flex flex-col justify-between" id="risk-donut-card">
<div>
<div className="flex items-center gap-2 pb-4 border-b border-rose-100 dark:border-[var(--dash-border)] mb-5">
<ShieldAlert className="w-5 h-5 text-rose-600 dark:text-[var(--dash-primary)]" />
<h3 className="text-sm font-bold text-slate-800 dark:text-[var(--dash-text)] tracking-tight font-sans">核心风险分类分布</h3>
</div>
{empty ? (
<div className="flex flex-col items-center justify-center gap-2 text-slate-400 dark:text-[var(--dash-text-dim)] py-12">
<ShieldAlert className="w-8 h-8 text-emerald-400" />
<span className="text-xs">本轮研讨未识别到显式风险项。</span>
</div>
) : (
<>
<div className="flex flex-row items-center gap-5 py-2">
{/* 风险总数(去掉百分比圆环,保留大数字,对齐设计原图) */}
<div className="flex flex-col items-center justify-center shrink-0 px-3 py-2 border-r border-slate-100 dark:border-[var(--dash-border)]">
<span className="text-4xl font-extrabold text-slate-800 dark:text-[var(--dash-text)] font-mono leading-none">
{activeCategory ? `${activeCategory.percentage}%` : riskTotal}
</span>
<span className="text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)] font-medium tracking-wider mt-1.5 text-center max-w-[60px] leading-tight">
{activeCategory ? activeCategory.name : "项核心风险"}
</span>
</div>
{/* 图例 */}
<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 ? catColor : undefined }}
onMouseEnter={() => handleMouseEnter(cat)}
onMouseLeave={handleMouseLeave}
>
<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>
<span className="text-xs font-bold text-slate-900 dark:text-[var(--dash-text-strong)] font-mono">{cat.value}%</span>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* 悬停细项抽屉 */}
<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: 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">
{activeCategory.details.slice(0, 3).map((det, i) => (
<li key={i} className="truncate">{det}</li>
))}
</ul>
</div>
) : (
<div className="flex items-center gap-2.5 text-slate-400 dark:text-[var(--dash-text-dim)] justify-center">
<ShieldAlert className="w-4 h-4" />
<span className="text-xs">悬停上方分类标签,查看各项底层细节。</span>
</div>
)}
</div>
</>
)}
</div>
<div className="mt-5 border-t border-dashed border-slate-100 dark:border-[var(--dash-border-soft)] pt-3 text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)] flex items-center justify-between">
<span>风险按高/中/低等级聚合占比</span>
<span className="font-semibold text-rose-600 dark:text-[var(--dash-primary)] px-1 bg-rose-50 dark:bg-[var(--dash-primary-soft)] border border-rose-200 dark:border-[var(--dash-border)] rounded-xs">
共 {riskTotal} 项
</span>
</div>
</DraftingCard>
);
}
@@ -0,0 +1,98 @@
/**
* 执行路线 —— 读 context.ROADMAP_PHASES(参考按空格拆 title 取 tag/name,改成 id 作 tag、
* title 作 name;period/highlights 真实数据可能缺省,分别条件渲染,无 highlights 不展开)。
*/
import { useState } from "react";
import { useDashboardData } from "../data-context";
import { autoGridColsClass } from "../grid";
import { DraftingCard } from "./DraftingCard";
import { CheckCircle2, ChevronDown, ChevronUp } from "lucide-react";
import { motion } from "motion/react";
export function Roadmap() {
const { ROADMAP_PHASES } = useDashboardData();
const [activePhaseId, setActivePhaseId] = useState<string | null>(null);
if (ROADMAP_PHASES.length === 0) return null;
const togglePhase = (id: string) => setActivePhaseId(activePhaseId === id ? null : id);
return (
<div className="space-y-5">
<div className="flex items-center justify-between pb-1 border-b border-rose-100 dark:border-[var(--dash-border)]">
<h3 className="text-base font-bold text-slate-800 dark:text-[var(--dash-text)] tracking-tight font-sans flex items-center gap-2">
执行路线
<span className="text-xs font-normal text-slate-400 dark:text-[var(--dash-text-dim)] font-sans">点击阶段卡片展开关键里程碑</span>
</h3>
<span className="text-[10px] text-rose-600 dark:text-[var(--dash-primary)] font-mono font-medium px-2 py-0.5 bg-rose-50 dark:bg-[var(--dash-primary-soft)] border border-rose-200/50 dark:border-[var(--dash-border)]">
共 {ROADMAP_PHASES.length} 个阶段
</span>
</div>
<div className={`grid grid-cols-1 ${autoGridColsClass(ROADMAP_PHASES.length)} gap-4`}>
{ROADMAP_PHASES.map((phase) => {
const hasHighlights = phase.highlights.length > 0;
const isExpanded = activePhaseId === phase.id;
return (
<DraftingCard
key={phase.id}
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}
>
<div>
<div className="flex justify-between items-start gap-2 mb-2">
<div className="space-y-0.5">
<span className="text-xs font-extrabold text-rose-600 dark:text-[var(--dash-primary)] font-sans block uppercase tracking-wide">
{phase.id}
</span>
<h4 className="text-sm font-extrabold text-slate-800 dark:text-[var(--dash-text)] font-sans">{phase.title}</h4>
</div>
{phase.period && (
<span className="text-[11px] font-bold text-slate-400 dark:text-[var(--dash-text-dim)] font-mono bg-slate-100 dark:bg-[var(--dash-elev)] border border-slate-200 dark:border-[var(--dash-border)] px-2 py-0.5 rounded-sm shrink-0">
{phase.period}
</span>
)}
</div>
<p className="text-xs text-slate-600 dark:text-[var(--dash-text-sec)] leading-relaxed font-sans mt-2.5">{phase.description}</p>
{hasHighlights && isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="mt-4 pt-3.5 border-t border-slate-200/80 dark:border-[var(--dash-border)] space-y-2 overflow-hidden"
>
<span className="text-[10px] font-bold text-rose-600 dark:text-[var(--dash-primary)] uppercase tracking-widest block mb-1">
此阶段关键里程碑:
</span>
<ul className="space-y-2">
{phase.highlights.map((hlt, idx) => (
<li key={idx} className="flex items-start gap-2 text-xs text-slate-700 dark:text-[var(--dash-text)]">
<CheckCircle2 className="w-3.5 h-3.5 text-rose-500 dark:text-[var(--dash-primary)] shrink-0 mt-0.5" />
<span>{hlt}</span>
</li>
))}
</ul>
</motion.div>
)}
</div>
<div className="mt-4 pt-2.5 border-t border-slate-100 dark:border-[var(--dash-border)] flex items-center justify-between text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)]">
<span className="font-mono uppercase tracking-wider">Timeline Segment {phase.id}</span>
{hasHighlights && (
<div className="flex items-center gap-1 font-semibold text-rose-600 hover:text-rose-700 dark:text-[var(--dash-primary)] dark:hover:text-[var(--dash-primary)]">
<span>{isExpanded ? "收起" : "详细指标"}</span>
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</div>
)}
</div>
</DraftingCard>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,144 @@
/**
* 席位交付总览 —— 读 context.SEAT_CARDS。参考按固定 id 配图标/颜色并写死“总结”全宽行;
* 这里改成:按序号循环取强调色,isSummary 席位全宽置底(无则全部进网格)。
*/
import { useDashboardData } from "../data-context";
import { useDashPalette } from "../palette";
import { autoGridColsClass } from "../grid";
import { DraftingCard } from "./DraftingCard";
import { TabType } from "../types";
import { ArrowRight, AlertTriangle, AlertCircle, FileText } from "lucide-react";
interface SeatDeliveryOverviewProps {
onSeatSelect: (seatId: TabType) => void;
activeSeatId?: TabType;
}
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;
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-baseline justify-between gap-2">
<h3 className="text-base font-bold text-slate-800 dark:text-[var(--dash-text)] tracking-tight font-sans flex items-center gap-2">
席位交付总览
<span className="text-xs font-normal text-slate-400 dark:text-[var(--dash-text-dim)] font-sans">点击卡片快速切入对应专属席位页</span>
</h3>
</div>
<div className={`grid grid-cols-1 ${autoGridColsClass(analystSeats.length)} gap-4`}>
{analystSeats.map((seat, idx) => {
const color = pal.accents[idx % pal.accents.length];
const isActive = activeSeatId === seat.id;
return (
<DraftingCard
key={seat.id}
className={`flex flex-col justify-between h-full cursor-pointer relative ${
isActive ? "ring-2 ring-rose-500/70 bg-rose-50/5 border-rose-300 dark:ring-[var(--dash-primary)]/60 dark:bg-[var(--dash-primary)]/5 dark:border-[var(--dash-primary)]" : ""
}`}
onClick={() => onSeatSelect(seat.id)}
>
<div>
<div className="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-[var(--dash-border)]">
<div className="flex items-center gap-2 min-w-0">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
<span className="text-xs font-bold text-slate-800 dark:text-[var(--dash-text)] font-sans truncate">{seat.title}</span>
</div>
<div className="flex items-center gap-0.5 text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)] font-sans hover:text-slate-600 dark:hover:text-[var(--dash-text-sec)] shrink-0">
<span>席位页</span>
<ArrowRight className="w-3 h-3" />
</div>
</div>
{seat.analyst && (
<div className="mt-3 text-slate-400 dark:text-[var(--dash-text-dim)] text-[10px] uppercase font-mono font-bold tracking-wider truncate">
汇报人: {seat.analyst}
</div>
)}
<p className="mt-2 text-xs text-slate-600 dark:text-[var(--dash-text-sec)] leading-relaxed line-clamp-3">{seat.description}</p>
{seat.metrics.length > 0 && (
<div className="mt-4 space-y-2.5">
{seat.metrics.slice(0, 2).map((met, i) => (
<div key={i} className="space-y-1">
<div className="flex justify-between text-[10px] font-medium text-slate-500 dark:text-[var(--dash-text-sec)]">
<span className="truncate">{met.label}</span>
<span className="font-bold text-slate-800 dark:text-[var(--dash-text)] font-mono shrink-0">{met.value}</span>
</div>
<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: pal.accents[i % pal.accents.length] }}
/>
</div>
</div>
))}
</div>
)}
</div>
{seat.risks.length > 0 && (
<div className="mt-5 pt-3.5 border-t border-slate-100 dark:border-[var(--dash-border)] space-y-1.5">
{seat.risks.slice(0, 3).map((risk) => (
<div
key={risk.id}
className={`text-[9px] px-2 py-0.5 rounded-sm flex items-center gap-1 border-l-2 ${
risk.level === "high"
? "bg-red-50 text-red-700 border-red-500 border border-red-200/50 dark:bg-red-500/10 dark:text-red-300 dark:border-red-500/40"
: risk.level === "low"
? "bg-emerald-50 text-emerald-700 border-emerald-500 border border-emerald-200/50 dark:bg-emerald-500/10 dark:text-emerald-300 dark:border-emerald-500/40"
: "bg-amber-50 text-amber-700 border-amber-500 border border-amber-200/50 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-500/40"
}`}
>
{risk.level === "high" ? (
<AlertCircle className="w-3 h-3 text-red-500 shrink-0" />
) : (
<AlertTriangle className={`w-3 h-3 shrink-0 ${risk.level === "low" ? "text-emerald-500" : "text-amber-500"}`} />
)}
<span className="truncate">{risk.text}</span>
</div>
))}
</div>
)}
</DraftingCard>
);
})}
</div>
{summarySeat && (
<DraftingCard
className={`cursor-pointer ${activeSeatId === summarySeat.id ? "ring-2 ring-rose-500 bg-rose-50/10 dark:ring-[var(--dash-primary)] dark:bg-[var(--dash-primary)]/10" : ""}`}
onClick={() => onSeatSelect(summarySeat.id)}
>
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div className="flex items-start md:items-center gap-3">
<div className="p-2.5 bg-sky-100 border border-sky-300 rounded-sm dark:bg-sky-500/15 dark:border-sky-500/40">
<FileText className="w-5 h-5 text-sky-600 dark:text-sky-300" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-sky-500" />
<h4 className="text-sm font-bold text-slate-950 dark:text-[var(--dash-text-strong)] font-sans">{summarySeat.title}</h4>
<span className="text-[10px] px-2 py-0.5 bg-sky-100/50 text-sky-800 rounded-full font-mono border border-sky-200/40 font-semibold uppercase tracking-wider dark:bg-sky-500/15 dark:text-sky-300 dark:border-sky-500/30">
决策中心终审
</span>
</div>
<p className="mt-1.5 text-xs text-slate-600 dark:text-[var(--dash-text-sec)] leading-relaxed max-w-4xl">{summarySeat.description}</p>
</div>
</div>
<div className="shrink-0 flex items-center gap-1 text-xs text-sky-600 hover:text-sky-700 font-semibold group bg-sky-50 px-3.5 py-2.5 rounded-sm border border-sky-100 dark:bg-sky-500/10 dark:text-sky-300 dark:border-sky-500/30">
<span>进入 {summarySeat.title} 专页</span>
<span className="group-hover:translate-x-1 transition-transform">→</span>
</div>
</div>
</DraftingCard>
)}
</div>
);
}
@@ -0,0 +1,338 @@
/**
* 席位专页「主体展示区」的多样式变体 —— 对齐参考项目 DetailView 里按席位定制的 5 种子页面
* (框架矩阵 / 洞察卡阵 / 双栏对比 / 清单核对 / 发布声明),但全部改成**数据驱动**:
* 不再写死维度/NPS/审计清单等内容,而是消费当前席位真实的 `sections` / `details` /
* `description`,按所选变体换一套排版,从而让每个席位专页「长得不一样」。
*
* 变体由 DetailView 按席位序号轮转分配(总结席位固定用 release),见 `SHOWCASE_VARIANTS`。
* 量化得分 / 风险 / 依赖 / 协同推荐仍由 DetailView 的右栏统一承载,这里只负责主体内容。
*/
import { useState } from "react";
import {
Award,
Building2,
Check,
ClipboardList,
GitCompare,
LayoutGrid,
Sparkles,
} from "lucide-react";
import { DraftingCard } from "./DraftingCard";
import type { SeatCard, SeatSection } from "../types";
export type ShowcaseVariant = "matrix" | "gauge" | "compare" | "audit" | "release";
/** 非总结席位的变体轮转序列(按席位序号取模分配,保证相邻席位不同款)。 */
export const SHOWCASE_VARIANTS: ShowcaseVariant[] = ["matrix", "gauge", "compare", "audit"];
/** 把席位内容归一成「分组小节」:优先 sections,回落 details 合成一组。 */
function toGroups(seat: SeatCard): SeatSection[] {
if (seat.sections.length > 0) return seat.sections.filter((s) => s.bullets.length > 0 || s.heading);
if (seat.details.length > 0) return [{ heading: "汇报要点支撑明细", bullets: seat.details }];
return [];
}
function allBullets(groups: SeatSection[]): string[] {
return groups.flatMap((g) => g.bullets);
}
function EmptyShowcase() {
return (
<DraftingCard hoverEffect={false}>
<p className="py-6 text-center text-xs text-slate-400 dark:text-[var(--dash-text-dim)]">该席位暂无结构化汇报内容。</p>
</DraftingCard>
);
}
export function SeatShowcase({
seat,
variant,
color,
}: {
seat: SeatCard;
variant: ShowcaseVariant;
color: string;
}) {
const groups = toGroups(seat);
const bullets = allBullets(groups);
if (variant !== "release" && bullets.length === 0) return <EmptyShowcase />;
switch (variant) {
case "matrix":
return <MatrixShowcase groups={groups} color={color} />;
case "gauge":
return <GaugeShowcase seat={seat} groups={groups} color={color} />;
case "compare":
return <CompareShowcase groups={groups} color={color} />;
case "audit":
return <AuditShowcase bullets={bullets} color={color} />;
case "release":
return <ReleaseShowcase seat={seat} bullets={bullets} />;
default:
return <EmptyShowcase />;
}
}
// ── 变体一:框架矩阵(ref scheme)—— 维度卡片网格 + 要点对标表 ──────────────────────────
function MatrixShowcase({ groups, color }: { groups: SeatSection[]; color: string }) {
const cols = groups.length >= 3 ? "md:grid-cols-3" : groups.length === 2 ? "md:grid-cols-2" : "md:grid-cols-1";
let rowNo = 0;
return (
<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 dark:text-[#97EBCD]" />
<span>结构化框架 · 维度拆解</span>
</div>
<div className={`grid grid-cols-1 gap-3 ${cols}`}>
{groups.map((g, i) => (
<div
key={i}
className="rounded-sm border border-slate-200 bg-slate-50/60 p-3 text-center dark:border-[var(--dash-border)] dark:bg-[var(--dash-elev)]"
style={{ borderTopColor: color, borderTopWidth: 2 }}
>
<div className="mb-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-[var(--dash-text-dim)]">
维度 {i + 1}
</div>
<div className="truncate text-xs font-semibold text-slate-800 dark:text-[var(--dash-text)]">{g.heading || `小节 ${i + 1}`}</div>
<span className="mt-1.5 inline-block rounded-full border border-current bg-white/70 px-1.5 py-0.5 font-mono text-[9px] text-slate-500 dark:bg-[var(--dash-card)] dark:text-[var(--dash-text-sec)]">
{g.bullets.length} 项要点
</span>
</div>
))}
</div>
</DraftingCard>
<DraftingCard hoverEffect={false}>
<div className="overflow-hidden rounded-md border border-slate-200/60 bg-slate-50 dark:border-[var(--dash-border)] dark:bg-[var(--dash-elev)]">
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-100 p-3 dark:border-[var(--dash-border)] dark:bg-[var(--dash-inset)]">
<span className="text-xs font-bold text-slate-700 dark:text-[var(--dash-text)]">各维度关键交付要点对标</span>
<span className="rounded-sm bg-slate-200/50 px-2 py-0.5 font-mono text-[10px] text-slate-600 dark:bg-[var(--dash-inset)] dark:text-[var(--dash-text-sec)]">
共 {groups.reduce((n, g) => n + g.bullets.length, 0)} 项
</span>
</div>
<table className="w-full text-left text-xs">
<thead className="border-b border-slate-200 bg-slate-50 font-sans text-[10px] uppercase tracking-wider text-slate-500 dark:border-[var(--dash-border)] dark:bg-[var(--dash-elev)] dark:text-[var(--dash-text-sec)]">
<tr>
<th className="px-3 py-2.5">序号</th>
<th className="px-4 py-2.5">所属维度</th>
<th className="px-4 py-2.5">关键交付要点</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-slate-700 dark:divide-[var(--dash-border)] dark:text-[var(--dash-text)]">
{groups.flatMap((g, gi) =>
g.bullets.map((b, bi) => {
rowNo += 1;
return (
<tr key={`${gi}-${bi}`} className={rowNo % 2 === 0 ? "bg-white/50 dark:bg-[var(--dash-card)]" : ""}>
<td className="px-3 py-2.5 font-mono font-semibold text-slate-400 dark:text-[var(--dash-text-dim)]">{String(rowNo).padStart(2, "0")}</td>
<td className="px-4 py-2.5 font-semibold" style={{ color }}>
{bi === 0 ? g.heading || `维度 ${gi + 1}` : ""}
</td>
<td className="px-4 py-2.5 leading-relaxed">{b}</td>
</tr>
);
}),
)}
</tbody>
</table>
</div>
</DraftingCard>
</div>
);
}
// ── 变体二:洞察卡阵(ref ux)—— 描述领读 + 要点卡片网格 ───────────────────────────────
function GaugeShowcase({ seat, groups, color }: { seat: SeatCard; groups: SeatSection[]; color: string }) {
const bullets = allBullets(groups);
return (
<DraftingCard hoverEffect={false}>
<div className="mb-4 flex items-center gap-2">
<Sparkles className="h-5 w-5" style={{ color }} />
<span className="text-xs font-bold text-slate-800 dark:text-[var(--dash-text)]">核心洞察聚焦 · 关键论点拆解</span>
</div>
<div
className="mb-5 rounded-md border p-4"
style={{ backgroundColor: `${color}0d`, borderColor: `${color}33` }}
>
<span className="mb-1 block text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-[var(--dash-text-dim)]">本席位核心论断</span>
<p className="text-xs font-medium leading-relaxed text-slate-700 dark:text-[var(--dash-text)]">{seat.description || "(无摘要)"}</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{bullets.map((b, i) => (
<div key={i} className="overflow-hidden rounded-sm border border-slate-200 bg-white dark:border-[var(--dash-border)] dark:bg-[var(--dash-card)]">
<div className="h-1 w-full" style={{ backgroundColor: color }} />
<div className="flex gap-2.5 p-3">
<span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-mono text-[10px] font-bold text-white"
style={{ backgroundColor: color }}
>
{i + 1}
</span>
<p className="text-[11px] leading-relaxed text-slate-600 dark:text-[var(--dash-text-sec)]">{b}</p>
</div>
</div>
))}
</div>
</DraftingCard>
);
}
// ── 变体三:双栏对比(ref ad)—— 小节分两列彩色面板 ────────────────────────────────────
function CompareShowcase({ groups, color }: { groups: SeatSection[]; color: string }) {
// ≥2 组:每组一面板;仅 1 组:把要点对半拆成两面板。
const panels: SeatSection[] =
groups.length >= 2
? groups
: (() => {
const b = groups[0]?.bullets ?? [];
const mid = Math.ceil(b.length / 2);
return [
{ heading: `${groups[0]?.heading || "汇报要点"} · 上`, bullets: b.slice(0, mid) },
{ heading: `${groups[0]?.heading || "汇报要点"} · 下`, bullets: b.slice(mid) },
].filter((p) => p.bullets.length > 0);
})();
// 浅色淡彩面板;深色按《深蓝规范》§41 淡色系列叠底(青/蓝/橙/天蓝),标题/圆点取同色,
// 避免浅彩底直接搬到深蓝页(即 大屏-3 里发灰的对比面板)。
const tints = [
{ 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 (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{panels.map((p, i) => {
const t = tints[i % tints.length];
return (
<div key={i} className={`rounded-md border p-4 ${t.bg} ${t.border}`}>
<h4 className={`mb-3 flex items-center gap-1.5 text-xs font-bold ${t.title}`}>
<GitCompare className="h-3.5 w-3.5" style={{ color }} />
<span className="truncate">{p.heading || `对比维度 ${i + 1}`}</span>
</h4>
<ul className="space-y-3 text-xs leading-relaxed">
{p.bullets.map((b, j) => (
<li key={j} className="flex gap-2">
<span className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${t.dot}`} />
<span className="text-slate-600 dark:text-[var(--dash-text-sec)]">{b}</span>
</li>
))}
</ul>
</div>
);
})}
</div>
);
}
// ── 变体四:清单核对(ref risk)—— 要点可勾选核对台 ───────────────────────────────────
function AuditShowcase({ bullets, color }: { bullets: string[]; color: string }) {
const [done, setDone] = useState<number[]>([]);
const toggle = (i: number) => setDone((p) => (p.includes(i) ? p.filter((x) => x !== i) : [...p, i]));
return (
<DraftingCard hoverEffect={false}>
<div className="mb-4 flex items-baseline justify-between border-b border-slate-100 pb-3 dark:border-[var(--dash-border)]">
<span className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-slate-800 dark:text-[var(--dash-text)]">
<ClipboardList className="h-4 w-4" style={{ color }} />
要点核对台
</span>
<span className="font-mono text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)]">
已确认 {done.length}/{bullets.length}
</span>
</div>
<div className="space-y-2.5">
{bullets.map((b, i) => {
const checked = done.includes(i);
return (
<div
key={i}
onClick={() => toggle(i)}
className={`flex cursor-pointer select-none items-start gap-3 rounded border p-3 transition-all duration-300 ${
checked ? "border-emerald-300 bg-emerald-50/40 dark:border-emerald-500/40 dark:bg-emerald-500/10" : "border-slate-200 bg-white hover:border-slate-300 dark:border-[var(--dash-border)] dark:bg-[var(--dash-card)] dark:hover:border-[var(--dash-border-soft)]"
}`}
>
<div
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-colors ${
checked ? "border-emerald-600 bg-emerald-500 text-white" : "border-slate-300 bg-white dark:border-[var(--dash-border-soft)] dark:bg-[var(--dash-card)]"
}`}
>
{checked && <Check className="h-3.5 w-3.5" />}
</div>
<span
className={`text-xs leading-relaxed ${
checked ? "text-slate-400 line-through dark:text-[var(--dash-text-dim)]" : "text-slate-700 dark:text-[var(--dash-text)]"
}`}
>
{b}
</span>
</div>
);
})}
</div>
</DraftingCard>
);
}
// ── 变体五:发布声明(ref summary)—— 声明书 + 要点核验网格 + 签署 ─────────────────────
function ReleaseShowcase({ seat, bullets }: { seat: SeatCard; bullets: string[] }) {
const [signed, setSigned] = useState(false);
return (
<div className="space-y-4">
<DraftingCard hoverEffect={false} className="border-sky-200/65 bg-sky-50/40 dark:border-sky-500/30 dark:bg-sky-500/10">
<h4 className="mb-2.5 flex items-center gap-1.5 text-xs font-bold text-sky-800 dark:text-sky-300">
<Award className="h-4 w-4 text-sky-600 dark:text-sky-300" />
研讨结果最终发布声明书 (Executive Release)
</h4>
<p className="text-xs leading-relaxed text-slate-600 dark:text-[var(--dash-text-sec)]">{seat.description || "(无总结陈述)"}</p>
{bullets.length > 0 && (
<div className="mt-4 space-y-2 border-t border-sky-100 pt-3 dark:border-sky-500/30">
<span className="block text-[10px] font-bold uppercase tracking-widest text-sky-700 dark:text-sky-300">关键结论与调和事项:</span>
<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 dark:text-[#95DE64]" />
<span className="leading-relaxed">{b}</span>
</div>
))}
</div>
</div>
)}
</DraftingCard>
<div className="flex flex-col items-center justify-center space-y-4 rounded-lg border border-dashed border-slate-200 bg-white/50 p-5 text-center dark:border-[var(--dash-border-soft)] dark:bg-[var(--dash-card)]">
<div className="max-w-md">
<LayoutGrid className="mx-auto mb-2 h-8 w-8 text-sky-600 dark:text-sky-300" />
<h5 className="text-sm font-bold text-slate-800 dark:text-[var(--dash-text)]">总台最终数字签署确认栏</h5>
<p className="mt-1 text-xs text-slate-400 dark:text-[var(--dash-text-dim)]">勾选此项即代表会议全席专家一致同意此交付终稿,封盘归档。</p>
</div>
<button
type="button"
onClick={() => setSigned((s) => !s)}
className={`flex items-center gap-2 rounded-sm border px-5 py-2.5 text-xs font-semibold transition-all ${
signed
? "border-emerald-700 bg-emerald-600 text-white hover:bg-emerald-700"
: "border-slate-300 bg-white text-slate-700 hover:border-rose-400 hover:text-rose-600 dark:bg-[var(--dash-card)] dark:border-[var(--dash-border-soft)] dark:text-[var(--dash-text)] dark:hover:border-[var(--dash-primary)] dark:hover:text-[var(--dash-primary)]"
}`}
>
{signed ? (
<>
<Check className="h-4 w-4" />
<span>【已电子签发:执委会终审确认】</span>
</>
) : (
<span>确认签名并完成终稿归档发布</span>
)}
</button>
</div>
</div>
);
}
@@ -0,0 +1,104 @@
/**
* 大屏多页(蓝图工程图风)自定义工具类 —— 补齐参考项目里依赖的、本仓 Tailwind v4
* 默认不带的几个 utility(其余 rose/slate/font-mono 等均走 Tailwind 原生)。
* 仅在 DashboardApp 子树内通过 import 引入,作用域不污染全局。
*/
/* ─────────────────────────────────────────────────────────────────────────
* 深色主题 token(《深蓝色 UI 规范》§1 取值)—— 挂在 DashboardApp 根 `.dash-root`,
* 组件保留浅色 Tailwind 类、仅追加 `dark:bg-[var(--dash-*)]` 变体引用本表,
* 浅色模式不受影响、深色模式整体切到深蓝规范色(色值集中此处,组件不散落 HEX)。
* ──────────────────────────────────────────────────────────────────────── */
.dash-root {
--dash-page: #0c1836; /* 页面背景 zs-004-09 */
--dash-card: #122347; /* 卡片背景 zs-004-08 */
--dash-elev: #1a2f5a; /* 容器/内嵌面板 zs-004-07 */
--dash-inset: #0c1836; /* 更深内嵌块(表头/代码态) */
--dash-border: #1a2f5a; /* 常规边框/分隔 zs-004-07 */
--dash-border-soft: #2d4473; /* 次级/虚线/控件边框 zs-004-06 */
--dash-text-strong: #ffffff; /* 标题/强调 */
--dash-text: #c5cfe0; /* 正文 zs-004-02 */
--dash-text-sec: #6e88b0; /* 次级 zs-004-04 */
--dash-text-dim: #4a6491; /* 禁用/弱 zs-004-05 */
--dash-primary: #1677ff; /* 主操作/品牌强调(替代浅色 rose)zs-002-06 */
--dash-primary-soft: rgba(22, 119, 255, 0.16); /* 主色浅底 */
--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:
linear-gradient(to right, rgba(45, 68, 115, 0.35) 1px, transparent 1px),
linear-gradient(to bottom, rgba(45, 68, 115, 0.35) 1px, transparent 1px);
}
/* DraftingCard 四角工程角标取色:浅色品牌正红 #F5222D,深色切深蓝规范主蓝。
* (角标元素只设单边 border 宽度,用专用类直接给 border-color,避开 Tailwind
* 任意值色类在单边宽度场景下的不生效问题。) */
.dash-corner {
border-color: #f5222d;
}
.dark .dash-root .dash-corner {
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:
linear-gradient(to right, rgba(241, 245, 249, 0.9) 1px, transparent 1px),
linear-gradient(to bottom, rgba(241, 245, 249, 0.9) 1px, transparent 1px);
background-size: 16px 16px;
}
/* 缓慢自转(Settings/Sliders 图标,参考 `animate-spin-slow`)。 */
@keyframes rt-dash-spin-slow {
to {
transform: rotate(360deg);
}
}
.animate-spin-slow {
animation: rt-dash-spin-slow 6s linear infinite;
}
/* 隐藏横向 Tab 滚动条(参考 `scrollbar-none`)。 */
.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
/* range 滑块在白底蓝图风下的细节微调(DetailView 权重沙盘若启用)。 */
.rt-dash input[type="range"] {
accent-color: currentColor;
}
@@ -0,0 +1,439 @@
/**
* 大屏多页数据上下文 —— DashboardApp 通过 Provider 注入,各组件用 `useDashboardData()`
* 读取,避免把同一份数据逐层穿透 props(对齐参考项目用静态 `data.ts` 的写法,但改成
* 运行时可注入真实圆桌数据)。
*
* 三块内容:
* 1) `DashboardDataValue` 契约 + Context/Provider/hook;
* 2) `DEMO_DATA`:不传真实数据时的回落演示数据(≈ 参考项目 data.ts,验证视觉用);
* 3) `adaptReportData(ReportData → DashboardDataValue)`:把 Step3 的 report-json 真实
* 数据映射成大屏组件消费的形状(KPI/共识/图表/席位/路线/风险/排行)。
*/
import { createContext, useContext, type ReactNode } from "react";
import type { ReportData, ReportSeat } from "@/roundtable-planning/lib/report-template";
import type {
BrandScore,
ConsensusData,
DashChart,
MetricItem,
RiskCategory,
RoadmapPhase,
SeatCard,
} from "./types";
export interface DashboardDataValue {
meta: { title: string; subtitle: string; generatedAt: string };
/** 顶部 KPI 带。 */
METRIC_DATA: MetricItem[];
/** 总控最终共识。 */
consensus: ConsensusData;
/** 多维竞争力排行(真实数据由图表/席位推导,可为空)。 */
BRAND_SCORES: BrandScore[];
/** 核心风险分类分布(真实数据由各席位风险按等级聚合)。 */
RISK_CATEGORIES: RiskCategory[];
/** 识别到的风险总数(环形图镂空中心的大数字)。 */
riskTotal: number;
/** 数据研判中心图表(最多 3 个)。 */
CHARTS: DashChart[];
/** 席位交付。 */
SEAT_CARDS: SeatCard[];
/** 执行路线阶段。 */
ROADMAP_PHASES: RoadmapPhase[];
}
// ── 取色:图表/KPI/席位强调色序(对齐 report-template 的 light accents) ──────────────
export const ACCENTS = ["#E11D48", "#4F46E5", "#059669", "#D97706", "#0284C7", "#7C3AED"];
const RISK_COLORS = { high: "#DC2626", medium: "#D97706", low: "#059669" } as const;
const Ctx = createContext<DashboardDataValue | null>(null);
export function DashboardDataProvider({
value,
children,
}: {
value: DashboardDataValue;
children: ReactNode;
}) {
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useDashboardData(): DashboardDataValue {
const v = useContext(Ctx);
if (!v) throw new Error("useDashboardData 必须在 <DashboardDataProvider> 内使用");
return v;
}
// ── adaptReportData:真实 report-json → 大屏数据 ────────────────────────────────────
/** 一个席位是否“总结/总控”性质(总览页全宽置底)。 */
function looksLikeSummary(name: string): boolean {
return /总结|总控|结论|综述|summary|总览/i.test(name);
}
/** 从席位指标取一个“达成度”代表值(首个指标,缺省 0)。 */
function seatCompleteness(seat: ReportSeat): number {
return seat.metrics.length > 0 ? Math.round(seat.metrics[0].value) : 0;
}
function adaptSeat(seat: ReportSeat, idx: number): SeatCard {
const details = seat.sections.flatMap((s) => s.bullets).slice(0, 6);
return {
id: seat.id,
title: seat.name,
completeness: seatCompleteness(seat),
description: seat.summary,
sections: seat.sections.map((s) => ({ heading: s.heading, bullets: s.bullets })),
details,
metrics: seat.metrics.map((m) => ({ label: m.label, value: Math.round(m.value) })),
risks: seat.risks.map((r, i) => ({
id: `${seat.id}-r${i}`,
text: r.label,
level: r.level === "mid" ? "medium" : r.level,
})),
dependsOn: [...seat.dependsOn],
isSummary: looksLikeSummary(seat.name),
};
}
/** 排行榜:优先用 bar/radar 图表,否则按各席位达成度推导,保证中列总有内容。 */
function deriveBrandScores(report: ReportData): BrandScore[] {
const ranking = report.charts.find((c) => c.type === "bar" || c.type === "radar");
if (ranking && ranking.items.length > 0) {
return ranking.items.slice(0, 8).map((it, i) => ({
name: it.label,
score: Math.max(0, Math.min(100, Math.round(it.value))),
color: ACCENTS[i % ACCENTS.length],
}));
}
return report.seats
.filter((s) => !looksLikeSummary(s.name))
.map((s, i) => ({
name: s.name,
score: seatCompleteness(s),
color: ACCENTS[i % ACCENTS.length],
}))
.filter((b) => b.score > 0);
}
/** 风险分布:把所有席位风险按 高/中/低 聚合成三类(环形图 + 图例)。 */
function deriveRiskCategories(report: ReportData): RiskCategory[] {
const buckets: Record<"high" | "medium" | "low", string[]> = { high: [], medium: [], low: [] };
for (const s of report.seats) {
for (const r of s.risks) {
const lv = r.level === "mid" ? "medium" : r.level;
buckets[lv].push(r.label);
}
}
const total = buckets.high.length + buckets.medium.length + buckets.low.length;
if (total === 0) return [];
const defs: Array<{ key: "high" | "medium" | "low"; name: string }> = [
{ key: "high", name: "高风险" },
{ key: "medium", name: "中风险" },
{ key: "low", name: "低风险" },
];
return defs
.filter((d) => buckets[d.key].length > 0)
.map((d) => {
const pct = Math.round((buckets[d.key].length / total) * 100);
return {
name: d.name,
value: pct,
percentage: pct,
color: RISK_COLORS[d.key],
details: buckets[d.key],
};
});
}
/** report-json → 大屏数据。generatedAt 由调用方传当前时间字符串。 */
export function adaptReportData(report: ReportData, generatedAt = ""): DashboardDataValue {
const seats = report.seats.map(adaptSeat);
const riskCount = report.seats.reduce((n, s) => n + s.risks.length, 0);
const METRIC_DATA: MetricItem[] = report.kpis.map((k, i) => ({
id: String(i + 1),
label: k.label,
value: k.value,
unit: k.unit ?? "",
trend: k.trend === "up" ? { type: "up", value: "" } : k.trend === "down" ? { type: "down", value: "" } : undefined,
color: ACCENTS[i % ACCENTS.length],
}));
return {
meta: { title: report.title, subtitle: report.subtitle, generatedAt },
METRIC_DATA,
consensus: { headline: report.consensus.headline, points: [...report.consensus.points] },
BRAND_SCORES: deriveBrandScores(report),
RISK_CATEGORIES: deriveRiskCategories(report),
riskTotal: riskCount,
CHARTS: report.charts.slice(0, 3).map((c) => ({ type: c.type, title: c.title, items: [...c.items] })),
SEAT_CARDS: seats,
ROADMAP_PHASES: report.timeline.map((t, i) => ({
id: `S${i + 1}`,
title: t.label,
description: t.desc,
highlights: [],
})),
};
}
// ── DEMO_DATA:参考项目演示数据(无真实数据时回落,验证视觉用) ─────────────────────────
const DEMO_SEATS: SeatCard[] = [
{
id: "scheme",
title: "方案设计",
analyst: "战略研究部组长",
completeness: 92,
description:
"提出四阶段×五维度分析框架(市场总量、品牌格局、车型梯队、价格带分布、技术路线),为全研讨提供方法论骨架。",
sections: [
{
heading: "汇报要点支撑明细",
bullets: [
"全面统筹:建立从 2019 至 2026 年的宏观行业全景沙盘。",
"五个抓手:总量增长、品牌洗牌、车型裂变、价格卡位、混动 vs 纯电。",
"定量对标:收录各品牌细分销量、排量与续航参数底座。",
],
},
],
details: [
"全面统筹:建立从 2019 至 2026 年的宏观行业全景沙盘。",
"五个抓手:总量增长、品牌洗牌、车型裂变、价格卡位、混动 vs 纯电。",
"定量对标:收录各品牌细分销量、排量与续航参数底座。",
],
metrics: [
{ label: "框架完整性评分", value: 92 },
{ label: "指标覆盖度评分", value: 88 },
],
risks: [
{ id: "s1", text: "缺少独立政策/监管维度,难评估双积分变动。", level: "medium" },
{ id: "s2", text: "四阶段转折判断(2025 直接成熟)可能过早。", level: "medium" },
],
dependsOn: [],
},
{
id: "ux",
title: "用户体验分析师",
analyst: "用户体验与产品定义专家",
completeness: 90,
description: "揭示用户需求三级跳(能跑就行 → 智能体验决胜 → 场景化生态决胜),量化智驾/座舱对忠诚度的拉动。",
sections: [
{
heading: "汇报要点支撑明细",
bullets: [
"主线转换:智能座舱 + 高阶智驾合占约 55% 决策权重。",
"NPS 大分化:理想 72-80 领跑;特斯拉受本土生态挤压承压。",
"体验场景:女性副驾、露营、多宠家庭、第二办公空间重塑价值。",
],
},
],
details: [
"主线转换:智能座舱 + 高阶智驾合占约 55% 决策权重。",
"NPS 大分化:理想 72-80 领跑;特斯拉受本土生态挤压承压。",
"体验场景:女性副驾、露营、多宠家庭、第二办公空间重塑价值。",
],
metrics: [{ label: "体验洞察深度", value: 90 }],
risks: [
{ id: "u1", text: "NPS 估算非全量官方普查,缺乏跨品牌同一标准。", level: "high" },
{ id: "u2", text: "用户体验画像缺乏大跨度样本与消费分层解读。", level: "medium" },
],
dependsOn: ["scheme"],
},
{
id: "ad",
title: "广告规划",
analyst: "数字营销与渠道设计总监",
completeness: 85,
description: "洞察从功能教育向心智共鸣与强渠道体系变迁的三级跳,定量对比销售渠道重塑的竞争力量。",
sections: [
{
heading: "汇报要点支撑明细",
bullets: [
"渠道重塑:直营商超 vs 深度分销,多品牌网络是跨级竞争护城河。",
"超级效率:理想凭产品高清晰度在零广告下收获超高带看流。",
"破圈公式:问界融合华为声誉 + 电子消费展厅,低边际获客成本。",
],
},
],
details: [
"渠道重塑:直营商超 vs 深度分销,多品牌网络是跨级竞争护城河。",
"超级效率:理想凭产品高清晰度在零广告下收获超高带看流。",
"破圈公式:问界融合华为声誉 + 电子消费展厅,低边际获客成本。",
],
metrics: [{ label: "营销策略覆盖度", value: 85 }],
risks: [
{ id: "a1", text: "营销费用保密性高,华为问界联合体费用拆分困难。", level: "high" },
{ id: "a2", text: "各品牌生命周期不同,直接拉平效率易产生偏差。", level: "medium" },
],
dependsOn: ["scheme", "ux"],
},
{
id: "risk",
title: "风险审查",
analyst: "合规与风险首席防线官",
completeness: 88,
description: "识别预测中的系统性不确定点,严防幸存者偏差与线性推算,将宏观政策与供应链黑天鹅嵌入共识。",
sections: [
{
heading: "汇报要点支撑明细",
bullets: [
"黑天鹅预埋:注入汇率剧烈波动、矿产断供等破坏性测试。",
"数据证伪:逐行审查各席位数据来源与统计口径。",
"修正方案:小米销量改为情景上限,蔚来高亏损调为防守模型。",
],
},
],
details: [
"黑天鹅预埋:注入汇率剧烈波动、矿产断供等破坏性测试。",
"数据证伪:逐行审查各席位数据来源与统计口径。",
"修正方案:小米销量改为情景上限,蔚来高亏损调为防守模型。",
],
metrics: [{ label: "风险识别全面度", value: 88 }],
risks: [
{ id: "r1", text: "海外芯片制裁与底层传感器半导体对冲考量不足。", level: "high" },
{ id: "r2", text: "2026 购置税退坡的心理压力与提前释放未被消化。", level: "high" },
],
dependsOn: ["scheme", "ux", "ad"],
},
{
id: "summary",
title: "总结陈述",
analyst: "战略决策终审发言人",
completeness: 0,
description:
"整合各席位共识与冲突,拉平决策共识,向执委会交付高战略敏感度、高实用度、防抖性强的演变总账。",
sections: [
{
heading: "汇报要点支撑明细",
bullets: [
"大一统格局:确定第一/第二/第三梯队三层大分化事实。",
"调和冲突:对四大风险与框架偏差合理定性,保留边界条件。",
"执行指引:为 2026 淘汰赛终局提供防守反击与智能化升维依据。",
],
},
],
details: [
"大一统格局:确定第一/第二/第三梯队三层大分化事实。",
"调和冲突:对四大风险与框架偏差合理定性,保留边界条件。",
"执行指引:为 2026 淘汰赛终局提供防守反击与智能化升维依据。",
],
metrics: [],
risks: [],
dependsOn: ["scheme", "ux", "ad", "risk"],
isSummary: true,
},
];
export const DEMO_DATA: DashboardDataValue = {
meta: {
title: "新能源乘用车市场格局与竞争分析",
subtitle: "四阶段 × 五维度框架,覆盖品牌/车型销量、市场份额及竞争态势演变(2019-2026)",
generatedAt: "2026-06-13 01:15",
},
METRIC_DATA: [
{ id: "1", label: "研讨参与席位", value: 5, unit: "个", color: ACCENTS[0] },
{ id: "2", label: "共识达成度", value: 92, unit: "%", trend: { type: "up", value: "" }, color: ACCENTS[4] },
{ id: "3", label: "识别关键风险", value: 12, unit: "项", color: ACCENTS[2] },
{ id: "4", label: "分析框架维度", value: 5, unit: "个", color: ACCENTS[3] },
{ id: "5", label: "覆盖品牌", value: 8, unit: "个", color: ACCENTS[1] },
{ id: "6", label: "覆盖时间跨度", value: 8, unit: "年", color: ACCENTS[5] },
],
consensus: {
headline:
"中国新能源乘用车市场经历四阶段演变,已形成比亚迪/特斯拉领跑、理想/问界追赶的三层阵营格局,竞争主线从功能转向智能体验与品牌心智。",
points: [
"三层阵营清晰定格:第一梯队比亚迪/特斯拉领跑,第二梯队理想/问界深度追赶,第三梯队蔚来/小鹏/小米差异化拼杀。",
"智能体验主导决策权重:智驾 + 座舱合占购车决策权重约 55%,用户体验完成“能跑就行 → 场景化生态”升维。",
"渠道与品牌三级跳跃:营销重塑历经功能教育 → 产品差异化 → 品牌心智驱动,直营/智选渠道成为最锋利切割手段。",
"宏观与微观风险需加条件限定:方法论与数据置信度中等,地缘政治与购置税退坡波动强烈,结论需声明边界。",
"核心争执调和:调降蔚来换电保值乐观度,小米长期出货改为多情景上限,半导体与地缘加入测试环境。",
],
},
BRAND_SCORES: [
{ name: "比亚迪", score: 92, color: "#E11D48", marketShare: "28.5%", strengths: ["垂直整合产业链", "混动纯电双轮驱动", "规模效应极致成本"], vehicles: ["秦L DM-i", "宋PLUS", "海鸥", "仰望U8"] },
{ name: "理想", score: 82, color: "#0D9488", marketShare: "8.4%", strengths: ["奶爸车定位精准", "增程解决里程焦虑", "全家共享车内生态"], vehicles: ["理想L9", "理想L8", "理想L7", "理想MEGA"] },
{ name: "问界", score: 80, color: "#D97706", marketShare: "7.8%", strengths: ["华为智选智能生态", "领先 ADS 智驾算法", "华为渠道深度导入"], vehicles: ["问界M9", "问界M7", "问界M5"] },
{ name: "特斯拉", score: 78, color: "#2563EB", marketShare: "12.2%", strengths: ["全球顶尖科技属性", "标准化极简生产", "超充网络自建"], vehicles: ["Model Y", "Model 3"] },
{ name: "蔚来", score: 68, color: "#0EA5E9", marketShare: "4.5%", strengths: ["换电护城河", "VIP 会员社交圈", "多品牌矩阵发力"] },
{ name: "小鹏", score: 65, color: "#64748B", marketShare: "3.9%", strengths: ["端到端智驾自研", "MONA 高性价比", "出海欧洲先锋"] },
],
RISK_CATEGORIES: [
{ name: "方法论/数据风险", value: 35, percentage: 35, color: "#E11D48", details: ["NPS 估算缺乏统一标准", "营销费用披露模糊难拆分", "座舱权重打分偏差大"] },
{ name: "外部环境/地缘风险", value: 30, percentage: 30, color: "#0D9488", details: ["欧美反补贴冲击出口链", "2026 购置税退坡转折", "电池关键矿物贸易壁垒"] },
{ name: "格局时效性风险", value: 20, percentage: 20, color: "#2563EB", details: ["小米等新势力爆发超预期", "智驾端到端可能绝杀长尾", "燃油车反攻引发价格下探"] },
{ name: "营销效率偏差风险", value: 15, percentage: 15, color: "#D97706", details: ["忽略品牌生命周期差异", "问界借母品牌 IP 难核算", "传统经销商转型评估不足"] },
],
riskTotal: 12,
CHARTS: [
{
type: "line",
title: "核心渗透率走势 (2019-2026)",
items: [
{ label: "2019", value: 4.7 },
{ label: "2020", value: 5.4 },
{ label: "2021", value: 14.8 },
{ label: "2022", value: 25.6 },
{ label: "2023", value: 35.7 },
{ label: "2024", value: 38.6 },
{ label: "2025", value: 42.5 },
{ label: "2026", value: 48.5 },
],
},
{
type: "donut",
title: "2026 阵营市场份额沙盘",
items: [
{ label: "比亚迪", value: 28.5 },
{ label: "特斯拉", value: 12.2 },
{ label: "理想汽车", value: 8.4 },
{ label: "华为问界", value: 7.8 },
{ label: "蔚来汽车", value: 4.5 },
{ label: "其他品牌", value: 38.6 },
],
},
{
type: "radar",
title: "比亚迪五维核心战力",
items: [
{ label: "性价比", value: 95 },
{ label: "三电技术", value: 92 },
{ label: "智能座舱", value: 75 },
{ label: "营销渠道", value: 95 },
{ label: "用户忠诚", value: 82 },
],
},
],
SEAT_CARDS: DEMO_SEATS,
ROADMAP_PHASES: [
{
id: "S1",
title: "政策驱动期",
period: "2019-2020",
description: "补贴退坡与突发事件交织,特斯拉上海工厂国产化落地,新势力在生死边缘探索。",
highlights: ["年销量约 120 万辆", "整体渗透率 <5%", "多针对 B 端或尝鲜消费者", "特斯拉催促全产业链启动"],
},
{
id: "S2",
title: "爆发成长期",
period: "2021-2022",
description: "市场由政策拉动转为市场驱动,比亚迪 DM-i 大爆发,家庭智能 SUV 与微型车两极爆发。",
highlights: ["比亚迪月销攀升至 20 万辆", "渗透率冲破 25%+", "宏光 MINI、理想 ONE 大单品", "锂价飞天、芯片荒逼迫自主替代"],
},
{
id: "S3",
title: "淘汰洗牌期",
period: "2023-2024",
description: "价格战见骨,合资燃油主力败退,长尾品牌因资金链断裂出局,智驾开城热潮席卷行业。",
highlights: ["合资折扣创历史新高", "多品牌停产裁员", "城市 NOA 爆发式落地", "渗透率切入 35%"],
},
{
id: "S4",
title: "成熟分化期",
period: "2025-2026",
description: "增速中枢向下,进入健康毛利质量竞争,大出海进入深水区,AI 深度决定品牌宿命。",
highlights: ["月渗透向 45-50% 冲击", "AI 端到端成基本标配", "弱势车企转代工或解体", "智能座舱与底盘融合"],
},
],
};
@@ -0,0 +1,21 @@
/**
* 大屏卡片网格列数自适应 —— 一行最多 4 个;不足 4 个时撑满整行(2 个就各占半行),
* 避免固定 `lg:grid-cols-4` 在卡片少时右侧大片留白。席位交付总览 / 执行路线共用。
*
* 返回的列数类全部为字面量,确保 Tailwind JIT 能在编译期扫描到。配合外层固定
* `grid grid-cols-1 ... gap-4` 使用(base 单列,md/lg 按数量铺开)。
*/
export function autoGridColsClass(count: number): string {
switch (Math.min(Math.max(count, 0), 4)) {
case 1:
return "lg:grid-cols-1";
case 2:
return "md:grid-cols-2 lg:grid-cols-2";
case 3:
return "md:grid-cols-2 lg:grid-cols-3";
case 4:
return "md:grid-cols-2 lg:grid-cols-4";
default:
return "";
}
}
@@ -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;
}
@@ -0,0 +1,104 @@
/**
* 大屏多页(蓝图工程图风)数据类型 —— 源自参考项目 `types.ts`,针对圆桌真实数据改造:
* - `TabType` 由写死的 6 个固定席位联合类型,放开成 `string`('overview' + 任意席位 id);
* - `MetricItem.color` 由 Tailwind class 串改成十六进制色(适配“按主题/序号自动取色”);
* - `SeatCard` 增加 `sections`/`dependsOn`/`isSummary`,承接真实数据的结构化小节与依赖;
* - `SeatRisk.level` 增补 `'low'`(真实契约是 high|mid|low,mid→medium)。
*/
/** 'overview' = 总览页;其余为席位 id(真实数据里是任意 string)。 */
export type TabType = string;
export interface MetricItem {
id: string;
label: string;
value: string | number;
unit: string;
trend?: {
type: "up" | "down";
value: string;
};
/** 十六进制强调色(图标/描边)。 */
color: string;
}
/** 总控共识:一句话结论 + 若干要点。 */
export interface ConsensusData {
headline: string;
points: string[];
}
export interface BrandScore {
name: string;
/** 0-100 综合指数。 */
score: number;
color: string;
/** 市场份额(如 "28.5%");真实数据可能缺省。 */
marketShare?: string;
/** 竞争优势/亮点;真实数据可能缺省。 */
strengths?: string[];
/** 代表车型/产物;真实数据可能缺省。 */
vehicles?: string[];
}
export interface RiskCategory {
name: string;
/** 在环形图里的占比权重(0-100)。 */
value: number;
percentage: number;
color: string;
details: string[];
}
/** 通用图表(数据研判中心):line=趋势 / bar=排名 / donut=占比 / radar=多维。 */
export interface DashChart {
type: "line" | "bar" | "donut" | "radar";
title: string;
items: { label: string; value: number }[];
}
export interface SeatRisk {
id: string;
text: string;
level: "high" | "medium" | "low";
}
export interface SeatMetric {
label: string;
/** 0-100。 */
value: number;
}
export interface SeatSection {
heading: string;
bullets: string[];
}
export interface SeatCard {
id: TabType;
title: string;
analyst?: string;
/** 达成度/完整度 0-100(取主指标),用于卡片进度条。 */
completeness: number;
/** 一两句结论摘要。 */
description: string;
/** 结构化小节(席位专页主体)。 */
sections: SeatSection[];
/** 扁平要点(总览卡片速览 / 兼容旧渲染)。 */
details: string[];
risks: SeatRisk[];
metrics: SeatMetric[];
/** 依赖的其它席位 id(产物承接),渲染成互跳 chips。 */
dependsOn: string[];
/** 是否“总结/总控”席位(总览页里全宽置底展示)。 */
isSummary?: boolean;
}
export interface RoadmapPhase {
id: string;
title: string;
/** 时间区间(如 "2019-2020");真实数据可能缺省。 */
period?: string;
description: string;
highlights: string[];
}
@@ -103,7 +103,7 @@ export interface UseDraftPersistenceReturn {
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
setCurrentDraftId: React.Dispatch<React.SetStateAction<string | null>>;
handleManualSave: () => void;
handleLoadDraft: (id: string) => void;
handleLoadDraft: (id: string) => Promise<boolean>;
/** 按外部任务 id 取最新草稿并 hydrate,返回是否命中(用于抑制 autoStart)。 */
loadLatestByTask: (taskId: string) => Promise<boolean>;
handleDeleteDraft: (id: string) => void;
@@ -115,6 +115,11 @@ export interface UseDraftPersistenceReturn {
* 推荐弹窗打开前调用,确保推荐历史有 draft_id 可归属。
*/
ensureDraft: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
/**
* **强制**把当前会话内容存进草稿(不同于 ensureDraft 的"已有就跳过")——后台挂起
* 发起前 / 点查看前各存一次,保住当时的聊天记录不丢。返回 draft id。
*/
saveDraftNow: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
/** 强制刷新草稿列表。 */
refresh: () => Promise<void>;
}
@@ -271,6 +276,17 @@ export function useDraftPersistence(
[currentStep, enqueueSave, refreshList],
);
// 强制存当前内容(与 ensureDraft 不同:已有草稿也照存,把最新对话回写进去)。
const saveDraftNow = useCallback(
async (furthestStep: 1 | 2 | 3 = (currentStep as 1 | 2 | 3) || 1): Promise<string | null> => {
const hadId = currentDraftIdRef.current;
const id = await enqueueSave(furthestStep);
if (id && !hadId) await refreshList();
return id;
},
[currentStep, enqueueSave, refreshList],
);
const handleManualSave = useCallback(() => {
const furthest = (currentStep as 1 | 2 | 3) || 1;
void (async () => {
@@ -381,23 +397,26 @@ export function useDraftPersistence(
],
);
// 返回 Promise<是否成功 hydrate>:调用方(如"查看运行中作业")需在 hydrate 完成
// **之后**再订阅 SSE 实时流,否则草稿快照会覆盖实时帧(竞态)。
const handleLoadDraft = useCallback(
(id: string) => {
void (async () => {
(id: string): Promise<boolean> => {
return (async () => {
let draft;
try {
draft = await getDraft(id);
} catch (err) {
console.warn("[drafts] 加载草稿失败:", err);
triggerToast("⚠️ 加载草稿失败,请重试");
return;
return false;
}
if (!draft) {
triggerToast("⚠️ 该草稿已不存在");
await refreshList();
return;
return false;
}
applyLoadedDraft(draft, `📂 已加载草稿:${draft.title}`);
return true;
})();
},
[applyLoadedDraft, refreshList, triggerToast],
@@ -515,6 +534,7 @@ export function useDraftPersistence(
handleCommitRename,
handleCancelRename,
ensureDraft,
saveDraftNow,
/** 强制刷新草稿列表(如后台挂起后让「进行中」状态立刻出现)。 */
refresh: refreshList,
};
@@ -330,6 +330,24 @@ export interface UseStep2OrchestrationReturn {
handleSaveAgentAndRecalculate: (id: number) => void;
/** 从草稿加载;同时把 step2InitRef 置为「已 init」跳过自动 SSE。 */
hydrateFromDraft: (snapshot: Step2Snapshot) => void;
/**
* 「查看=恢复流程」(2026-06-13):后台作业 SSE 每帧调用,把作业实时对话/产物 stub/
* 共识进度灌进第二步画面 —— 消息列表像本地编排一样实时长出,而不是静态聊天记录。
*/
applyLiveJobFrame: (frame: {
dialogues: Step2Dialogue[];
seatStubMessages: Record<string, StubAiMessage[]>;
consensusPercentage: number;
hasConsensus: boolean;
lastLeaderContent: string;
}) => void;
/**
* 「后台挂起接管」(2026-06-13):点「查看」一个运行中的后台作业并取消它、
* `hydrateFromDraft` 恢复作业真实线程 + 完整对话后调用 —— 在那些线程上**由前端
* 继续实时流式编排**(leader 自由派活续轮,不从头重跑 DAG)。返回是否成功接管
* (无线程时返回 false,调用方据此回退到只读查看)。
*/
resumeLiveTakeover: () => boolean;
/** 「新建任务」清空 Step 2 数据(threadIds / dialogues / 共识等)。 */
resetStep2State: () => void;
/** 当前沙箱预览绑定的席位 thread id。 */
@@ -1129,20 +1147,26 @@ export function useStep2Orchestration(
}
}, [selectedParallelSeats, setDialogueContent]);
// 推荐模式并行批进行中标记(2026-06-13):总控一轮派给多个席位 → 判定可并行,
// 批内并行执行 + 显示走「业务链 sandbox 单列可写」同款(含下方沙箱跟随)。
const recommendParallelRef = useRef(false);
// sandbox 单列模式「沙箱跟随」(防 bug 重点,用户特别强调):切换当前显示的席位时,
// 沙箱内容必须等于该席位的内容 —— 有产物则打开它的沙箱,无产物则关闭沙箱面板。
// 并把「当前允许显示沙箱的席位 thread」记到 sandboxFollowThreadIdRef,供 openSeatVirtualArtifact
// 的 auto 守卫拦掉后台并行席位的抢占(见该函数)。非 sandbox 模式 / 无选中时清空,不干预沙箱。
// 推荐模式并行批(recommendParallelRef)同样按单列沙箱跟随处理。
useEffect(() => {
const seat = dagExecMode === "sandbox" ? selectedParallelSeats[0] ?? "" : "";
const singleColumn = dagExecMode === "sandbox" || recommendParallelRef.current;
const seat = singleColumn ? selectedParallelSeats[0] ?? "" : "";
// 只在「显示席位真正变化」时 reconcile。否则:本 effect 的 deps(openSeatLatestArtifact /
// setArtifactsOpen 等)身份每次 render 都可能变 → effect 反复跑 → 把用户刚手动关闭的沙箱
// 又强制重开,表现为「关闭按钮关不掉」。用 key 守卫后,用户关闭(席位没变)时直接早退。
const key = `${dagExecMode}:${seat}`;
const key = `${dagExecMode}:${recommendParallelRef.current ? "rp" : ""}:${seat}`;
if (lastReconciledSeatRef.current === key) return;
lastReconciledSeatRef.current = key;
if (dagExecMode !== "sandbox" || !seat) {
if (!singleColumn || !seat) {
sandboxFollowThreadIdRef.current = null;
return;
}
@@ -1201,8 +1225,9 @@ export function useStep2Orchestration(
if (speakerId !== null && item.id === speakerId) {
return { ...item, status: "研讨中" };
}
// 发言结束(不再研讨中)的席位标记为「已完成」,而非回退到「在线」。
if (item.status === "研讨中") {
return { ...item, status: "在线" };
return { ...item, status: "已完成" };
}
return item;
}),
@@ -1215,7 +1240,7 @@ export function useStep2Orchestration(
(speakerId: number) => {
// 编排循环已经改成**顺序**派 special(详见 §5.4 / 文件顶部注释),
// 任意时刻只有一个子智能体在跑。因此这里既要把当前发言席位置为"研讨中",
// 又要把上一轮 / 历史残留下来的"研讨中"统一清成"在线",保证右侧"参与角色"
// 又要把上一轮 / 历史残留下来的"研讨中"统一标记成"已完成",保证右侧"参与角色"
// 面板任何时刻**只有一个** sub agent 显示研讨中,与对话区实际在 stream 的
// 那一个一致。activeSpeakerSet 沿用 Set 形态(虽然实际只放 1 个)以兼容外部
// 消费方,真正的"高亮排他"靠 roles 的 status reset。
@@ -1223,7 +1248,8 @@ export function useStep2Orchestration(
setRoles((prev) =>
prev.map((item) => {
if (item.id === speakerId) return { ...item, status: "研讨中" };
if (item.status === "研讨中") return { ...item, status: "在线" };
// 上一轮发言已结束的席位标记为「已完成」,而非回退到「在线」。
if (item.status === "研讨中") return { ...item, status: "已完成" };
return item;
}),
);
@@ -1241,8 +1267,9 @@ export function useStep2Orchestration(
});
setRoles((prev) =>
prev.map((item) =>
// 该席位本轮输出完成 —— 标记为「已完成」(带勾选样式),而非回退到「在线」。
item.id === speakerId && item.status === "研讨中"
? { ...item, status: "在线" }
? { ...item, status: "已完成" }
: item,
),
);
@@ -1254,14 +1281,15 @@ export function useStep2Orchestration(
setActiveSpeakerSet(new Set());
setRoles((prev) =>
prev.map((item) =>
item.status === "研讨中" ? { ...item, status: "在线" } : item,
// 研讨收尾:仍在研讨中的席位置为「已完成」,而非回退到「在线」。
item.status === "研讨中" ? { ...item, status: "已完成" } : item,
),
);
}, [setRoles]);
/**
* DAG 并行批次:把**一整批**席位同时标为「研讨中」(与 markSpeakerStreaming
* 的"排他单发"不同 —— 并行就是要多个 sub 同时高亮)。其余历史残留的"研讨中"清成"在线"。
* 的"排他单发"不同 —— 并行就是要多个 sub 同时高亮)。其余历史残留的"研讨中"标记成"已完成"。
* activeSpeakerSet 天然是 Set,这里放整批 speakerId。
*/
const markParallelSpeakers = useCallback(
@@ -1271,7 +1299,8 @@ export function useStep2Orchestration(
setRoles((prev) =>
prev.map((item) => {
if (wanted.has(item.id)) return { ...item, status: "研讨中" };
if (item.status === "研讨中") return { ...item, status: "在线" };
// 上一并行批次发言已结束的席位标记为「已完成」,而非回退到「在线」。
if (item.status === "研讨中") return { ...item, status: "已完成" };
return item;
}),
);
@@ -1335,7 +1364,89 @@ export function useStep2Orchestration(
const isStale = () =>
orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch;
let nextLeaderTask = seedMessage;
// 串/并行交给**总控判断**(2026-06-13):各席位子任务相互独立 → 同一轮一次性
// 派给多个席位(前端并行执行,单列可写显示);有先后依赖 → 每轮只派一个。
const PARALLEL_JUDGE_NOTE =
"\n\n【派活方式】派活前请先判断各席位子任务之间的依赖关系:相互独立的子任务请在" +
"**同一轮**通过 agent_orchestration 一次性派给多个席位(系统会并行执行、显著提速);" +
"有先后依赖的子任务则每轮只派一个席位,等其交付后再派下一个。";
// 优化1+2(2026-06-13):每个席位任务都拼上「直接产出别提问 + 承接前序 + 结构化交付模板」。
// 提升交付质量,并让 Step3 三产物(尤其大屏 seats.sections)的提炼有稳定结构可循。
const SEAT_DELIVERY_GUIDE =
"\n\n【交付要求】请直接基于已知信息做出合理假设、给出完整交付,**不要向用户提问或等待确认**;" +
"承接此前各席位的交付(需要其它席位产物全文时用 read_peer_delivery 获取),不要重复或推翻已有结论。" +
"交付请按以下结构组织:① 一句话核心结论(先行);② 关键要点(分条列出);" +
"③ 风险 / 注意事项(如有);④ 与其它席位的依赖 / 承接关系(如有)。";
// 优化4:跨轮累积各席位**完整交付**,作为 peerDeliveries 传给后续席位的 run
// (后端注入 context,席位用 read_peer_delivery 按需取全文,不进 prompt 不耗 token)。
const priorDeliveries: Array<{ name: string; content: string }> = [];
// 共享的「单席位交付执行」——串行/并行分支共用。含**失败自动重试一次**(优化5,
// 对齐 Step3 自愈:网络错误 / 502 重试,abort 不重试)。返回交付正文(aborted → 空)。
const runSeatDelivery = async (a: {
agentId: string;
displayName: string;
task: string;
bubbleId: number;
seatThreadId: string | undefined;
isParallel: boolean;
}): Promise<{ content: string; aborted: boolean }> => {
const meta = agentNameToRoleRef.current[a.agentId.toLowerCase()];
const perSeatModel = meta ? agentConfigsRef.current[meta.speakerId]?.model : undefined;
const fullTask = a.task + SEAT_DELIVERY_GUIDE;
for (let attempt = 0; attempt < 2; attempt += 1) {
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
try {
const specRes = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: a.agentId,
displayName: a.displayName,
newMessage: fullTask,
peerDeliveries: priorDeliveries,
model: perSeatModel || selectedModelRef.current || undefined,
signal: subAborter.signal,
onTextDelta: (text) =>
a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(a.bubbleId, label);
appendStepIfNew(a.bubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, a.seatThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, a.seatThreadId),
});
activeAbortersRef.current.delete(subAborter);
if (isStale()) {
finalizeDialogue(a.bubbleId, specRes.content || "(已停止)", "已停止");
return { content: "", aborted: true };
}
finalizeDialogue(a.bubbleId, specRes.content, "子智能体交付");
return { content: specRes.content ?? "", aborted: false };
} catch (err) {
activeAbortersRef.current.delete(subAborter);
if (subAborter.signal.aborted || (err as Error).name === "AbortError") {
finalizeDialogue(a.bubbleId, "(已停止)", "已停止");
return { content: "", aborted: true };
}
if (attempt === 0) {
// 第一次失败 → 清掉半截内容,自动重试一次。
console.warn(`[special:${a.agentId}] 第一次失败,自动重试:`, err);
if (a.isParallel) parallelStreamRef.current.delete(a.bubbleId);
else setDialogueContent(a.bubbleId, "");
setDialogueConfidence(a.bubbleId, "失败重试中…");
continue;
}
console.error(`[special:${a.agentId}] error:`, err);
finalizeDialogue(a.bubbleId, `(错误) ${(err as Error).message}`, "调用失败");
return { content: "", aborted: false };
}
}
return { content: "", aborted: false };
};
let nextLeaderTask = seedMessage + PARALLEL_JUDGE_NOTE;
let safetyCounter = 0;
try {
@@ -1440,86 +1551,97 @@ export function useStep2Orchestration(
break;
}
// **顺序**派活 —— 不要改回 Promise.all,详见 §5.4。
for (const [subName, task] of leaderRes.dispatched as LeaderDispatch[]) {
if (isStale()) break;
const meta = agentNameToRoleRef.current[subName.toLowerCase()];
if (meta) markSpeakerStreaming(meta.speakerId);
const perSeatModel = meta
? agentConfigsRef.current[meta.speakerId]?.model
: undefined;
const seatThreadId = resolveSeatThreadId(ids, subName);
const subBubbleId = appendStreamingDialogue({
sender: meta?.displayName ?? subName,
avatarType: meta?.avatarType ?? "blue",
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
let aborted = false;
try {
const specRes = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: subName,
displayName: meta?.displayName,
newMessage: task,
model: perSeatModel || selectedModelRef.current || undefined,
signal: subAborter.signal,
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(subBubbleId, label);
appendStepIfNew(
subBubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(
phase,
toolName,
args,
seatThreadId,
);
},
onUpstreamFrame: (frame) =>
handleUpstreamFrame(frame, seatThreadId),
const dispatched = leaderRes.dispatched as LeaderDispatch[];
if (dispatched.length > 1) {
// ── 总控一轮派给多个席位 = 它判定子任务相互独立 → **批内并行**执行
// (2026-06-13)。显示走「业务链 sandbox 单列可写」同款:头像条 + 单列
// 消息流 + 沙箱跟随选中席位;席位可正常写文件(seat policy)。
recommendParallelRef.current = true;
const seatPrep = dispatched.map(([subName, task]) => {
const meta = agentNameToRoleRef.current[subName.toLowerCase()];
const seatName = meta?.displayName ?? subName;
const avatarType = meta?.avatarType ?? "blue";
const seatThreadId = resolveSeatThreadId(ids, subName);
const bubbleId = appendStreamingDialogue({
sender: seatName,
avatarType,
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
return { agentId: subName, task, meta, seatName, avatarType, seatThreadId, bubbleId };
});
markParallelSpeakers(
seatPrep
.map((p) => p.meta?.speakerId)
.filter((v): v is number => typeof v === "number"),
);
setParallelBatch(
seatPrep.map((p) => ({
agentId: p.agentId,
displayName: p.seatName,
avatarType: p.avatarType,
status: "running" as const,
bubbleId: p.bubbleId,
})),
);
// 单列(对齐业务链 sandbox 模式):默认选中第一个,点头像切换。
setSelectedParallelSeats(seatPrep.slice(0, 1).map((p) => p.agentId));
const markBatchStatus = (agentId: string, status: ParallelSeatView["status"]) =>
setParallelBatch((prev) =>
prev.map((p) => (p.agentId === agentId ? { ...p, status } : p)),
);
const batchResults = await Promise.all(
seatPrep.map(async ({ agentId, task, meta, seatName, seatThreadId, bubbleId }) => {
const r = await runSeatDelivery({
agentId,
displayName: seatName,
task,
bubbleId,
seatThreadId,
isParallel: true,
});
markBatchStatus(agentId, "done");
if (meta) markSpeakerIdle(meta.speakerId);
parallelStreamRef.current.delete(bubbleId);
return r.content.trim() ? { name: seatName, content: r.content } : null;
}),
);
// 批结束:收起并行显示,回普通流式列表。
setParallelBatch([]);
setSelectedParallelSeats([]);
resetParallelStreams();
recommendParallelRef.current = false;
// 累积本批交付(供后续轮席位 read_peer_delivery 取全文)。
for (const r of batchResults) if (r) priorDeliveries.push(r);
} else {
// **顺序**派活(单席位 / 总控判定有依赖)—— 不要改回 Promise.all,详见 §5.4。
for (const [subName, task] of dispatched) {
if (isStale()) break;
const meta = agentNameToRoleRef.current[subName.toLowerCase()];
if (meta) markSpeakerStreaming(meta.speakerId);
const seatThreadId = resolveSeatThreadId(ids, subName);
const subBubbleId = appendStreamingDialogue({
sender: meta?.displayName ?? subName,
avatarType: meta?.avatarType ?? "blue",
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
const r = await runSeatDelivery({
agentId: subName,
displayName: meta?.displayName ?? subName,
task,
bubbleId: subBubbleId,
seatThreadId,
isParallel: false,
});
if (isStale()) {
finalizeDialogue(
subBubbleId,
specRes.content || "(已停止)",
"已停止",
);
aborted = true;
} else {
finalizeDialogue(subBubbleId, specRes.content, "子智能体交付");
}
} catch (err) {
if (
subAborter.signal.aborted ||
(err as Error).name === "AbortError"
) {
finalizeDialogue(subBubbleId, "(已停止)", "已停止");
aborted = true;
} else {
console.error(`[special:${subName}] error:`, err);
finalizeDialogue(
subBubbleId,
`(错误) ${(err as Error).message}`,
"调用失败",
);
}
} finally {
activeAbortersRef.current.delete(subAborter);
if (meta) markSpeakerIdle(meta.speakerId);
if (r.content.trim()) priorDeliveries.push({ name: meta?.displayName ?? subName, content: r.content });
if (r.aborted) break;
}
if (aborted) break;
}
if (isStale()) break;
@@ -1528,8 +1650,14 @@ export function useStep2Orchestration(
Math.min(95, parseFloat((prev + 8).toFixed(1))),
);
// 优化3:续轮强化收敛指引——信息足够就**明确收口**,结论开头标注【已达成共识】,
// 减少弱模型该收口时还乱派 / 该继续时草草收尾的两类偏差。
nextLeaderTask =
"各子智能体已就上一轮派活完成交付,请你结合最新结论判断是否仍需继续派活;若信息已足够,请直接输出最终方案并停止派活。";
"各子智能体已就上一轮派活完成交付,请你结合最新结论判断:" +
"① 若仍有关键信息缺口或席位职责未覆盖 → 继续派活(按上述派活方式判断串/并行);" +
"② 若信息已足够 → **停止派活**,直接综合各席位交付输出最终方案,并在结论最开头用一行标注" +
"「【已达成共识】(置信度:高/中/低)」。不要在信息不足时草率收口,也不要在已足够时反复派活。" +
PARALLEL_JUDGE_NOTE;
}
if (safetyCounter >= MAX_CYCLES) {
@@ -1549,11 +1677,15 @@ export function useStep2Orchestration(
appendToDialogue,
appendStepIfNew,
setDialogueConfidence,
setDialogueContent,
phaseLabel,
finalizeDialogue,
highlightSpeaker,
markSpeakerStreaming,
markSpeakerIdle,
markParallelSpeakers,
ingestParallelDelta,
resetParallelStreams,
clearAllStreamingSpeakers,
triggerToast,
handleArtifactToolPhase,
@@ -2509,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);
@@ -2526,6 +2663,52 @@ export function useStep2Orchestration(
[],
);
// 「查看=恢复流程」:后台作业 SSE 帧 → 第二步实时画面(只更新展示数据,不动
// 线程/init 闸门 —— 那些由先行的 hydrateFromDraft 建好)。
const applyLiveJobFrame = useCallback(
(frame: {
dialogues: Step2Dialogue[];
seatStubMessages: Record<string, StubAiMessage[]>;
consensusPercentage: number;
hasConsensus: boolean;
lastLeaderContent: string;
}) => {
setStep2RoundtableDialogues(frame.dialogues);
seatStubMessagesRef.current = frame.seatStubMessages;
setSeatStubMessages(frame.seatStubMessages);
setConsensusPercentage(frame.consensusPercentage);
setHasConsensus(frame.hasConsensus);
if (frame.lastLeaderContent) setLastLeaderContent(frame.lastLeaderContent);
},
[],
);
// 「后台挂起接管」:取消后端作业 + 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();
@@ -2603,6 +2786,8 @@ export function useStep2Orchestration(
handleBudgetChange,
handleSaveAgentAndRecalculate,
hydrateFromDraft,
applyLiveJobFrame,
resumeLiveTakeover,
resetStep2State,
activeArtifactThreadId,
seatStubMessages,
@@ -0,0 +1,524 @@
/**
* Step 3「结果绘制 · 大屏多页(模式二)」编排 hook —— 与模式一同款交互(2026-06-13)。
*
* 链路与模式一/总结报告完全同源(`initReportThread` + `streamMultiAgent`,
* 已验证可用):流式对话气泡 + 右侧沙箱预览 + 输入框继续修改。区别在产物:
* - 模型只输出 ```report-json 结构化数据(buildDashboardPrompt,禁工具禁文件);
* - 前端 `renderDashboardHtml` 模板渲染成大屏 HTML,**种成 write_file 虚拟 stub**
* (path = DASHBOARD_OUTPUT_PATH)→ 走与模式一相同的沙箱 HTML 预览(含代码/预览
* 切换、下载、新窗口打开);
* - `ask(message)`:同线程续轮,要求模型输出**修订后的全量** report-json → 重渲染
* → 更新 stub 与草稿快照。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import {
initReportThread,
streamMultiAgent,
type StreamPhase,
} from "@/roundtable-planning/api/multi-agent";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
import type {
Step2Dialogue,
StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import {
buildDashboardPrompt,
DASHBOARD_OUTPUT_PATH,
extractReportData,
isDashboardHtml,
parseReportJson,
renderDashboardHtml,
type ReportData,
} from "@/roundtable-planning/lib/report-template";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
const DASH_AGENT_FALLBACK = "roundtable-report";
export type DashboardPhase = "idle" | "creating" | "streaming" | "done" | "error";
interface UseStep3DashboardOptions {
intentReady: ReportIntent | null;
seatDeliveries: Array<{ sender: string; content: string }>;
consensusText: string;
selectedModel: string;
/** 当前界面主题 —— 大屏按它取色(light=浅红色 UI 规范 / dark=深蓝色 UI 规范)。 */
themeMode: "light" | "dark";
savedReport?: DraftStep3Snapshot | null;
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
}
export interface UseStep3DashboardReturn {
phase: DashboardPhase;
error: string | null;
dashThreadId: string | null;
dialogues: Step2Dialogue[];
seatStubMessages: Record<string, StubAiMessage[]>;
activeArtifactThreadId: string | null;
busy: boolean;
/** 当前大屏 HTML(已渲染;无则 null)。 */
html: string | null;
/** 当前大屏的结构化数据(report-json 解析结果)—— 供应用内 React 大屏直接渲染。 */
data: ReportData | null;
start: () => void;
redraw: () => void;
/** 用户输入修改要求 → 同线程续轮,输出修订版全量数据并重渲染。 */
ask: (message: string) => void;
abort: () => void;
/** 本流是否允许**自动**打开沙箱预览(视图挂载=true / 卸载=false;并行流防抢沙箱)。 */
setPreviewEnabled: (enabled: boolean) => void;
/** 从草稿恢复(带 marker 的大屏 HTML,旧版存稿):种 stub + 完成气泡,不自动开沙箱。 */
hydrateSaved: (html: string) => void;
/** 从草稿/后台作业恢复(dashboardJson 结构化数据):按**当前主题**渲染后展示。 */
hydrateFromJson: (jsonStr: string) => void;
/** 手动打开大屏沙箱预览(恢复后/用户点气泡)。 */
openDashboardPreview: () => void;
}
function formatNow(): string {
const d = new Date();
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function dashPhaseLabel(phase: StreamPhase): string {
switch (phase) {
case "connecting":
return "调度中";
case "connected":
return "已连接,等待响应";
case "streaming":
return "正在提炼大屏数据";
default:
return "正在输出…";
}
}
export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3DashboardReturn {
const {
intentReady,
seatDeliveries,
consensusText,
selectedModel,
themeMode,
savedReport,
onReportSaved,
} = options;
const onSavedRef = useRef(onReportSaved);
useEffect(() => {
onSavedRef.current = onReportSaved;
}, [onReportSaved]);
const { select: selectArtifact, setOpen: setArtifactsOpen, open: artifactsOpen } = useArtifacts();
const artifactsOpenRef = useRef(artifactsOpen);
useEffect(() => {
artifactsOpenRef.current = artifactsOpen;
}, [artifactsOpen]);
const [phase, setPhase] = useState<DashboardPhase>("idle");
const [error, setError] = useState<string | null>(null);
const [dashThreadId, setDashThreadId] = useState<string | null>(null);
const [dialogues, setDialogues] = useState<Step2Dialogue[]>([]);
const [seatStubMessages, setSeatStubMessages] = useState<Record<string, StubAiMessage[]>>({});
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<string | null>(null);
const [html, setHtml] = useState<string | null>(null);
const [data, setData] = useState<ReportData | null>(null);
const stubRef = useRef<Record<string, StubAiMessage[]>>({});
const runningRef = useRef(false);
const aborterRef = useRef<AbortController | null>(null);
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 => {
const id = Date.now() + Math.random();
setDialogues((prev) => [
...prev,
{
id,
sender: "大屏绘制智能体",
avatarType: "blue",
content: "",
time: nowTime(),
confidence: "调度中",
streaming: true,
steps: [],
agentThreadId: threadId,
},
]);
return id;
}, []);
const appendUserDialogue = useCallback((text: string) => {
setDialogues((prev) => [
...prev,
{
id: Date.now() + Math.random(),
sender: "指挥官(修改要求)",
avatarType: "human",
content: text,
time: nowTime(),
confidence: "已发送",
streaming: false,
steps: [],
},
]);
}, []);
const appendToDialogue = useCallback((id: number, deltaText: string) => {
if (!deltaText) return;
setDialogues((prev) =>
prev.map((d) => (d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d)),
);
}, []);
const setDialogueConfidence = useCallback((id: number, confidence: string) => {
setDialogues((prev) => prev.map((d) => (d.id === id ? { ...d, confidence } : d)));
}, []);
const finalizeDialogue = useCallback((id: number, finalContent: string, confidence?: string) => {
setDialogues((prev) =>
prev.map((d) => {
if (d.id !== id) return d;
const next = finalContent?.trim() ? finalContent : d.content || "(无文本输出)";
return { ...d, content: next, confidence: confidence ?? d.confidence, streaming: false };
}),
);
}, []);
// ── 大屏 stub(write_file 虚拟产物 → 沙箱 HTML 预览) ──
const upsertDashStub = useCallback((threadId: string, htmlContent: string) => {
const messageId = `roundtable-dashboard-stub-${threadId}`;
const toolCallId = `tc-${threadId}-0`;
const msg: StubAiMessage = {
id: messageId,
type: "ai",
content: "",
tool_calls: [
{ id: toolCallId, name: "write_file", args: { path: DASHBOARD_OUTPUT_PATH, content: htmlContent } },
],
};
const nextMap = { ...stubRef.current, [threadId]: [msg] };
stubRef.current = nextMap;
setSeatStubMessages(nextMap);
return { messageId, toolCallId };
}, []);
// 预览开关(并行流防抢沙箱):只有用户当前所在视图的流允许**自动**打开沙箱。
const previewEnabledRef = useRef(false);
const lastAutoOpenRef = useRef<{ threadId: string; messageId: string; toolCallId: string } | null>(
null,
);
const openDashboardArtifact = useCallback(
(threadId: string, messageId: string, toolCallId: string, opts?: { force?: boolean }) => {
lastAutoOpenRef.current = { threadId, messageId, toolCallId };
if (!opts?.force && !previewEnabledRef.current) return; // 不在前台只记录不抢
const virtualUrl = new URL(
`write-file:${DASHBOARD_OUTPUT_PATH}?message_id=${encodeURIComponent(messageId)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString();
setActiveArtifactThreadId(threadId);
selectArtifact(virtualUrl, false);
setArtifactsOpen(true);
},
[selectArtifact, setArtifactsOpen],
);
const setPreviewEnabled = useCallback(
(enabled: boolean) => {
previewEnabledRef.current = enabled;
// 进入大屏「交互区」即视为用户想看抽取过程:有已记录的预览锚点(流式 stub /
// 已渲染 HTML)就**强制接管沙箱**展示本流产物(之前只在面板已开时接管,会导致
// 中途进交互区看不到抽取过程的空白)。离开交互区(enabled=false)只让出预览权,
// 不主动关面板(沙箱可继续停留,切回经典/其它流时各自接管)。
const last = lastAutoOpenRef.current;
if (enabled && last) {
openDashboardArtifact(last.threadId, last.messageId, last.toolCallId, { force: true });
}
},
[openDashboardArtifact],
);
const openDashboardPreview = useCallback(() => {
const tid = liveThreadRef.current ?? activeArtifactThreadId ?? "roundtable-dashboard-restored";
const current = htmlRef.current;
if (!current) return;
const { messageId, toolCallId } = upsertDashStub(tid, current);
openDashboardArtifact(tid, messageId, toolCallId, { force: true }); // 用户显式点击不受预览开关限制
}, [activeArtifactThreadId, upsertDashStub, openDashboardArtifact]);
const abort = useCallback(() => {
aborterRef.current?.abort();
aborterRef.current = null;
runningRef.current = false;
}, []);
/** 一轮流式:发 message 给大屏绘制线程,**生成过程实时进沙箱**(结构化数据边出
* 边以代码态显示,对齐模式一"看着 HTML 被写出来"的体验),结束后解析
* report-json → 按当前主题渲染 → 沙箱自动切预览。 */
const streamOnce = useCallback(
async (args: { bubbleId: number; message: string; aborter: AbortController }) => {
const { bubbleId, message, aborter } = args;
let threadId = liveThreadRef.current;
if (!threadId) {
const init = await initReportThread({ signal: aborter.signal });
if (aborter.signal.aborted) return;
threadId = init.threadId;
agentNameRef.current = init.agentName || DASH_AGENT_FALLBACK;
liveThreadRef.current = threadId;
setDashThreadId(threadId);
}
setActiveArtifactThreadId(threadId);
setDialogues((prev) =>
prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId! } : d)),
);
setPhase("streaming");
// 生成开始就打开沙箱:流式期间 stub 内容=模型已输出的结构化数据(代码态展示),
// 完成后替换成渲染好的大屏 HTML(isLoading 翻 false → 预览自动切换)。
let acc = "";
let lastFlush = 0;
const initial = upsertDashStub(threadId, "(正在连接模型,结构化数据稍后在此实时显示…)");
openDashboardArtifact(threadId, initial.messageId, initial.toolCallId);
const flushStub = (force = false) => {
const now = Date.now();
if (!force && now - lastFlush < 200) return;
lastFlush = now;
upsertDashStub(threadId!, acc);
};
const res = await streamMultiAgent({
agentType: "special",
agentName: agentNameRef.current,
dAgentThreadId: { [agentNameRef.current]: threadId },
displayName: "大屏绘制",
newMessage: message,
model: selectedModel || undefined,
signal: aborter.signal,
onTextDelta: (text) => {
appendToDialogue(bubbleId, text);
acc += text;
flushStub();
},
onPhaseChange: (p) => setDialogueConfidence(bubbleId, dashPhaseLabel(p)),
});
if (aborter.signal.aborted) return;
acc = res.content || acc;
flushStub(true);
const data = extractReportData(res.content);
if (!data) {
// 解析失败:把 stub 还原成上一版可用的大屏(若有),别让沙箱停在生 JSON 上。
if (htmlRef.current) upsertDashStub(threadId, htmlRef.current);
finalizeDialogue(
bubbleId,
(res.content || "").trim() ||
"(模型没有输出有效的 report-json 数据,请点「重新绘制」或输入更明确的修改要求重试)",
"数据解析失败",
);
setError("模型没有输出有效的 report-json 结构化数据");
setPhase("error");
return;
}
const rendered = renderDashboardHtml(data, { generatedAt: formatNow(), theme: themeMode });
htmlRef.current = rendered;
setHtml(rendered);
setData(data); // 同步结构化数据 → 应用内 React 大屏可直接渲染
dataRef.current = data; // 记下当前数据,供下一轮修改时喂回模型做最小改动
const { messageId, toolCallId } = upsertDashStub(threadId, rendered);
openDashboardArtifact(threadId, messageId, toolCallId);
finalizeDialogue(bubbleId, res.content, "大屏绘制完成");
setPhase("done");
setError(null);
// **只存结构化数据**(dashboardJson),不写 step3.html —— html 槽位归经典模式一,
// 两者并行绘制互不覆盖;查看时按当前主题重新渲染,主题切换天然适配。
onSavedRef.current?.({ dashboardJson: JSON.stringify(data) });
},
[
selectedModel,
themeMode,
appendToDialogue,
setDialogueConfidence,
finalizeDialogue,
upsertDashStub,
openDashboardArtifact,
],
);
const run = useCallback(async () => {
if (runningRef.current) return;
runningRef.current = true;
setError(null);
setPhase("creating");
setDialogues([]);
stubRef.current = {};
setSeatStubMessages({});
liveThreadRef.current = null;
const aborter = new AbortController();
aborterRef.current = aborter;
const bubbleId = appendStreamingDialogue();
try {
const intentText = [
intentReady?.objective ?? "",
...(intentReady?.constraints ?? []).map((c) => `约束:${c}`),
...(intentReady?.assumptions ?? []).map((a) => `假设:${a}`),
]
.filter(Boolean)
.join("\n");
const prompt = buildDashboardPrompt({ intentText, seatDeliveries, consensusText });
await streamOnce({ bubbleId, message: prompt, aborter });
} catch (err) {
if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
finalizeDialogue(bubbleId, `(绘制失败) ${msg}`, "绘制失败");
setPhase("error");
} finally {
if (aborterRef.current === aborter) runningRef.current = false;
}
}, [intentReady, seatDeliveries, consensusText, appendStreamingDialogue, streamOnce, finalizeDialogue]);
const ask = useCallback(
async (message: string) => {
const text = message.trim();
if (!text) return;
const wasBusy = runningRef.current;
abort();
if (wasBusy) {
setDialogues((prev) =>
prev.map((d) => (d.streaming ? { ...d, streaming: false, confidence: "已打断" } : d)),
);
}
runningRef.current = true;
setError(null);
const aborter = new AbortController();
aborterRef.current = aborter;
appendUserDialogue(text);
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}${currentBlock}\n\n(系统指令:在上面给出的“当前大屏完整数据”基础上,` +
"**只修改用户点名要改的字段**,其余字段(标题/各席位/图表/共识/时间线等)必须逐字保留、不得改写或删减;" +
"在回复最末尾输出**修订后的完整全量** ```report-json 代码块(不是增量、必须包含全部字段)," +
"正文只给一两句修改说明;不要调用任何工具、不要写文件、不要输出 HTML。)";
await streamOnce({ bubbleId, message: prompt, aborter });
} catch (err) {
if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
finalizeDialogue(bubbleId, `(处理失败) ${msg}`, "处理失败");
setPhase("error");
} finally {
if (aborterRef.current === aborter) runningRef.current = false;
}
},
[abort, appendUserDialogue, appendStreamingDialogue, streamOnce, finalizeDialogue],
);
const hydrateSaved = useCallback(
(savedHtml: string) => {
abort();
runningRef.current = false;
htmlRef.current = savedHtml;
setHtml(savedHtml);
// 纯 HTML 恢复路径(旧版 marker 存稿):清掉上一条草稿残留的结构化 data,
// 否则应用内 React 大屏的 dashboardData memo 会优先用旧 data、不随切换刷新。
// (hydrateFromJson 会在调用本函数后再 setData(parsed) 覆盖回正确数据。)
setData(null);
dataRef.current = null; // 纯 HTML 恢复无结构化基线;hydrateFromJson 随后会写回
const synthThreadId = "roundtable-dashboard-restored";
liveThreadRef.current = null; // 合成线程非真线程:续问会新建线程重新生成
setDashThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId);
const { messageId, toolCallId } = upsertDashStub(synthThreadId, savedHtml);
// 记录预览锚点(不打开):进入大屏视图时若沙箱已开着,可立即接管显示本流产物。
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
const bubbleId = Date.now() + Math.random();
setDialogues([
{
id: bubbleId,
sender: "大屏绘制智能体",
avatarType: "blue",
content:
"已从历史记录恢复上次生成的大屏。点击下方「打开大屏预览」查看,或输入修改要求重新生成。",
time: nowTime(),
confidence: "历史记录",
streaming: false,
steps: [],
agentThreadId: synthThreadId,
},
]);
setError(null);
setPhase("done");
// 不自动开沙箱(对齐 06-12「进第三步默认关闭」策略),用户点按钮随时可开。
},
[abort, upsertDashStub],
);
/** 从 dashboardJson(草稿/后台作业存的结构化数据)恢复:按当前主题渲染。 */
const hydrateFromJson = useCallback(
(jsonStr: string) => {
const parsed = parseReportJson(jsonStr);
if (!parsed) return;
const rendered = renderDashboardHtml(parsed, { generatedAt: formatNow(), theme: themeMode });
hydrateSaved(rendered); // 先恢复 HTML 沙箱路径(内部会 setData(null) 清残留)
setData(parsed); // 再写回本草稿结构化数据 → 应用内 React 大屏直接渲染(批量更新,最终生效)
dataRef.current = parsed; // 续轮修改的基线 → 从历史恢复后也能精准局部改
},
[themeMode, hydrateSaved],
);
// 恢复路径辅助:savedReport 由组件/页面按字段调用对应 hydrate;这里只消除未用告警。
void savedReport;
void isDashboardHtml;
const start = useCallback(() => {
void run();
}, [run]);
const redraw = useCallback(() => {
abort();
liveThreadRef.current = null; // 全新线程重画(避免旧上下文把修订当底稿)
void run();
}, [abort, run]);
const busy = phase === "creating" || phase === "streaming";
return {
phase,
error,
dashThreadId,
dialogues,
seatStubMessages,
activeArtifactThreadId,
busy,
html,
data,
start,
redraw,
ask: (m: string) => void ask(m),
abort,
setPreviewEnabled,
hydrateSaved,
hydrateFromJson,
openDashboardPreview,
};
}
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more