Merge branch 'origin/dhk-1' into dhk-1
Resolve CLAUDE.md conflict by keeping remote version.
This commit is contained in:
commit
ce04f14f58
130 files changed
+14153
-3272
No files matched your search
@@ -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>
|
||||
```
|
||||
@@ -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}
|
||||
@@ -120,10 +122,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 |
@@ -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 |
@@ -379,6 +379,13 @@
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* 左右分组:不撑满、不裁切选中高光 */
|
||||
.top-submenu-bar--group {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.top-submenu-list {
|
||||
min-width: max-content;
|
||||
height: 64px;
|
||||
@@ -398,48 +405,31 @@
|
||||
border: 0;
|
||||
padding: 0 18px;
|
||||
background: transparent;
|
||||
color: rgba(224, 242, 255, 0.9);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif;
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: color 0.18s ease, text-shadow 0.18s ease, background 0.18s ease;
|
||||
overflow: visible;
|
||||
transition: color 0.18s ease, text-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.top-submenu-item:hover,
|
||||
.top-submenu-item:hover {
|
||||
color: #9fe3ff;
|
||||
text-shadow: 0 0 10px rgba(91, 210, 255, 0.4);
|
||||
}
|
||||
|
||||
/* 选中态:设计稿提供的 nav-active.svg 发光高光 + 青色文字 */
|
||||
.top-submenu-item--active {
|
||||
color: #ffffff;
|
||||
text-shadow: 0 0 10px rgba(91, 210, 255, 0.45);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(15, 84, 169, 0.52) 0%, rgba(5, 43, 103, 0.34) 76%),
|
||||
linear-gradient(180deg, rgba(39, 126, 230, 0.2) 0%, rgba(0, 50, 122, 0.26) 100%);
|
||||
box-shadow: inset 0 1px 0 rgba(98, 180, 255, 0.16), inset 0 -18px 22px rgba(0, 31, 79, 0.24);
|
||||
}
|
||||
|
||||
.top-submenu-item--active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, rgba(59, 162, 255, 0) 0%, #35d7ff 50%, rgba(59, 162, 255, 0) 100%);
|
||||
box-shadow: 0 0 12px rgba(53, 215, 255, 0.9), 0 -6px 18px rgba(33, 143, 255, 0.36);
|
||||
}
|
||||
|
||||
.top-submenu-item--active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 0;
|
||||
height: 16px;
|
||||
background: radial-gradient(ellipse at bottom, rgba(37, 189, 255, 0.34) 0%, rgba(37, 189, 255, 0) 68%);
|
||||
pointer-events: none;
|
||||
color: #64cdf5;
|
||||
text-shadow: 0 0 12px rgba(100, 205, 245, 0.5);
|
||||
background-image: url("../assets/img/nav-active.svg");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center calc(50% + 7px);
|
||||
background-size: 152px 42px;
|
||||
}
|
||||
|
||||
.third-submenu-bar {
|
||||
|
||||
@@ -77,8 +77,8 @@ export function PageSidebar({
|
||||
compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100"
|
||||
} ${small ? "text-xs" : "text-sm"} flex-1 font-medium`;
|
||||
const menuButtonClass = (isActive: boolean, depth = 0) =>
|
||||
`flex w-full items-center rounded-lg py-2 text-left transition-all duration-300 ease-out hover:-translate-y-0.5 theme-transition ${
|
||||
collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3"
|
||||
`group relative flex w-full items-center rounded-[2px] py-2 text-left transition-all duration-300 ease-out theme-transition ${
|
||||
collapsed && depth === 0 ? "justify-center px-2" : "gap-3 px-3"
|
||||
} ${
|
||||
isActive
|
||||
? isDark
|
||||
@@ -114,7 +114,14 @@ export function PageSidebar({
|
||||
onClick={() => item.path && navigate(item.path)}
|
||||
className={menuButtonClass(isActive, depth)}
|
||||
>
|
||||
<Icon className={`${depth === 0 ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out`} />
|
||||
{isActive && (
|
||||
<span
|
||||
className={`absolute left-0 inset-y-0 w-[3px] rounded-r ${
|
||||
isDark ? "bg-cyan-300" : "bg-red-600"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<Icon className={`${depth === 0 ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105`} />
|
||||
{(!collapsed || depth > 0) && (
|
||||
<span className={labelClass(false, depth > 0)}>
|
||||
{item.label}
|
||||
@@ -144,7 +151,7 @@ export function PageSidebar({
|
||||
className={menuButtonClass(false, depth)}
|
||||
>
|
||||
<Icon
|
||||
className={`${isTopLevel ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out ${
|
||||
className={`${isTopLevel ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105 ${
|
||||
expanded && depth > 0 ? "scale-105" : ""
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent, AgentSort } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -29,6 +30,8 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
||||
export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [viewMode, setViewMode] = useViewMode("agent");
|
||||
const [activeTab, setActiveTab] = useState<string>("mine");
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -58,15 +61,21 @@ export function AgentGallery() {
|
||||
// If the active square tab disappears (admin removed that square), fall back
|
||||
// to "我的" so the gallery never points at a dead tab.
|
||||
useEffect(() => {
|
||||
// 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。
|
||||
if (activeTab === "builtin" && !isAdmin) {
|
||||
setActiveTab("mine");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
activeTab !== "mine" &&
|
||||
activeTab !== "private" &&
|
||||
activeTab !== "builtin" &&
|
||||
squareTabs.length > 0 &&
|
||||
!squareTabs.some((sq) => sq.id === activeTab)
|
||||
) {
|
||||
setActiveTab("mine");
|
||||
}
|
||||
}, [activeTab, squareTabs]);
|
||||
}, [activeTab, squareTabs, isAdmin]);
|
||||
|
||||
const { data: privateStatus } = usePrivateSquareStatus();
|
||||
const privateFeatureAvailable = !!privateStatus?.enabled;
|
||||
@@ -80,9 +89,16 @@ export function AgentGallery() {
|
||||
const [passwordInput, setPasswordInput] = useState("");
|
||||
|
||||
// Only the active tab fetches. "广场" maps to the server "square" scope
|
||||
// (built-in/published, never private), "我的" to "mine" (owned + favorited).
|
||||
const isSquareTab = activeTab !== "mine" && activeTab !== "private";
|
||||
const mainScope = activeTab === "mine" ? "mine" : "square";
|
||||
// (built-in/published, never private), "我的" to "mine" (owned + favorited),
|
||||
// "内置" to "builtin" (admin-only, user_id IS NULL).
|
||||
const isSquareTab =
|
||||
activeTab !== "mine" && activeTab !== "private" && activeTab !== "builtin";
|
||||
const mainScope =
|
||||
activeTab === "mine"
|
||||
? "mine"
|
||||
: activeTab === "builtin"
|
||||
? "builtin"
|
||||
: "square";
|
||||
const mainPage = pages[activeTab] ?? 1;
|
||||
const {
|
||||
agents: mainAgents,
|
||||
@@ -393,6 +409,12 @@ export function AgentGallery() {
|
||||
私密广场
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<TabsTrigger value="builtin" title="内置/功能型智能体(仅管理员可见)">
|
||||
<BotIcon className="mr-1 h-3.5 w-3.5" />
|
||||
内置智能体
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
@@ -490,6 +512,19 @@ export function AgentGallery() {
|
||||
})}
|
||||
</TabsContent>
|
||||
|
||||
{isAdmin && (
|
||||
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{renderListBody({
|
||||
list: mainAgents,
|
||||
total: mainTotal,
|
||||
isLoading: mainLoading,
|
||||
isFetching: mainFetching,
|
||||
tab: "builtin",
|
||||
emptyDescription: "暂时没有内置智能体。",
|
||||
})}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{privateFeatureAvailable && (
|
||||
<TabsContent
|
||||
value="private"
|
||||
|
||||
@@ -29,6 +29,8 @@ interface AgentSelectorProps {
|
||||
selectedAgentId: string | null;
|
||||
onAgentChange: (agentId: string | null) => void;
|
||||
disabled?: boolean;
|
||||
/** 紧凑模式(CM 参考图):触发器改为灰底胶囊 + 主色机器人图标。 */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +46,7 @@ export function AgentSelector({
|
||||
selectedAgentId,
|
||||
onAgentChange,
|
||||
disabled,
|
||||
compact = false,
|
||||
}: AgentSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
@@ -76,12 +79,17 @@ export function AgentSelector({
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full border px-3! shadow-none transition-colors",
|
||||
selectedAgent
|
||||
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
|
||||
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
compact
|
||||
? "border-transparent bg-gray-50/60 text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
: selectedAgent
|
||||
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
|
||||
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
||||
)}
|
||||
>
|
||||
<BotIcon className="size-3" />
|
||||
<BotIcon
|
||||
className={cn("size-3", compact && "text-[var(--primary)]")}
|
||||
/>
|
||||
<span className="max-w-[140px] truncate text-xs font-normal">
|
||||
{triggerLabel}
|
||||
</span>
|
||||
|
||||
@@ -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,
|
||||
@@ -512,7 +513,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 +560,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 +590,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 +633,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 +693,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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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";
|
||||
@@ -42,9 +43,10 @@ const languageOptions: { value: Locale; label: string }[] = [
|
||||
{ value: "zh-CN", label: zhCN.locale.localName },
|
||||
];
|
||||
|
||||
export function AppearanceSettingsPage() {
|
||||
export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
const { t, locale, changeLocale } = useI18n();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const currentTheme = theme;
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
@@ -58,15 +60,15 @@ export function AppearanceSettingsPage() {
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
|
||||
const handleSetGlobalDefault = async (value: "light" | "dark") => {
|
||||
const handleSetGlobalDefault = async (
|
||||
value: "light" | "dark" | "lightblue",
|
||||
) => {
|
||||
if (value === globalDefault) return;
|
||||
try {
|
||||
await updateAppearance.mutateAsync({ default_theme: value });
|
||||
toast.success(
|
||||
value === "light"
|
||||
? "已将系统默认主题设置为浅色"
|
||||
: "已将系统默认主题设置为深色",
|
||||
);
|
||||
const label =
|
||||
value === "light" ? "浅色" : value === "dark" ? "深色" : "浅蓝";
|
||||
toast.success(`已将系统默认主题设置为${label}`);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
@@ -107,12 +109,20 @@ export function AppearanceSettingsPage() {
|
||||
description: t.settings.appearance.darkDescription,
|
||||
icon: MoonIcon,
|
||||
},
|
||||
{
|
||||
id: "lightblue",
|
||||
label: t.settings.appearance.lightblue,
|
||||
description: t.settings.appearance.lightblueDescription,
|
||||
icon: DropletIcon,
|
||||
},
|
||||
],
|
||||
[
|
||||
t.settings.appearance.dark,
|
||||
t.settings.appearance.darkDescription,
|
||||
t.settings.appearance.light,
|
||||
t.settings.appearance.lightDescription,
|
||||
t.settings.appearance.lightblue,
|
||||
t.settings.appearance.lightblueDescription,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -130,13 +140,49 @@ export function AppearanceSettingsPage() {
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
active={currentTheme === option.id}
|
||||
mode={option.id as "light" | "dark"}
|
||||
mode={option.id as "light" | "dark" | "lightblue"}
|
||||
onSelect={(value) => setTheme(value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
title="界面模式"
|
||||
description="全量模式展示侧边栏与顶栏导航;简洁模式隐藏侧边栏与顶栏,获得更大的工作区。"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={settings.display.layoutMode !== "compact" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSettings("display", { layoutMode: "full" });
|
||||
onClose?.();
|
||||
navigate("/page/workspace/chats/new");
|
||||
}}
|
||||
>
|
||||
<PanelLeftIcon className="mr-1.5 size-4" />
|
||||
全量模式
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={settings.display.layoutMode === "compact" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSettings("display", { layoutMode: "compact" });
|
||||
onClose?.();
|
||||
navigate("/page/workspace/chats/new");
|
||||
}}
|
||||
>
|
||||
<LayoutIcon className="mr-1.5 size-4" />
|
||||
简洁模式
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
@@ -165,9 +211,23 @@ export function AppearanceSettingsPage() {
|
||||
<MoonIcon className="mr-1.5 size-4" />
|
||||
深色
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalDefault === "lightblue" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetGlobalDefault("lightblue")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<DropletIcon className="mr-1.5 size-4" />
|
||||
浅蓝
|
||||
</Button>
|
||||
<p className="text-muted-foreground ml-2 text-xs">
|
||||
当前系统默认:
|
||||
{globalDefault === "light" ? "浅色" : "深色"}
|
||||
{globalDefault === "light"
|
||||
? "浅色"
|
||||
: globalDefault === "dark"
|
||||
? "深色"
|
||||
: "浅蓝"}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
@@ -370,8 +430,8 @@ function ThemePreviewCard({
|
||||
label: string;
|
||||
description: string;
|
||||
active: boolean;
|
||||
mode: "light" | "dark";
|
||||
onSelect: (mode: "light" | "dark") => void;
|
||||
mode: "light" | "dark" | "lightblue";
|
||||
onSelect: (mode: "light" | "dark" | "lightblue") => void;
|
||||
}) {
|
||||
const previewMode = mode;
|
||||
return (
|
||||
@@ -401,14 +461,20 @@ function ThemePreviewCard({
|
||||
"relative overflow-hidden rounded-md border text-xs transition-colors",
|
||||
previewMode === "dark"
|
||||
? "border-neutral-800 bg-neutral-900 text-neutral-200"
|
||||
: "border-slate-200 bg-white text-slate-900",
|
||||
: previewMode === "lightblue"
|
||||
? "border-blue-200 bg-white text-slate-900"
|
||||
: "border-slate-200 bg-white text-slate-900",
|
||||
)}
|
||||
>
|
||||
<div className="border-border/50 flex items-center gap-2 border-b px-3 py-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
previewMode === "dark" ? "bg-emerald-400" : "bg-emerald-500",
|
||||
previewMode === "dark"
|
||||
? "bg-emerald-400"
|
||||
: previewMode === "lightblue"
|
||||
? "bg-blue-500"
|
||||
: "bg-emerald-500",
|
||||
)}
|
||||
/>
|
||||
<div className="h-2 w-10 rounded-full bg-current/20" />
|
||||
|
||||
@@ -121,7 +121,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
|
||||
<div className="min-w-0 space-y-8 p-6">
|
||||
{activeSection === "account" && <AccountSettingsPage />}
|
||||
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
||||
{activeSection === "appearance" && (
|
||||
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
|
||||
)}
|
||||
{activeSection === "memory" && <MemorySettingsPage />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "about" && <AboutSettingsPage />}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -33,10 +33,19 @@ const NEW_CHAT_CLASS =
|
||||
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
|
||||
"dark:text-primary-foreground dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25";
|
||||
|
||||
// 紧凑模式(CM 参考图):白卡描边、居中、主色 + 图标
|
||||
const NEW_CHAT_COMPACT_CLASS =
|
||||
"h-9 justify-center gap-2 rounded-xl bg-card text-[13px] font-semibold text-foreground transition-colors " +
|
||||
"border border-[color-mix(in_srgb,var(--primary)_18%,transparent)] " +
|
||||
"hover:bg-[color-mix(in_srgb,var(--primary)_4%,transparent)] hover:text-[var(--primary)] hover:border-[color-mix(in_srgb,var(--primary)_32%,transparent)] " +
|
||||
"data-[active=true]:bg-card data-[active=true]:text-[var(--primary)] data-[active=true]:shadow-none";
|
||||
|
||||
export function WorkspaceNavChatList({
|
||||
showCollapseTrigger = false,
|
||||
compact = false,
|
||||
}: {
|
||||
showCollapseTrigger?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
@@ -60,21 +69,46 @@ export function WorkspaceNavChatList({
|
||||
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
|
||||
<SidebarMenu className="gap-1">
|
||||
<SidebarMenuItem>
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats/new"}
|
||||
asChild
|
||||
className={cn(NEW_CHAT_CLASS, showCollapseTrigger && "min-w-0 flex-1")}
|
||||
>
|
||||
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
|
||||
<MessageSquarePlus />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{showCollapseTrigger && (
|
||||
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
{compact ? (
|
||||
// 紧凑模式:新对话占满整行白卡样式 + 右侧收起钮(点击收起整列历史栏)。
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats/new"}
|
||||
asChild
|
||||
className={cn(NEW_CHAT_COMPACT_CLASS, "min-w-0 flex-1")}
|
||||
>
|
||||
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
|
||||
<Plus className="stroke-[2.5] text-[var(--primary)]" />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{showCollapseTrigger && (
|
||||
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats/new"}
|
||||
asChild
|
||||
className={cn(
|
||||
NEW_CHAT_CLASS,
|
||||
showCollapseTrigger && "min-w-0 flex-1",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/page/workspace/chats/new"
|
||||
onClick={handleNewChatClick}
|
||||
>
|
||||
<MessageSquarePlus />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{showCollapseTrigger && (
|
||||
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
{!showCollapseTrigger && (
|
||||
<>
|
||||
|
||||
@@ -27,11 +27,47 @@ export function WorkspaceSidebar({
|
||||
const { theme } = useTheme();
|
||||
const useWhiteSidebar = compactChrome && theme === "light";
|
||||
|
||||
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
||||
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
|
||||
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
|
||||
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
|
||||
if (compactChrome) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!isSidebarOpen}
|
||||
className={cn(
|
||||
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
|
||||
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="none"
|
||||
className={cn(
|
||||
"h-full w-(--sidebar-width)",
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
<RecentChatList compact />
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="icon"
|
||||
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
|
||||
// 非紧凑沿用 icon。
|
||||
collapsible={compactChrome ? "none" : "icon"}
|
||||
className={cn(
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
@@ -45,10 +81,15 @@ export function WorkspaceSidebar({
|
||||
</SidebarHeader>
|
||||
)}
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
|
||||
{isSidebarOpen && <RecentChatList />}
|
||||
{isSidebarOpen && <RecentRoundtableList />}
|
||||
{isSidebarOpen && <RecentWritingList />}
|
||||
<WorkspaceNavChatList
|
||||
showCollapseTrigger={compactChrome}
|
||||
compact={compactChrome}
|
||||
/>
|
||||
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
|
||||
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
|
||||
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
|
||||
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
|
||||
{isSidebarOpen && !compactChrome && <RecentWritingList />}
|
||||
</SidebarContent>
|
||||
{!compactChrome && (
|
||||
<SidebarFooter>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useTheme as useNextTheme } from 'next-themes';
|
||||
import { useAppearanceSettings } from '@/core/system-settings/hooks';
|
||||
|
||||
// 主题类型
|
||||
export type ThemeType = 'dark' | 'light';
|
||||
export type ThemeType = 'dark' | 'light' | 'lightblue';
|
||||
|
||||
// 主题配置
|
||||
export interface ThemeConfig {
|
||||
@@ -82,6 +82,28 @@ const themes: Record<ThemeType, ThemeConfig> = {
|
||||
overlay: 'black/30',
|
||||
},
|
||||
},
|
||||
lightblue: {
|
||||
type: 'lightblue',
|
||||
name: '浅蓝主题',
|
||||
colors: {
|
||||
background: 'white',
|
||||
backgroundSecondary: 'blue-50',
|
||||
text: 'gray-800',
|
||||
textSecondary: 'gray-500',
|
||||
primary: 'blue-600',
|
||||
primaryHover: 'blue-500',
|
||||
border: 'blue-200',
|
||||
accent: 'blue-700',
|
||||
success: 'emerald-600',
|
||||
warning: 'amber-500',
|
||||
error: 'red-600',
|
||||
card: 'white',
|
||||
cardHover: 'blue-50',
|
||||
input: 'white',
|
||||
inputBorder: 'blue-200',
|
||||
overlay: 'black/30',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'strategy-theme';
|
||||
@@ -97,7 +119,9 @@ function readPersistedTheme(): ThemeType {
|
||||
return 'light';
|
||||
}
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return saved === 'dark' ? 'dark' : 'light';
|
||||
if (saved === 'dark') return 'dark';
|
||||
if (saved === 'lightblue') return 'lightblue';
|
||||
return 'light';
|
||||
}
|
||||
|
||||
function hasExplicitChoice(): boolean {
|
||||
@@ -151,7 +175,12 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const current = localStorage.getItem(STORAGE_KEY);
|
||||
const legacy = localStorage.getItem('theme');
|
||||
|
||||
if (current && current !== 'light' && current !== 'dark') {
|
||||
if (
|
||||
current &&
|
||||
current !== 'light' &&
|
||||
current !== 'dark' &&
|
||||
current !== 'lightblue'
|
||||
) {
|
||||
// Garbage value — wipe it so the boot-sync below can take over.
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
@@ -185,7 +214,9 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const theme: ThemeType = mounted
|
||||
? effectiveTheme === 'dark'
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: effectiveTheme === 'lightblue'
|
||||
? 'lightblue'
|
||||
: 'light'
|
||||
: readPersistedTheme();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -25,8 +25,9 @@ export class AgentNameCheckError extends Error {
|
||||
export interface ListAgentsParams {
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
/** Pagination scope. Omit for the legacy combined-visibility list. */
|
||||
scope?: "mine" | "square";
|
||||
/** Pagination scope. Omit for the legacy combined-visibility list.
|
||||
* "builtin" = 内置智能体 (user_id IS NULL), admin only — backend 403s non-admins. */
|
||||
scope?: "mine" | "square" | "builtin";
|
||||
/** 1-based page index. Providing this (or scope) enables server-side pagination. */
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
|
||||
@@ -79,7 +79,7 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
/** Server-side paginated agent listing for one gallery tab (scope). Returns
|
||||
* the page of agents plus the total count for the "共 N 个" header. */
|
||||
export function useAgentsPaginated(params: {
|
||||
scope: "mine" | "square";
|
||||
scope: "mine" | "square" | "builtin";
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
sort?: AgentSort;
|
||||
|
||||
@@ -44,6 +44,37 @@ export function clearStoredAuth() {
|
||||
window.localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把登录用户名写入 localStorage.userInfo.userName(顶栏用户区据此显示)。
|
||||
* 仅在尚未设置时写入,避免覆盖外部登录(yUserId)从 consumer 拿到的用户名。
|
||||
*/
|
||||
export function setStoredUserName(name: string | null | undefined) {
|
||||
if (!isBrowser()) {
|
||||
return;
|
||||
}
|
||||
const trimmed = name?.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
let info: Record<string, unknown> = {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem("userInfo");
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
info = parsed as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
info = {};
|
||||
}
|
||||
if (typeof info.userName === "string" && info.userName.trim()) {
|
||||
return;
|
||||
}
|
||||
info.userName = trimmed;
|
||||
window.localStorage.setItem("userInfo", JSON.stringify(info));
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | undefined {
|
||||
const auth = getStoredAuth();
|
||||
const token = auth?.access_token?.trim();
|
||||
|
||||
@@ -448,9 +448,11 @@ export const enUS: Translations = {
|
||||
system: "System",
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
lightblue: "Light Blue",
|
||||
systemDescription: "Match the operating system preference automatically.",
|
||||
lightDescription: "Bright palette with higher contrast for daytime.",
|
||||
darkDescription: "Dim palette that reduces glare for focus.",
|
||||
lightblueDescription: "A crisp light palette built around blue.",
|
||||
languageTitle: "Language",
|
||||
languageDescription: "Switch between languages.",
|
||||
},
|
||||
|
||||
@@ -383,9 +383,11 @@ export interface Translations {
|
||||
system: string;
|
||||
light: string;
|
||||
dark: string;
|
||||
lightblue: string;
|
||||
systemDescription: string;
|
||||
lightDescription: string;
|
||||
darkDescription: string;
|
||||
lightblueDescription: string;
|
||||
languageTitle: string;
|
||||
languageDescription: string;
|
||||
};
|
||||
|
||||
@@ -429,9 +429,11 @@ export const zhCN: Translations = {
|
||||
system: "系统",
|
||||
light: "浅色",
|
||||
dark: "深色",
|
||||
lightblue: "浅蓝",
|
||||
systemDescription: "自动跟随系统主题。",
|
||||
lightDescription: "更明亮的配色,适合日间使用。",
|
||||
darkDescription: "更暗的配色,减少眩光方便专注。",
|
||||
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
||||
languageTitle: "语言",
|
||||
languageDescription: "在不同语言之间切换。",
|
||||
},
|
||||
|
||||
@@ -62,6 +62,11 @@ export interface LocalSettings {
|
||||
*/
|
||||
skillViewMode?: "card" | "list";
|
||||
agentViewMode?: "card" | "list";
|
||||
/**
|
||||
* Layout mode: "full" shows the page sidebar + header; "compact" hides them
|
||||
* for a cleaner work area. Defaults to "full" (undefined treated as "full").
|
||||
*/
|
||||
layoutMode?: "full" | "compact";
|
||||
};
|
||||
context: Omit<
|
||||
AgentThreadContext,
|
||||
|
||||
@@ -135,7 +135,7 @@ export async function updatePublishSquares(
|
||||
return (await response.json()) as PublishSquaresSettings;
|
||||
}
|
||||
|
||||
export type ThemeMode = "light" | "dark";
|
||||
export type ThemeMode = "light" | "dark" | "lightblue";
|
||||
export type ViewMode = "card" | "list";
|
||||
|
||||
export interface AppearanceSettings {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
export const DESENSITIZE_MAP: Record<string, string> = {
|
||||
态势: "TS",
|
||||
你好: "nh",
|
||||
滨海作战体系: "第一张网",
|
||||
滨海作战部队: "第一张网",
|
||||
'Taiwan Air Force Flight Units': "第二张网",
|
||||
|
||||
@@ -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 列表,空数组表示检索整个空间 */
|
||||
|
||||
@@ -180,7 +180,7 @@ export default function AgentChatPage() {
|
||||
separate flex sibling OUTSIDE ChatBox, so the artifact preview
|
||||
always opens between the chat and the detail panel — the detail
|
||||
panel stays rightmost. */}
|
||||
<div className="flex size-full min-h-0 overflow-hidden">
|
||||
<div className="flex size-full min-h-0 overflow-hidden workspace-chat-bg">
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<ChatBox threadId={threadId}>
|
||||
{/* Main conversation column. `relative` hosts the floating
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
/**
|
||||
* 配置管理页 —— 把固定业务码(3Q/6BF/7BF/8BF)绑定到「智能体」+「业务链」。
|
||||
* 配置管理页 —— 把固定业务码绑定到「智能体」+「业务链」。
|
||||
*
|
||||
* 表格三列:业务(只读)/ 智能体 / 业务链。编辑模式下后两列为可搜索下拉:
|
||||
* - 智能体来自 `/api/agents`
|
||||
* - 业务链来自 `/api/roundtable-chains?scope=public`(只列公共链,保证全局共享时各账号都能解析)
|
||||
* 智能体来自 `/api/agents`,业务链来自 `/api/roundtable-chains?scope=public`。
|
||||
* 保存调 `PUT /api/business-mapping/{code}`。映射全局共享、无用户隔离。
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Loading, Select } from "tdesign-react";
|
||||
import { Link2, Pencil, Save, Users, X } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Select } from "tdesign-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
HelpCircle,
|
||||
Info,
|
||||
Link2,
|
||||
Pencil,
|
||||
Save,
|
||||
Search,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
@@ -21,11 +30,7 @@ import {
|
||||
updateBusinessMapping,
|
||||
type BusinessMapping,
|
||||
} from "@/strategy-components/api/business-mapping";
|
||||
|
||||
interface DraftSelection {
|
||||
agentId: string | null;
|
||||
chainId: string | null;
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function BusinessChainMappingPage() {
|
||||
const { theme } = useTheme();
|
||||
@@ -36,8 +41,13 @@ export default function BusinessChainMappingPage() {
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingCode, setEditingCode] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<DraftSelection>({ agentId: null, chainId: null });
|
||||
const [draft, setDraft] = useState<{ agentId: string | null; chainId: string | null }>({
|
||||
agentId: null,
|
||||
chainId: null,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "configured" | "pending">("all");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -58,9 +68,7 @@ export default function BusinessChainMappingPage() {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const agentOptions = useMemo(
|
||||
@@ -72,6 +80,24 @@ export default function BusinessChainMappingPage() {
|
||||
[chains],
|
||||
);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = rows.length;
|
||||
const boundAgents = rows.filter((r) => r.agentId !== null).length;
|
||||
return { total, boundAgents };
|
||||
}, [rows]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
return rows.filter((r) => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchesSearch =
|
||||
r.label.toLowerCase().includes(q) || r.businessCode.toLowerCase().includes(q);
|
||||
const isConfigured = r.agentId !== null && r.chainId !== null;
|
||||
if (statusFilter === "configured") return matchesSearch && isConfigured;
|
||||
if (statusFilter === "pending") return matchesSearch && !isConfigured;
|
||||
return matchesSearch;
|
||||
});
|
||||
}, [rows, searchQuery, statusFilter]);
|
||||
|
||||
const beginEdit = (row: BusinessMapping) => {
|
||||
setEditingCode(row.businessCode);
|
||||
setDraft({ agentId: row.agentId, chainId: row.chainId });
|
||||
@@ -86,10 +112,10 @@ export default function BusinessChainMappingPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const agentName = draft.agentId
|
||||
? agents.find((a) => a.id === draft.agentId)?.name ?? row.agentName ?? null
|
||||
? (agents.find((a) => a.id === draft.agentId)?.name ?? row.agentName ?? null)
|
||||
: null;
|
||||
const chainTitle = draft.chainId
|
||||
? chains.find((c) => c.id === draft.chainId)?.title ?? row.chainTitle ?? null
|
||||
? (chains.find((c) => c.id === draft.chainId)?.title ?? row.chainTitle ?? null)
|
||||
: null;
|
||||
const updated = await updateBusinessMapping(row.businessCode, {
|
||||
agentId: draft.agentId,
|
||||
@@ -111,162 +137,416 @@ export default function BusinessChainMappingPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 样式 ── */
|
||||
const shellClass = isDark
|
||||
? "h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100"
|
||||
: "h-full overflow-y-auto bg-[linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]";
|
||||
? "h-full overflow-y-auto text-slate-100 workspace-chat-bg"
|
||||
: "h-full overflow-y-auto bg-white text-[#222]";
|
||||
|
||||
// 卡片:无边框,纯阴影
|
||||
const cardClass = isDark
|
||||
? "border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)] shadow-[0_16px_36px_rgba(3,10,22,0.2)]"
|
||||
: "border border-[#E8E8E8] bg-white shadow-[0_14px_30px_rgba(245,34,45,0.06)]";
|
||||
const headRowClass = isDark ? "text-slate-400" : "text-[#8C8C8C]";
|
||||
const rowBorder = isDark ? "border-[rgba(67,89,124,0.32)]" : "border-[#F0F0F0]";
|
||||
? "bg-[rgba(12,29,54,0.88)] shadow-[0_8px_32px_rgba(3,10,22,0.28)] backdrop-blur-sm"
|
||||
: "bg-white shadow-[0_8px_32px_rgba(0,0,0,0.07)]";
|
||||
|
||||
// 行分隔:极淡,不带色
|
||||
const rowDivider = isDark ? "border-white/[0.04]" : "border-black/[0.04]";
|
||||
|
||||
const mutedText = isDark ? "text-slate-500" : "text-[#A6A6A6]";
|
||||
const codeBadge = isDark
|
||||
? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]"
|
||||
: "bg-[#FFF1F0] text-[#CF1322]";
|
||||
const subText = isDark ? "text-slate-400" : "text-slate-500";
|
||||
|
||||
const filterTabs: { id: "all" | "configured" | "pending"; label: string }[] = [
|
||||
{ id: "all", label: "全部业务" },
|
||||
{ id: "configured", label: "已绑定" },
|
||||
{ id: "pending", label: "待配置" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={shellClass}>
|
||||
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col gap-5 px-6 py-6 lg:px-8">
|
||||
<div className="space-y-1">
|
||||
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? "text-slate-50" : "text-[#222222]"}`}>
|
||||
配置管理
|
||||
</h1>
|
||||
<p className={`text-sm ${isDark ? "text-slate-300/80" : "text-slate-500"}`}>
|
||||
为每个业务绑定智能体与业务链。多智能体会商从对应业务自动取用此处配置的业务链。业务链仅可选择「公共」链条。
|
||||
</p>
|
||||
</div>
|
||||
<div className={cn(shellClass, "[&_.t-input]:rounded-[5px] [&_.t-input__wrap]:rounded-[5px]")}>
|
||||
<div className="mx-auto flex min-h-full max-w-7xl flex-col gap-6 px-6 py-7 lg:px-8">
|
||||
|
||||
<Loading loading={loading} showOverlay={false}>
|
||||
<div className={`overflow-hidden rounded-3xl ${cardClass}`}>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className={`text-left text-xs uppercase tracking-widest ${headRowClass}`}>
|
||||
<th className="px-6 py-4 font-semibold">业务</th>
|
||||
<th className="px-6 py-4 font-semibold">智能体</th>
|
||||
<th className="px-6 py-4 font-semibold">业务链</th>
|
||||
<th className="px-6 py-4 text-right font-semibold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && !loading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className={`px-6 py-12 text-center ${mutedText}`}>
|
||||
暂无业务数据
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
const editing = editingCode === row.businessCode;
|
||||
return (
|
||||
<tr key={row.businessCode} className={`border-t ${rowBorder}`}>
|
||||
{/* 业务(只读) */}
|
||||
<td className="px-6 py-4 align-middle">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[15px] font-semibold ${isDark ? "text-white" : "text-[#222222]"}`}>
|
||||
{row.label}
|
||||
</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-mono ${codeBadge}`}>
|
||||
{row.businessCode}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
{/* ── 页头 banner ── */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className={cn("rounded-[5px] p-6 lg:p-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-6 relative overflow-hidden", cardClass)}
|
||||
>
|
||||
<div
|
||||
className="absolute right-0 top-0 w-72 h-32 pointer-events-none"
|
||||
style={{
|
||||
background: isDark
|
||||
? "radial-gradient(ellipse at top right, rgba(77,141,255,0.07), transparent)"
|
||||
: "radial-gradient(ellipse at top right, rgba(245,34,45,0.04), transparent)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 智能体 */}
|
||||
<td className="px-6 py-4 align-middle">
|
||||
{editing ? (
|
||||
<Select
|
||||
className="min-w-[200px]"
|
||||
value={draft.agentId ?? undefined}
|
||||
options={agentOptions}
|
||||
filterable
|
||||
clearable
|
||||
placeholder="搜索并选择智能体"
|
||||
onChange={(v) => setDraft((d) => ({ ...d, agentId: (v as string) ?? null }))}
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Users className={`h-3.5 w-3.5 ${mutedText}`} />
|
||||
{row.agentName || row.agentId ? (
|
||||
<span>{row.agentName || row.agentId}</span>
|
||||
) : (
|
||||
<span className={mutedText}>未配置</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* 业务链 */}
|
||||
<td className="px-6 py-4 align-middle">
|
||||
{editing ? (
|
||||
<Select
|
||||
className="min-w-[220px]"
|
||||
value={draft.chainId ?? undefined}
|
||||
options={chainOptions}
|
||||
filterable
|
||||
clearable
|
||||
placeholder="搜索并选择业务链(公共)"
|
||||
onChange={(v) => setDraft((d) => ({ ...d, chainId: (v as string) ?? null }))}
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Link2 className={`h-3.5 w-3.5 ${mutedText}`} />
|
||||
{row.chainTitle || row.chainId ? (
|
||||
<span>{row.chainTitle || row.chainId}</span>
|
||||
) : (
|
||||
<span className={mutedText}>未配置</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* 操作 */}
|
||||
<td className="px-6 py-4 text-right align-middle">
|
||||
{editing ? (
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => handleSave(row)}
|
||||
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium disabled:opacity-50 ${
|
||||
isDark ? "bg-[rgba(34,197,94,0.18)] text-[#86EFAC]" : "bg-[#EEFCF7] text-[#0F9D6C]"
|
||||
}`}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={cancelEdit}
|
||||
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium disabled:opacity-50 ${
|
||||
isDark ? "bg-[rgba(110,136,176,0.18)] text-slate-300" : "bg-[#F5F5F5] text-[#575757]"
|
||||
}`}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => beginEdit(row)}
|
||||
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium ${
|
||||
isDark ? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]" : "bg-[#FFF1F0] text-[#CF1322]"
|
||||
}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
编辑
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
<div className="space-y-3 relative z-10">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<h2 className={cn("text-xl font-bold tracking-tight", isDark ? "text-slate-50" : "text-slate-900")}>
|
||||
配置管理
|
||||
</h2>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] uppercase font-mono tracking-wider font-bold px-2.5 py-1 rounded-[5px] flex items-center gap-1.5",
|
||||
isDark
|
||||
? "bg-[rgba(77,141,255,0.12)] text-[#9CCBFF]"
|
||||
: "bg-red-50/80 text-red-500",
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full animate-pulse", isDark ? "bg-[#55A6FE]" : "bg-red-500")} />
|
||||
智能会商神经中枢
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-mono tracking-wider px-2 py-0.5 rounded-[5px]",
|
||||
isDark ? "text-slate-500 bg-white/5" : "text-slate-400 bg-slate-50",
|
||||
)}
|
||||
>
|
||||
v1.2.5
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className={cn("text-xs max-w-2xl leading-relaxed", subText)}>
|
||||
业务流转与交叉验证的高级控制中心。在此注册高频业务模块并绑定专职执行智能体(Agent)及公共流转决策链(Chain),决策信息将基于该拓扑自动触发多智能体自诊断及会商组织。
|
||||
</p>
|
||||
|
||||
<div className={cn("flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-1 text-[11px] font-medium", mutedText)}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500" />
|
||||
会商引擎: 正常运行中
|
||||
</span>
|
||||
<span className="opacity-30">•</span>
|
||||
<span>
|
||||
全局配置项:{" "}
|
||||
<strong className={isDark ? "text-slate-200 font-semibold" : "text-slate-700 font-semibold"}>
|
||||
{stats.total} 项注册
|
||||
</strong>
|
||||
</span>
|
||||
<span className="opacity-30">•</span>
|
||||
<span>
|
||||
指定专家节点:{" "}
|
||||
<strong className={isDark ? "text-[#9CCBFF] font-semibold" : "text-red-500 font-semibold"}>
|
||||
{stats.boundAgents} / {stats.total}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Loading>
|
||||
</motion.div>
|
||||
|
||||
{/* ── 表格区 ── */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
className={cn("rounded-[5px] overflow-hidden", cardClass)}
|
||||
>
|
||||
{/* 搜索 + 筛选 */}
|
||||
<div className={cn("px-6 py-5 border-b flex flex-col sm:flex-row sm:items-center justify-between gap-4", rowDivider)}>
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Search className={cn("absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4", mutedText)} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索业务名称、ID..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn(
|
||||
"w-full pl-10 pr-4 py-2.5 rounded-[5px] text-xs transition-all placeholder:text-slate-400 focus:outline-none",
|
||||
isDark
|
||||
? "text-slate-200 bg-white/5 focus:bg-white/8 focus:ring-1 focus:ring-[#4D8DFF]/30"
|
||||
: "text-slate-700 bg-slate-50/60 hover:bg-slate-50 focus:bg-white focus:ring-1 focus:ring-red-400/30",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cn("flex items-center gap-1 p-1 rounded-[5px] self-end sm:self-auto shrink-0", isDark ? "bg-white/5" : "bg-slate-100/70")}>
|
||||
{filterTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setStatusFilter(tab.id)}
|
||||
className={cn(
|
||||
"px-3.5 py-1.5 rounded-[5px] text-xs font-semibold transition-all cursor-pointer",
|
||||
statusFilter === tab.id
|
||||
? isDark
|
||||
? "bg-[rgba(77,141,255,0.18)] text-[#9CCBFF] shadow-sm"
|
||||
: "bg-white text-gray-900 shadow-sm"
|
||||
: isDark
|
||||
? "text-slate-500 hover:text-slate-300"
|
||||
: "text-gray-400 hover:text-gray-700",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="overflow-x-auto">
|
||||
{loading ? (
|
||||
<div className={cn("py-16 text-center text-sm", mutedText)}>加载中…</div>
|
||||
) : (
|
||||
<table className="w-full table-fixed text-left border-collapse">
|
||||
<thead>
|
||||
<tr className={cn("border-b", rowDivider, isDark ? "bg-white/[0.02]" : "bg-black/[0.02]")}>
|
||||
<th className={cn("pl-8 pr-4 py-3.5 text-xs font-semibold uppercase tracking-wider w-[20%]", mutedText)}>业务</th>
|
||||
<th className={cn("px-4 py-3.5 text-xs font-semibold uppercase tracking-wider w-[26%]", mutedText)}>智能体</th>
|
||||
<th className={cn("px-4 py-3.5 text-xs font-semibold uppercase tracking-wider w-[26%]", mutedText)}>业务链</th>
|
||||
<th className={cn("pl-4 pr-8 py-3.5 text-xs font-semibold uppercase tracking-wider w-[28%] text-right", mutedText)}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.length > 0 ? (
|
||||
filteredRows.map((row) => {
|
||||
const editing = editingCode === row.businessCode;
|
||||
return (
|
||||
<tr
|
||||
key={row.businessCode}
|
||||
className={cn(
|
||||
"border-b last:border-0 transition-colors duration-150",
|
||||
rowDivider,
|
||||
!editing && (isDark ? "hover:bg-white/[0.025]" : "hover:bg-black/[0.015]"),
|
||||
editing && (isDark ? "bg-[rgba(77,141,255,0.04)]" : "bg-red-50/30"),
|
||||
)}
|
||||
>
|
||||
{/* 业务 */}
|
||||
<td className="pl-8 pr-4 py-5 align-middle">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className={cn("font-semibold tracking-tight text-sm", isDark ? "text-slate-100" : "text-gray-800")}>
|
||||
{row.label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-mono font-bold px-2 py-0.5 rounded-[5px] leading-none",
|
||||
isDark
|
||||
? "bg-[rgba(77,141,255,0.12)] text-[#9CCBFF]"
|
||||
: "bg-red-50 text-red-400",
|
||||
)}
|
||||
>
|
||||
{row.businessCode}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 智能体 */}
|
||||
<td className="px-4 py-4 align-middle">
|
||||
{editing ? (
|
||||
<Select
|
||||
className="min-w-[200px]"
|
||||
value={draft.agentId ?? undefined}
|
||||
options={agentOptions}
|
||||
filterable
|
||||
clearable
|
||||
placeholder="搜索并选择智能体"
|
||||
onChange={(v) =>
|
||||
setDraft((d) => ({ ...d, agentId: (v as string) ?? null }))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className={cn(
|
||||
"p-1.5 rounded-[5px] transition-colors",
|
||||
row.agentId
|
||||
? isDark
|
||||
? "bg-[rgba(77,141,255,0.1)] text-[#9CCBFF]"
|
||||
: "bg-red-50 text-red-400"
|
||||
: isDark
|
||||
? "bg-white/5 text-slate-600"
|
||||
: "bg-slate-50 text-gray-300",
|
||||
)}
|
||||
>
|
||||
<Users className="w-3.5 h-3.5 shrink-0" />
|
||||
</div>
|
||||
{row.agentName || row.agentId ? (
|
||||
<div className="space-y-0.5">
|
||||
<span className={cn("text-xs font-semibold", isDark ? "text-slate-200" : "text-gray-800")}>
|
||||
{row.agentName || row.agentId}
|
||||
</span>
|
||||
{row.agentId && agents.find((a) => a.id === row.agentId)?.description && (
|
||||
<span className={cn("block text-[10px]", mutedText)}>
|
||||
{(agents.find((a) => a.id === row.agentId)?.description ?? "").slice(0, 40)}
|
||||
{(agents.find((a) => a.id === row.agentId)?.description?.length ?? 0) > 40 ? "…" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className={cn("text-xs", mutedText)}>未配置</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* 业务链 */}
|
||||
<td className="px-4 py-4 align-middle">
|
||||
{editing ? (
|
||||
<Select
|
||||
className="min-w-[220px]"
|
||||
value={draft.chainId ?? undefined}
|
||||
options={chainOptions}
|
||||
filterable
|
||||
clearable
|
||||
placeholder="搜索并选择业务链(公共)"
|
||||
onChange={(v) =>
|
||||
setDraft((d) => ({ ...d, chainId: (v as string) ?? null }))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className={cn(
|
||||
"p-1.5 rounded-[5px] transition-colors",
|
||||
row.chainId
|
||||
? isDark
|
||||
? "bg-[rgba(34,197,94,0.08)] text-green-400"
|
||||
: "bg-blue-50 text-blue-400"
|
||||
: isDark
|
||||
? "bg-white/5 text-slate-600"
|
||||
: "bg-slate-50 text-gray-300",
|
||||
)}
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5 shrink-0" />
|
||||
</div>
|
||||
{row.chainTitle || row.chainId ? (
|
||||
<div className="space-y-0.5">
|
||||
<span className={cn("text-xs font-semibold", isDark ? "text-slate-200" : "text-gray-800")}>
|
||||
{row.chainTitle || row.chainId}
|
||||
</span>
|
||||
{row.chainId && (
|
||||
<span className={cn("flex items-center gap-1 text-[10px] leading-none", mutedText)}>
|
||||
<span
|
||||
className={cn(
|
||||
"px-1 rounded-[5px] text-[9px] font-medium",
|
||||
isDark
|
||||
? "bg-[rgba(34,197,94,0.08)] text-green-400"
|
||||
: "bg-green-50 text-green-600",
|
||||
)}
|
||||
>
|
||||
公共
|
||||
</span>
|
||||
节点数: {chains.find((c) => c.id === row.chainId)?.seats?.length ?? "—"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className={cn("text-xs", mutedText)}>未配置</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* 操作 */}
|
||||
<td className="pl-4 pr-8 py-4 text-right align-middle whitespace-nowrap">
|
||||
{editing ? (
|
||||
<div className="inline-flex items-center gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => handleSave(row)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-all active:scale-95 disabled:opacity-50",
|
||||
isDark
|
||||
? "bg-[rgba(34,197,94,0.12)] text-green-400 hover:bg-[rgba(34,197,94,0.2)]"
|
||||
: "bg-green-50 text-green-600 hover:bg-green-100",
|
||||
)}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={cancelEdit}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-all active:scale-95 disabled:opacity-50",
|
||||
isDark
|
||||
? "bg-white/5 text-slate-400 hover:bg-white/10 hover:text-slate-200"
|
||||
: "bg-slate-50 text-slate-500 hover:bg-slate-100",
|
||||
)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => beginEdit(row)}
|
||||
title="编辑配置"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-all duration-200 active:scale-95",
|
||||
isDark
|
||||
? "text-[#9CCBFF] bg-[rgba(77,141,255,0.12)] hover:bg-[rgba(77,141,255,0.18)]"
|
||||
: "text-red-500 bg-red-50 hover:bg-red-100",
|
||||
)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-14 text-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<AlertCircle className={cn("w-7 h-7", isDark ? "text-slate-700" : "text-gray-200")} />
|
||||
<p className={cn("text-xs font-medium", mutedText)}>未找到符合筛选条件的业务配置</p>
|
||||
<button
|
||||
onClick={() => { setSearchQuery(""); setStatusFilter("all"); }}
|
||||
className={cn("text-[11px] font-semibold mt-1 hover:underline", isDark ? "text-[#9CCBFF]" : "text-red-500")}
|
||||
>
|
||||
重置筛选条件
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 表格尾栏 */}
|
||||
<div className={cn("px-6 py-4 border-t flex items-center justify-between text-xs font-medium", rowDivider, isDark ? "bg-white/[0.02]" : "bg-black/[0.015]", mutedText)}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
会商程序会在对应业务的业务链生效时开启多体交叉核验证。
|
||||
</span>
|
||||
<span className="font-mono text-[10px]">
|
||||
显示 {filteredRows.length} / {rows.length} 条
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ── 使用说明 ── */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
className={cn(
|
||||
"rounded-[5px] p-5 flex gap-4 text-xs leading-relaxed",
|
||||
isDark
|
||||
? "bg-[rgba(245,158,11,0.05)] text-amber-300/70"
|
||||
: "bg-amber-50/50 text-amber-800",
|
||||
)}
|
||||
>
|
||||
<div className={cn("w-8 h-8 rounded-[5px] shrink-0 flex items-center justify-center", isDark ? "bg-[rgba(245,158,11,0.08)] text-amber-400" : "bg-amber-100/60 text-amber-500")}>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className={cn("font-bold mb-0.5", isDark ? "text-amber-300" : "text-amber-900")}>如何配置业务流转?</h4>
|
||||
<p className={isDark ? "text-amber-400/60" : "text-amber-700/80"}>
|
||||
1. 将鼠标悬停在任一业务行右侧,点击出现的{" "}
|
||||
<span className={cn("font-semibold", isDark ? "text-[#9CCBFF]" : "text-red-500")}>「编辑」</span>。
|
||||
<br />
|
||||
2. 在该行内直接搜索并选择 <strong>执行智能体</strong> 与 <strong>业务链</strong>,点击「保存」即时生效。
|
||||
<br />
|
||||
3. 若无需绑定,可搜索框清空后保存,置为「未配置」状态。
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -28,6 +28,12 @@ import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { CompactWelcome } from "@/components/workspace/compact-welcome";
|
||||
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
|
||||
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PanelLeftOpen } from "lucide-react";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
@@ -36,6 +42,7 @@ import {
|
||||
getLastAgentId,
|
||||
saveLastAgentId,
|
||||
useKnowledgeRagEnabled,
|
||||
useLocalSettings,
|
||||
useThreadSettings,
|
||||
} from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
@@ -85,7 +92,11 @@ export default function ChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
|
||||
useSidebar();
|
||||
const { t } = useI18n();
|
||||
const [localSettings] = useLocalSettings();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
@@ -402,11 +413,32 @@ export default function ChatPage() {
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
// 紧凑模式(正常 App,有外层页面侧栏)落地态:用 CM 参考图样式重画;
|
||||
// 判定与 PageRoutes 的 compactWorkspaceSidebar(pageSidebarAllowed) 保持一致 ——
|
||||
// 优先看外观设置的「全量/简洁」,未设置过才回退到会话级 sidebarEnabled。否则用户显式
|
||||
// 选「全量」时 PageRoutes 已挂出顶栏+侧栏,这里却还按旧 sidebarEnabled 判定,导致深蓝
|
||||
// 主题底图(workspace-chat-bg)不生效。非紧凑(嵌入/standalone)保持原样。
|
||||
const layoutMode = localSettings.display.layoutMode;
|
||||
const chromeByPreference =
|
||||
layoutMode === "full"
|
||||
? true
|
||||
: layoutMode === "compact"
|
||||
? false
|
||||
: readSidebarEnabled();
|
||||
const compactChrome =
|
||||
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
|
||||
const compactLanding = isLandingState && compactChrome;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex size-full min-h-0 justify-between",
|
||||
// 紧凑模式(有顶栏+左侧页面侧栏)下,深色背景换成设计稿深蓝主题底图
|
||||
compactChrome && "workspace-chat-bg",
|
||||
)}
|
||||
>
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
|
||||
@@ -419,6 +451,20 @@ export default function ChatPage() {
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
|
||||
{compactChrome && !historySidebarOpen && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={toggleHistorySidebar}
|
||||
title="打开历史记录"
|
||||
aria-label="打开历史记录"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<PanelLeftOpen className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
@@ -453,11 +499,25 @@ export default function ChatPage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
// 外层居中容器不吃指针事件:空白区域(尤其落地态铺满 inset-0 时盖住的
|
||||
// 右上角「展开历史」按钮)让点击穿透,仅真正的内容块(下方 pointer-events-auto)
|
||||
// 接收交互。否则该透明层会吞掉顶栏按钮的 hover/光标/点击。
|
||||
"pointer-events-none absolute z-30 flex justify-center px-4",
|
||||
// 紧凑落地态:整块垂直+水平居中于对话区
|
||||
compactLanding
|
||||
? "inset-0 items-center"
|
||||
: "right-0 bottom-0 left-0",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||
"pointer-events-auto relative w-full",
|
||||
// 非紧凑落地态沿用上移居中;紧凑态由外层 items-center 居中,无需位移
|
||||
isLandingState &&
|
||||
!compactLanding &&
|
||||
"-translate-y-[calc(50vh-96px)]",
|
||||
isLandingState
|
||||
? "max-w-(--container-width-md)"
|
||||
: "max-w-(--container-width-lg)",
|
||||
@@ -474,17 +534,21 @@ export default function ChatPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isLandingState && mounted && (
|
||||
<Welcome
|
||||
mode={settings.context.mode}
|
||||
className="mb-6 -translate-y-4"
|
||||
/>
|
||||
)}
|
||||
{isLandingState && mounted &&
|
||||
(compactLanding ? (
|
||||
<CompactWelcome className="mb-6 -translate-y-4" />
|
||||
) : (
|
||||
<Welcome
|
||||
mode={settings.context.mode}
|
||||
className="mb-6 -translate-y-4"
|
||||
/>
|
||||
))}
|
||||
{mounted ? (
|
||||
<div className="w-full -translate-y-4">
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={isLandingState}
|
||||
compact={compactLanding}
|
||||
threadId={threadId}
|
||||
autoFocus={isLandingState}
|
||||
status={
|
||||
@@ -525,6 +589,11 @@ export default function ChatPage() {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{compactLanding && (
|
||||
<div className="text-muted-foreground/70 w-full -translate-y-2 text-center text-[11px]">
|
||||
CM Assistant V1.2 · 深度推理可能会涉及数据同步 · 信息由AI自动生成,请审慎参考
|
||||
</div>
|
||||
)}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
|
||||
@@ -19,8 +19,9 @@ export default function LightAppIframeView() {
|
||||
|
||||
// 由 useTheme 的 theme 直接换算主题色并显式传入,切主题时 src 立即重建、
|
||||
// 把新主题色传给被嵌套应用(不依赖 <html> 的 dark class,避免时序读旧值)。
|
||||
// TODO: 后续接入 blue 主题后,这里区分 blue 与 dark-blue。
|
||||
const themeColor: AppThemeColor = isDark ? "dark-blue" : "red";
|
||||
// 深色=dark-blue,浅蓝=blue,其余(红白)=red。
|
||||
const themeColor: AppThemeColor =
|
||||
theme === "dark" ? "dark-blue" : theme === "lightblue" ? "blue" : "red";
|
||||
const src = useMemo(
|
||||
() => (app ? buildLightAppUrl(app, undefined, themeColor) : ""),
|
||||
[app, themeColor],
|
||||
|
||||
@@ -1,27 +1,71 @@
|
||||
/**
|
||||
* 应用列表页 —— 以卡片网格展示「新页跳转(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 { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
|
||||
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
||||
|
||||
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 { data: apps = [], isLoading } = useLightApps({
|
||||
appType: "window_open",
|
||||
enabledOnly: true,
|
||||
@@ -47,67 +91,226 @@ 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="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="absolute inline-flex size-full animate-ping rounded-full bg-[#CF1322] opacity-75 dark:bg-[#1677FF]" />
|
||||
<span className="relative inline-flex size-3 rounded-full bg-[#CF1322] dark:bg-[#1677FF]" />
|
||||
</span>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
|
||||
应用列表
|
||||
</h1>
|
||||
<span className="rounded-[5px] border border-[#CF1322]/15 bg-[#FFF1F0] px-2 py-0.5 font-mono text-xs font-medium text-[#CF1322] dark:border-[#1677FF]/30 dark:bg-[#1677FF]/15 dark:text-[#4096FF]">
|
||||
导航工作台
|
||||
</span>
|
||||
</div>
|
||||
<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="w-full rounded-[5px] border border-zinc-200 bg-zinc-50/50 py-3 pl-10 pr-10 text-sm transition focus:border-[#CF1322]/60 focus:bg-white focus:outline-none focus:ring-2 focus:ring-[#CF1322]/20 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#C5CFE0] dark:placeholder-[#4A6491] dark:focus:border-[#1677FF]/60 dark:focus:bg-[#122347] dark:focus:ring-[#1677FF]/25"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
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="font-bold text-[#CF1322] dark:text-[#4096FF]">{apps.length}</span> 个关联应用
|
||||
</div>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
className="text-xs font-medium text-[#CF1322] underline hover:text-[#A8071A] dark:text-[#4096FF] dark:hover:text-[#1677FF]"
|
||||
>
|
||||
重置过滤条件
|
||||
</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 Icon = ICON_POOL[hashIndex(app.appId, ICON_POOL.length)];
|
||||
const theme = 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", theme.iconBg)}>
|
||||
<Icon className="size-5.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="mb-2 line-clamp-1 text-base font-bold leading-snug text-zinc-900 transition-colors group-hover:text-[#CF1322] dark:text-[#E8EDF5] dark:group-hover:text-[#4096FF]">
|
||||
{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="text-[9px] font-bold uppercase tracking-wider text-[#CF1322] dark:text-[#4096FF]">
|
||||
自动拼接已激活参
|
||||
</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="flex items-center justify-center gap-1.5 rounded-[5px] bg-[#CF1322] px-3 py-2 text-xs font-semibold text-white shadow-[0_1px_3px_rgba(207,19,34,0.2)] transition hover:bg-[#A8071A] active:scale-[0.98] dark:bg-[#1677FF] dark:shadow-none dark:hover:bg-[#0958D9]"
|
||||
>
|
||||
<span>进入应用</span>
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlignLeft,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FolderTree,
|
||||
Globe,
|
||||
HelpCircle,
|
||||
Info,
|
||||
Layers,
|
||||
Link2,
|
||||
Monitor,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Sliders,
|
||||
Tag,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Select } from "tdesign-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -41,6 +53,18 @@ interface FormState {
|
||||
routeParams: LightAppRouteParam[];
|
||||
}
|
||||
|
||||
const APP_TYPE_OPTIONS: { label: string; value: LightAppType }[] = [
|
||||
{ label: "iframe 嵌套 (挂载到菜单)", value: "iframe" },
|
||||
{ label: "window.open (新页跳转)", value: "window_open" },
|
||||
];
|
||||
|
||||
const PARAM_TYPE_OPTIONS: { label: string; value: "login" | "theme" }[] = [
|
||||
{ label: "登录参数", value: "login" },
|
||||
{ label: "主题参数", value: "theme" },
|
||||
];
|
||||
|
||||
const DIALOG_SELECT_POPUP = { attach: () => document.body };
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
name: "",
|
||||
description: "",
|
||||
@@ -98,6 +122,7 @@ export default function LightAppManagementPage() {
|
||||
const [editing, setEditing] = useState<LightApp | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [activeParamPopover, setActiveParamPopover] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"table" | "cards">("table");
|
||||
|
||||
const saving = actions.create.isPending || actions.update.isPending;
|
||||
|
||||
@@ -180,109 +205,164 @@ export default function LightAppManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyUrl = (url: string) => {
|
||||
navigator.clipboard?.writeText(url);
|
||||
toast.success("已复制地址");
|
||||
};
|
||||
|
||||
const tableApps = useMemo(() => apps, [apps]);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-[#FAFAFA]">
|
||||
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col space-y-6 px-6 py-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-[20px] font-bold tracking-tight text-[#222222]">应用管理</h1>
|
||||
<p className="max-w-4xl text-[13px] leading-relaxed text-[#575757]">
|
||||
注册轻应用。window.open 类型在「应用列表」以卡片展示;iframe 类型挂载到所选菜单下并注入侧栏。
|
||||
<div className="h-full overflow-y-auto bg-neutral-50">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col space-y-7 px-8 py-8">
|
||||
<div className="flex flex-col justify-between gap-6 md:flex-row md:items-center">
|
||||
<div className="space-y-1.5">
|
||||
<h1 className="flex items-center text-xl font-black tracking-tight text-neutral-900">
|
||||
应用管理
|
||||
</h1>
|
||||
<p className="max-w-2xl text-xs leading-relaxed text-neutral-500">
|
||||
注册轻应用。
|
||||
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
|
||||
window.open
|
||||
</code>
|
||||
类型在「应用列表」以卡片展示;
|
||||
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
|
||||
iframe
|
||||
</code>
|
||||
类型挂载到所选菜单下并注入侧栏。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="flex h-9 cursor-pointer items-center space-x-1.5 rounded-[2px] bg-[#A8071A] px-4 text-[14px] font-bold text-white shadow-[0_2px_8px_rgba(34,34,34,0.08)] transition-all hover:bg-[#BC0D1E] active:bg-[#820014]"
|
||||
className="flex cursor-pointer items-center justify-center space-x-1.5 rounded-[5px] bg-[#A8071A] px-5 py-2.5 text-xs font-semibold text-white shadow-md shadow-rose-950/10 transition-all hover:bg-[#BC0D1E] hover:shadow-lg hover:shadow-rose-900/15 active:bg-[#820014]"
|
||||
>
|
||||
<span>+ 注册应用</span>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>注册应用</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[#C1C1C1]" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索应用名称 / 描述"
|
||||
className="h-9 w-full rounded-[2px] border border-[#E8E8E8] bg-white pl-10 pr-4 text-[14px] text-[#222222] placeholder-[#C1C1C1] shadow-sm transition-all focus:border-[#F5222D] focus:outline-none focus:ring-1 focus:ring-[#F5222D]"
|
||||
/>
|
||||
{search && (
|
||||
<div className="flex flex-col items-center gap-3.5 rounded-[5px] bg-white p-3 shadow-[0_1px_3px_rgba(0,0,0,0.01)] md:flex-row">
|
||||
<div className="relative w-full flex-1">
|
||||
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索应用名称 / 描述..."
|
||||
className="w-full rounded-[5px] border border-neutral-100 bg-neutral-50/60 py-2 pl-10 pr-10 text-xs text-neutral-800 placeholder-neutral-400 transition-all hover:bg-white focus:border-[#F5222D]/40 focus:outline-none focus:ring-4 focus:ring-[#F5222D]/10"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer text-neutral-400 hover:text-neutral-700"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center space-x-0.5 self-end rounded-[5px] border border-neutral-200/50 bg-neutral-100/60 p-0.5 md:self-auto">
|
||||
<button
|
||||
onClick={() => setSearch("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer text-[12px] text-[#A6A6A6] hover:text-[#F5222D]"
|
||||
onClick={() => setViewMode("table")}
|
||||
className={`cursor-pointer rounded-[5px] px-4 py-1.5 text-xs font-bold transition-all ${
|
||||
viewMode === "table"
|
||||
? "bg-white text-[#F5222D] shadow-sm"
|
||||
: "text-neutral-500 hover:text-neutral-700"
|
||||
}`}
|
||||
>
|
||||
清除
|
||||
表格视图
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setViewMode("cards")}
|
||||
className={`cursor-pointer rounded-[5px] px-4 py-1.5 text-xs font-bold transition-all ${
|
||||
viewMode === "cards"
|
||||
? "bg-white text-[#F5222D] shadow-sm"
|
||||
: "text-neutral-500 hover:text-neutral-700"
|
||||
}`}
|
||||
>
|
||||
卡片视图
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-visible rounded-[4px] border border-[#E8E8E8] bg-white shadow-[0_2px_8px_rgba(34,34,34,0.08)]">
|
||||
<table className="w-full table-fixed border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E8E8E8] bg-[#FAFAFA] text-[12px] font-bold uppercase tracking-wider text-[#A6A6A6]">
|
||||
<th className="w-[18%] px-6 py-3.5">名称</th>
|
||||
<th className="w-[12%] px-4 py-3.5">类型</th>
|
||||
<th className="w-[34%] px-4 py-3.5">地址 / 挂载</th>
|
||||
<th className="w-[12%] px-4 py-3.5">参数</th>
|
||||
<th className="w-[12%] px-4 py-3.5">启用</th>
|
||||
<th className="w-[12%] px-4 py-3.5 pr-6 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#E8E8E8]">
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium text-neutral-500">
|
||||
搜寻到 <span className="font-bold text-[#F5222D]">{tableApps.length}</span>{" "}
|
||||
个注册完成的子模块轻应用
|
||||
</div>
|
||||
|
||||
{viewMode === "table" ? (
|
||||
<div className="overflow-visible rounded-[5px] bg-white shadow-[0_1px_3px_0_rgba(0,0,0,0.01)]">
|
||||
<table className="w-full table-fixed border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="select-none border-b border-neutral-100/80 bg-neutral-50/40 text-[12.5px] font-semibold text-neutral-500">
|
||||
<th className="w-[15%] px-6 py-4">名称</th>
|
||||
<th className="w-[14%] px-4 py-4">类型</th>
|
||||
<th className="w-[27%] px-4 py-4">地址</th>
|
||||
<th className="w-[14%] px-4 py-4">挂载</th>
|
||||
<th className="w-[9%] px-4 py-4">参数</th>
|
||||
<th className="w-[9%] px-4 py-4">启用</th>
|
||||
<th className="w-[12%] px-4 py-4 pr-6 text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{tableApps.map((app) => (
|
||||
<tr key={app.appId} className="group align-top transition-colors hover:bg-[#FEFAFA]">
|
||||
<tr key={app.appId} className="group align-middle transition-colors hover:bg-neutral-50/40">
|
||||
<td className="px-6 py-[18px]">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<span className="text-[14px] font-bold text-[#222222] transition-colors group-hover:text-[#F5222D]">
|
||||
{app.name}
|
||||
</span>
|
||||
{app.description ? (
|
||||
<span className="line-clamp-2 text-[12px] text-[#575757]">{app.description}</span>
|
||||
) : (
|
||||
<span className="text-[11px] italic text-[#C1C1C1]">无描述说明</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[14px] font-bold text-neutral-800 transition-colors group-hover:text-[#F5222D]">
|
||||
{app.name}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-[18px]">
|
||||
{app.appType === "window_open" ? (
|
||||
<span className="inline-flex select-none items-center space-x-1 rounded-[2px] border border-[#FDDADC] bg-[#FEEDED] px-2 py-0.5 text-[11px] font-medium text-[#F5222D] shadow-sm">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
<span className="inline-flex select-none items-center space-x-1.5 rounded-[5px] border border-rose-100 bg-rose-50 px-3 py-1 text-[12px] font-semibold text-rose-800">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
<span>新页跳转</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex select-none items-center space-x-1 rounded-[2px] border border-[#D4E9FF] bg-[#EAF4FF] px-2 py-0.5 text-[11px] font-medium text-[#0079FE] shadow-sm">
|
||||
<Layers className="h-3 w-3" />
|
||||
<span className="inline-flex select-none items-center space-x-1.5 rounded-[5px] border border-sky-100 bg-sky-50 px-3 py-1 text-[12px] font-semibold text-sky-800">
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
<span>iframe 嵌套</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="overflow-hidden px-4 py-[18px]">
|
||||
<div className="flex max-w-full flex-col space-y-1">
|
||||
<div className="flex max-w-full items-center space-x-1.5 font-mono text-[12px] text-neutral-600 transition-colors group-hover:text-neutral-800">
|
||||
<a
|
||||
href={app.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-1.5 truncate font-mono text-[13px] text-[#222222] hover:text-[#0079FE] hover:underline"
|
||||
className="truncate hover:underline"
|
||||
title={app.url}
|
||||
>
|
||||
<span className="truncate">{app.url}</span>
|
||||
{app.url}
|
||||
</a>
|
||||
{app.appType === "iframe" && app.mountLocation && (
|
||||
<div className="flex items-center space-x-1 text-[11px] text-[#A6A6A6]">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[#0079FE]" />
|
||||
<span>挂载: {mountLabel(app.mountLocation)}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleCopyUrl(app.url)}
|
||||
className="shrink-0 rounded-[5px] p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
|
||||
title="复制网址"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-[18px]">
|
||||
{app.appType === "iframe" && app.mountLocation ? (
|
||||
<span className="inline-flex max-w-full items-center space-x-1.5 rounded-[5px] bg-sky-50 px-2 py-0.5 text-[11px] font-medium text-sky-700">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
|
||||
<span className="truncate">{mountLabel(app.mountLocation)}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[12px] text-neutral-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="relative px-4 py-[18px]">
|
||||
<div className="flex items-center select-none">
|
||||
<div className="flex select-none items-center">
|
||||
{app.routeParams.length > 0 ? (
|
||||
<button
|
||||
onClick={() =>
|
||||
@@ -290,15 +370,17 @@ export default function LightAppManagementPage() {
|
||||
activeParamPopover === app.appId ? null : app.appId
|
||||
)
|
||||
}
|
||||
className="group/param inline-flex cursor-pointer items-center space-x-1 text-[14px] text-[#222222] transition-colors hover:text-[#0079FE]"
|
||||
className="inline-flex cursor-pointer items-center space-x-1.5 rounded-[5px] border border-neutral-200/60 bg-neutral-50/60 px-2.5 py-1 text-xs font-medium text-neutral-500 transition-colors hover:bg-neutral-100"
|
||||
>
|
||||
<span className="font-medium">{app.routeParams.length}个</span>
|
||||
<span className="text-[10px] text-[#A6A6A6] transition-colors group-hover/param:text-[#0079FE]">
|
||||
▼
|
||||
</span>
|
||||
<span>{app.routeParams.length}个</span>
|
||||
{activeParamPopover === app.appId ? (
|
||||
<ChevronUp className="h-3.5 w-3.5 text-neutral-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-neutral-400" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[14px] text-[#A6A6A6]">0个</span>
|
||||
<span className="text-xs text-neutral-400">0个</span>
|
||||
)}
|
||||
|
||||
{activeParamPopover === app.appId && app.routeParams.length > 0 && (
|
||||
@@ -307,23 +389,30 @@ export default function LightAppManagementPage() {
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setActiveParamPopover(null)}
|
||||
/>
|
||||
<div className="absolute left-4 top-11 z-50 mt-1 w-[280px] animate-in fade-in rounded-[4px] border border-[#E8E8E8] bg-white p-3 text-[12px] shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
|
||||
<div className="mb-2 flex items-center justify-between border-b border-[#E8E8E8] pb-2 font-semibold text-[#3D3D3D]">
|
||||
<span>路由参数明细</span>
|
||||
<span className="rounded-sm bg-[#EAF4FF] px-1.5 py-0.5 text-[10px] text-[#0079FE]">
|
||||
URL 拼接
|
||||
<div className="absolute left-4 top-12 z-50 w-[280px] animate-in fade-in rounded-[5px] border border-neutral-100 bg-white p-3.5 text-[12px] shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
|
||||
<div className="mb-2.5 flex items-center justify-between border-b border-neutral-100 pb-2">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-neutral-400">
|
||||
注入的路由参数
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setActiveParamPopover(null)}
|
||||
className="text-[10px] font-semibold text-rose-700 hover:underline"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[160px] space-y-1.5 overflow-y-auto">
|
||||
<div className="max-h-40 space-y-1.5 overflow-y-auto">
|
||||
{app.routeParams.map((p, idx) => (
|
||||
<div
|
||||
key={`${app.appId}-${idx}`}
|
||||
className="flex items-center justify-between rounded-sm bg-[#FAFAFA] p-1.5 font-mono hover:bg-[#FEF6F6]"
|
||||
className="rounded-[5px] border border-neutral-100 bg-neutral-50/60 p-2 text-[11px] leading-relaxed"
|
||||
>
|
||||
<span className="font-bold text-[#F5222D]">{p.key}</span>
|
||||
<span className="text-[11px] text-[#A6A6A6]">
|
||||
({getParamSourceLabel(p)})
|
||||
</span>
|
||||
<div className="flex items-center justify-between gap-1 font-mono">
|
||||
<span className="truncate font-bold text-rose-700">{p.key}</span>
|
||||
<span className="truncate text-[10px] text-neutral-500">
|
||||
{getParamSourceLabel(p)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -337,36 +426,36 @@ export default function LightAppManagementPage() {
|
||||
<div className="flex select-none items-center space-x-2">
|
||||
{app.enabled ? (
|
||||
<>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#2FD69A] opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-[#2FD69A]" />
|
||||
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<span className="text-[13px] font-semibold text-[#189D72]">已启用</span>
|
||||
<span className="text-[12.5px] font-bold text-emerald-700">已启用</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="h-2 w-2 rounded-full bg-[#A6A6A6]" />
|
||||
<span className="text-[13px] text-[#A6A6A6]">已禁用</span>
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full bg-neutral-300" />
|
||||
<span className="text-[12.5px] font-medium text-neutral-400">已停用</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-[18px] pr-6 text-right">
|
||||
<div className="inline-flex items-center justify-end space-x-2.5">
|
||||
<td className="px-4 py-[18px] pr-6 text-center">
|
||||
<div className="flex items-center justify-center space-x-1">
|
||||
<button
|
||||
onClick={() => openEdit(app)}
|
||||
className="group/btn flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[4px] border border-[#D4E9FF] bg-[#EAF4FF] text-[#0079FE] transition-all duration-200 hover:scale-105 hover:border-[#0079FE] hover:bg-[#0079FE] hover:text-white active:scale-95"
|
||||
className="cursor-pointer rounded-[5px] p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-emerald-600"
|
||||
title="编辑配置"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 transition-transform duration-200 group-hover/btn:rotate-12" />
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(app)}
|
||||
className="group/btn flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[4px] border border-[#FDDADC] bg-[#FEEDED] text-[#F5222D] transition-all duration-200 hover:scale-105 hover:border-[#F5222D] hover:bg-[#F5222D] hover:text-white active:scale-95"
|
||||
className="cursor-pointer rounded-[5px] p-1.5 text-neutral-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
|
||||
title="删除配置"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 transition-transform duration-200 group-hover/btn:scale-110" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -375,17 +464,17 @@ export default function LightAppManagementPage() {
|
||||
|
||||
{tableApps.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-12 text-center">
|
||||
<td colSpan={7} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center justify-center space-y-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#F5F5F5] text-[#C1C1C1]">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-300">
|
||||
<Info className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[14px] font-semibold text-[#A6A6A6]">
|
||||
<p className="text-[14px] font-semibold text-neutral-400">
|
||||
{isLoading ? "加载中..." : "暂无匹配的组件应用"}
|
||||
</p>
|
||||
{!isLoading && (
|
||||
<p className="text-[12px] text-[#C1C1C1]">
|
||||
<p className="text-[12px] text-neutral-300">
|
||||
{search ? "请确认搜索关键词输入是否正确" : "点击右上角“注册应用”添加第一款轻应用"}
|
||||
</p>
|
||||
)}
|
||||
@@ -396,25 +485,152 @@ export default function LightAppManagementPage() {
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : tableApps.length === 0 ? (
|
||||
<div className="rounded-[5px] bg-white py-20 text-center">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-300">
|
||||
<Info className="h-6 w-6" />
|
||||
</div>
|
||||
<p className="text-[14px] font-semibold text-neutral-400">
|
||||
{isLoading ? "加载中..." : "暂无匹配的组件应用"}
|
||||
</p>
|
||||
{!isLoading && (
|
||||
<p className="mt-1 text-[12px] text-neutral-300">
|
||||
{search ? "请确认搜索关键词输入是否正确" : "点击右上角“注册应用”添加第一款轻应用"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{tableApps.map((app) => (
|
||||
<div
|
||||
key={app.appId}
|
||||
className="group flex flex-col justify-between overflow-hidden rounded-[5px] bg-white shadow-[0_1px_3px_0_rgba(0,0,0,0.01)] transition-all hover:shadow-md"
|
||||
>
|
||||
<div
|
||||
className={`h-1.5 w-full bg-gradient-to-r ${
|
||||
app.appType === "window_open"
|
||||
? "from-rose-600 to-amber-500"
|
||||
: "from-indigo-600 to-sky-500"
|
||||
}`}
|
||||
/>
|
||||
<div className="flex-1 space-y-3.5 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
{app.appType === "window_open" ? (
|
||||
<span className="inline-flex items-center space-x-1 rounded-[5px] border border-rose-100 bg-rose-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-rose-700">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
<span>新页跳转</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center space-x-1 rounded-[5px] border border-sky-100 bg-sky-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-sky-700">
|
||||
<Monitor className="h-3 w-3" />
|
||||
<span>iframe 嵌套</span>
|
||||
</span>
|
||||
)}
|
||||
<h4 className="line-clamp-1 text-sm font-bold leading-snug text-neutral-800 transition-colors group-hover:text-[#F5222D]">
|
||||
{app.name}
|
||||
</h4>
|
||||
</div>
|
||||
{app.enabled ? (
|
||||
<span className="inline-flex shrink-0 items-center space-x-1 text-[11px] font-bold text-emerald-700">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span>已启用</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex shrink-0 items-center space-x-1 text-[11px] font-medium text-neutral-400">
|
||||
<span className="h-2 w-2 rounded-full bg-neutral-300" />
|
||||
<span>已停用</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="line-clamp-2 min-h-[2.25rem] text-xs leading-relaxed text-neutral-500">
|
||||
{app.description || "暂无应用详细描述说明。"}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-1.5 rounded-[5px] bg-neutral-50/60 p-2 font-mono text-[11px] text-neutral-600">
|
||||
<Globe className="h-3.5 w-3.5 shrink-0 text-neutral-400" />
|
||||
<span className="flex-1 truncate" title={app.url}>
|
||||
{app.url}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopyUrl(app.url)}
|
||||
className="shrink-0 rounded-[5px] p-1 text-neutral-400 transition-colors hover:bg-neutral-200 hover:text-neutral-600"
|
||||
title="复制网址"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{app.routeParams.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{app.routeParams.map((p, idx) => (
|
||||
<span
|
||||
key={`${app.appId}-card-${idx}`}
|
||||
className="rounded-[5px] border border-neutral-200/40 bg-neutral-100 px-1.5 py-0.5 font-mono text-[10px] text-neutral-500"
|
||||
>
|
||||
{p.key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{app.appType === "iframe" && app.mountLocation && (
|
||||
<div className="inline-flex items-center gap-1.5 rounded-[5px] bg-sky-50/60 p-2 text-[11px] font-medium leading-none text-sky-700">
|
||||
<span>挂载分类:</span>
|
||||
<code className="font-semibold">{mountLabel(app.mountLocation)}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-1 border-t border-neutral-100/60 bg-neutral-50/30 px-5 py-3">
|
||||
<button
|
||||
onClick={() => openEdit(app)}
|
||||
className="cursor-pointer rounded-[5px] p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-emerald-600"
|
||||
title="编辑配置"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(app)}
|
||||
className="cursor-pointer rounded-[5px] p-1.5 text-neutral-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
|
||||
title="删除配置"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex max-w-full select-none items-start space-x-3.5 rounded-[4px] border border-[#FFBB55] bg-[#FFF6EA] p-4 text-[13px] text-[#575757]">
|
||||
<Info className="mt-0.5 h-5 w-5 flex-shrink-0 text-[#FF9900]" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-bold text-[#222222]">📌 智能多维挂载规则:</p>
|
||||
<ul className="list-inside list-disc space-y-1 pl-1">
|
||||
<li>
|
||||
当您注册 <strong>iframe 嵌套</strong>{" "}
|
||||
应用时,您需要在菜单一栏指定分类(如“资源管理”),组件将直接作为新导航菜单项加入该分类。
|
||||
{viewMode === "table" && (
|
||||
<div className="select-none rounded-[5px] bg-amber-50/40 p-5 shadow-[0_1px_2px_rgba(0,0,0,0.005)]">
|
||||
<div className="flex items-center space-x-2 border-b border-amber-100/50 pb-3 text-amber-800">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 text-amber-600" />
|
||||
<h4 className="text-[13px] font-bold tracking-tight">智能多维挂载规则:</h4>
|
||||
</div>
|
||||
<ul className="mt-3.5 space-y-3 pl-1 text-[12px] leading-relaxed text-neutral-600">
|
||||
<li className="flex items-start space-x-2">
|
||||
<span className="mt-1 shrink-0 text-amber-600">•</span>
|
||||
<span>
|
||||
当您注册 <strong className="font-bold text-neutral-800">iframe 嵌套</strong>{" "}
|
||||
应用时,您需要在菜单一栏指定分类(如“资源管理”),组件将直接作为新导航菜单项加入该分类。
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
当路由挂载成功,点击左侧注入的子栏名称可在主区域调起{" "}
|
||||
<strong>真实 Iframe 预览器</strong>
|
||||
,并自动将参数以加密/标准的QueryString传递过去。
|
||||
<li className="flex items-start space-x-2">
|
||||
<span className="mt-1 shrink-0 text-amber-600">•</span>
|
||||
<span>
|
||||
当路由挂载成功,点击左侧注入的子栏名称可在主区域调起{" "}
|
||||
<strong className="font-bold text-neutral-800">真实 Iframe 预览器</strong>
|
||||
,并自动将参数以加密/标准的QueryString传递过去。
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dialogOpen && (
|
||||
@@ -424,14 +640,19 @@ export default function LightAppManagementPage() {
|
||||
onClick={() => setDialogOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="relative z-[110] m-4 flex max-h-[90vh] w-full max-w-[580px] flex-col rounded-[4px] border border-[#E8E8E8] bg-white p-6 shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
|
||||
<div className="relative z-[110] m-4 flex max-h-[90vh] w-full max-w-[580px] flex-col rounded-[5px] border border-[#E8E8E8] bg-white p-6 shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-[#E8E8E8] pb-4">
|
||||
<h2 className="text-[17px] font-bold text-[#222222]">
|
||||
{editing ? "修改轻应用" : "注册应用"}
|
||||
</h2>
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<span className="rounded-[5px] bg-rose-50 p-1.5 text-[#A8071A]">
|
||||
<Layers className="h-5 w-5" />
|
||||
</span>
|
||||
<h2 className="text-[17px] font-bold text-[#222222]">
|
||||
{editing ? "修改轻应用" : "注册应用"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDialogOpen(false)}
|
||||
className="cursor-pointer rounded-sm p-1 text-[#A6A6A6] transition-colors hover:bg-[#FAFAFA] hover:text-[#F5222D]"
|
||||
className="cursor-pointer rounded-[5px] p-1 text-[#A6A6A6] transition-colors hover:bg-[#FAFAFA] hover:text-[#F5222D]"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -439,84 +660,93 @@ export default function LightAppManagementPage() {
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-y-auto py-4 pr-1 text-[#222222]">
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center text-[14px] font-medium">
|
||||
<label className="flex items-center space-x-1.5 text-[14px] font-medium">
|
||||
<span className="font-bold text-[#F5222D]">*</span>
|
||||
<Layers className="h-4 w-4 text-[#A6A6A6]" />
|
||||
<span>应用类型</span>
|
||||
<span className="ml-1 text-[#F5222D]">*</span>
|
||||
</label>
|
||||
<select
|
||||
<Select
|
||||
className="w-full"
|
||||
value={form.appType}
|
||||
onChange={(e) => patchForm({ appType: e.target.value as LightAppType })}
|
||||
className="h-9 w-full cursor-pointer rounded-[2px] border border-[#DBDBDB] bg-white px-3 text-[14px] focus:border-[#F5222D] focus:outline-none focus:ring-1 focus:ring-[#F5222D]"
|
||||
>
|
||||
<option value="iframe">iframe 嵌套 (挂载到菜单)</option>
|
||||
<option value="window_open">window.open (新页跳转)</option>
|
||||
</select>
|
||||
options={APP_TYPE_OPTIONS}
|
||||
popupProps={DIALOG_SELECT_POPUP}
|
||||
onChange={(v) => patchForm({ appType: v as LightAppType })}
|
||||
/>
|
||||
<p className="text-[11.5px] leading-relaxed text-[#A6A6A6]">
|
||||
{form.appType === "window_open"
|
||||
? "在新标签页或卡片式路由容器打开。多用于外部高度复杂的系统跳转。"
|
||||
: "通过标准 iframe 嵌入页面,可直接挂载到左侧导航树相应分类内。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center text-[14px] font-medium">
|
||||
<label className="flex items-center space-x-1.5 text-[14px] font-medium">
|
||||
<span className="font-bold text-[#F5222D]">*</span>
|
||||
<Tag className="h-4 w-4 text-[#A6A6A6]" />
|
||||
<span>应用名称</span>
|
||||
<span className="ml-1 text-[#F5222D]">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => patchForm({ name: e.target.value })}
|
||||
placeholder="如: 舆情看板"
|
||||
className="h-9 rounded-[2px] border-[#DBDBDB] text-[14px]"
|
||||
className="h-9 rounded-[5px] border-[#DBDBDB] text-[14px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center text-[14px] font-medium">
|
||||
<label className="flex items-center space-x-1.5 text-[14px] font-medium">
|
||||
<span className="font-bold text-[#F5222D]">*</span>
|
||||
<Link2 className="h-4 w-4 text-[#A6A6A6]" />
|
||||
<span>应用地址</span>
|
||||
<span className="ml-1 text-[#F5222D]">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.url}
|
||||
onChange={(e) => patchForm({ url: e.target.value })}
|
||||
placeholder="https://example.com/page 或 http://47.88.25.99:3000/"
|
||||
className="h-9 rounded-[2px] border-[#DBDBDB] font-mono text-[14px]"
|
||||
className="h-9 rounded-[5px] border-[#DBDBDB] font-mono text-[14px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.appType === "iframe" && (
|
||||
<div className="animate-in fade-in space-y-1.5">
|
||||
<label className="flex items-center text-[14px] font-medium">
|
||||
<label className="flex items-center space-x-1.5 text-[14px] font-medium">
|
||||
<span className="font-bold text-[#F5222D]">*</span>
|
||||
<FolderTree className="h-4 w-4 text-[#A6A6A6]" />
|
||||
<span>挂载菜单</span>
|
||||
<span className="ml-1 text-[#F5222D]">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={form.mountLocation}
|
||||
onChange={(e) => patchForm({ mountLocation: e.target.value })}
|
||||
className="h-9 w-full cursor-pointer rounded-[2px] border border-[#DBDBDB] bg-white px-3 text-[14px] focus:border-[#F5222D] focus:outline-none focus:ring-1 focus:ring-[#F5222D]"
|
||||
>
|
||||
<option value="">请选择挂载菜单</option>
|
||||
{MOUNT_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Select
|
||||
className="w-full"
|
||||
value={form.mountLocation || undefined}
|
||||
placeholder="请选择挂载菜单"
|
||||
options={MOUNT_OPTIONS}
|
||||
popupProps={DIALOG_SELECT_POPUP}
|
||||
onChange={(v) => patchForm({ mountLocation: (v as string) ?? "" })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[14px] font-medium">应用描述 (可选)</label>
|
||||
<label className="flex items-center space-x-1.5 text-[14px] font-medium">
|
||||
<span className="w-2 shrink-0" />
|
||||
<AlignLeft className="h-4 w-4 text-[#A6A6A6]" />
|
||||
<span>应用描述 (可选)</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(e) => patchForm({ description: e.target.value })}
|
||||
placeholder="简要说明该应用的用途"
|
||||
rows={2}
|
||||
className="resize-none rounded-[2px] border-[#DBDBDB] text-[14px]"
|
||||
className="resize-none rounded-[5px] border-[#DBDBDB] text-[14px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 pt-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center text-[14px] font-medium">
|
||||
<span className="flex items-center space-x-1.5 text-[14px] font-medium">
|
||||
<Sliders className="h-4 w-4 text-[#A6A6A6]" />
|
||||
<span>路由参数</span>
|
||||
<span
|
||||
className="ml-1 inline-flex"
|
||||
className="inline-flex"
|
||||
title="参数会在加载此应用时作为 query 参数拼接在 URL 的尾部"
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5 text-[#A6A6A6]" />
|
||||
@@ -525,22 +755,22 @@ export default function LightAppManagementPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={addParam}
|
||||
className="flex h-7 cursor-pointer items-center space-x-1 rounded-[2px] border border-[#DBDBDB] bg-white px-2.5 text-[12px] font-bold text-[#575757] transition-colors hover:border-[#F5222D] hover:text-[#F5222D]"
|
||||
className="flex h-7 cursor-pointer items-center space-x-1 rounded-[5px] border border-[#DBDBDB] bg-white px-2.5 text-[12px] font-bold text-[#575757] transition-colors hover:border-[#F5222D] hover:text-[#F5222D]"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span>+ 添加路由参数</span>
|
||||
<span>添加路由参数</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{form.routeParams.length > 0 ? (
|
||||
<div className="max-h-[220px] space-y-2 overflow-y-auto rounded-sm border border-[#E8E8E8] bg-[#FAFAFA] p-1">
|
||||
<div className="max-h-[220px] space-y-2 overflow-y-auto rounded-[5px] border border-[#E8E8E8] bg-[#FAFAFA] p-1">
|
||||
{form.routeParams.map((p, idx) => {
|
||||
const themeMap =
|
||||
p.paramType === "theme" ? parseThemeMap(p.themeValue) : {};
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="relative rounded-sm border border-[#E8E8E8] bg-white p-2"
|
||||
className="relative rounded-[5px] border border-[#E8E8E8] bg-white p-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1">
|
||||
@@ -549,40 +779,36 @@ export default function LightAppManagementPage() {
|
||||
value={p.key}
|
||||
onChange={(e) => patchParam(idx, { key: e.target.value })}
|
||||
placeholder="参数名 key"
|
||||
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
|
||||
className="h-8 w-full rounded-[5px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-[110px]">
|
||||
<select
|
||||
<Select
|
||||
className="w-full"
|
||||
size="small"
|
||||
value={p.paramType}
|
||||
onChange={(e) =>
|
||||
options={PARAM_TYPE_OPTIONS}
|
||||
popupProps={DIALOG_SELECT_POPUP}
|
||||
onChange={(v) =>
|
||||
patchParam(idx, {
|
||||
paramType: e.target.value as "login" | "theme",
|
||||
source:
|
||||
e.target.value === "login" ? (p.source || "accessToken") : undefined,
|
||||
paramType: v as "login" | "theme",
|
||||
source: v === "login" ? (p.source || "accessToken") : undefined,
|
||||
})
|
||||
}
|
||||
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-[#FAFAFA] px-1 text-[12px] focus:border-[#F5222D] focus:outline-none"
|
||||
>
|
||||
<option value="login">登录参数</option>
|
||||
<option value="theme">主题参数</option>
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{p.paramType === "login" && (
|
||||
<div className="flex flex-[1.2] items-center gap-2 text-[11px]">
|
||||
<select
|
||||
<Select
|
||||
className="w-full"
|
||||
size="small"
|
||||
value={p.source || "accessToken"}
|
||||
onChange={(e) => patchParam(idx, { source: e.target.value })}
|
||||
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-white px-1 text-[12px] focus:border-[#F5222D] focus:outline-none"
|
||||
>
|
||||
{LOGIN_SOURCE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={LOGIN_SOURCE_OPTIONS}
|
||||
popupProps={DIALOG_SELECT_POPUP}
|
||||
onChange={(v) => patchParam(idx, { source: v as string })}
|
||||
/>
|
||||
{p.source === "other" && (
|
||||
<input
|
||||
type="text"
|
||||
@@ -591,7 +817,7 @@ export default function LightAppManagementPage() {
|
||||
patchParam(idx, { customValue: e.target.value })
|
||||
}
|
||||
placeholder="自定义值"
|
||||
className="h-8 w-[120px] rounded-[2px] border border-[#DBDBDB] px-2 text-[12px] focus:border-[#F5222D] focus:outline-none"
|
||||
className="h-8 w-[120px] rounded-[5px] border border-[#DBDBDB] px-2 text-[12px] focus:border-[#F5222D] focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -600,7 +826,7 @@ export default function LightAppManagementPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeParam(idx)}
|
||||
className="cursor-pointer rounded-sm p-1 text-[#A6A6A6] transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D]"
|
||||
className="cursor-pointer rounded-[5px] p-1 text-[#A6A6A6] transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D]"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -608,7 +834,7 @@ export default function LightAppManagementPage() {
|
||||
|
||||
{/* 主题参数:逐主题色配置传给目标应用的值(左=系统主题色,右=目标应用值,可改) */}
|
||||
{p.paramType === "theme" && (
|
||||
<div className="mt-2 space-y-1.5 rounded-sm bg-[#FAFAFA] p-2">
|
||||
<div className="mt-2 space-y-1.5 rounded-[5px] bg-[#FAFAFA] p-2">
|
||||
<p className="text-[11px] leading-relaxed text-[#A6A6A6]">
|
||||
各系统主题色跳转/嵌套时传给该应用的值(默认同名,可按对方命名修改)
|
||||
</p>
|
||||
@@ -636,7 +862,7 @@ export default function LightAppManagementPage() {
|
||||
})
|
||||
}
|
||||
placeholder={c.key}
|
||||
className="h-7 flex-1 rounded-[2px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
|
||||
className="h-7 flex-1 rounded-[5px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -647,7 +873,7 @@ export default function LightAppManagementPage() {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-sm border border-dashed border-[#E8E8E8] bg-[#FAFAFA] p-4 text-center text-[12px] text-[#A6A6A6]">
|
||||
<div className="rounded-[5px] border border-dashed border-[#E8E8E8] bg-[#FAFAFA] p-4 text-center text-[12px] text-[#A6A6A6]">
|
||||
暂无注入的路由参数,加载地址时不传递多余拼接。
|
||||
</div>
|
||||
)}
|
||||
@@ -676,7 +902,7 @@ export default function LightAppManagementPage() {
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => setDialogOpen(false)}
|
||||
className="h-9 rounded-[2px] border-[#DBDBDB] px-[18px] text-[14px] font-semibold"
|
||||
className="h-9 rounded-[5px] border-[#DBDBDB] px-[18px] text-[14px] font-semibold"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
@@ -684,7 +910,7 @@ export default function LightAppManagementPage() {
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={handleSave}
|
||||
className="h-9 cursor-pointer rounded-[2px] bg-[#F5222D] px-[18px] text-[14px] font-bold text-white shadow-[0_2px_8px_rgba(34,34,34,0.08)] transition-colors hover:bg-[#CF1322] active:bg-[#A8071A] disabled:cursor-not-allowed disabled:opacity-70"
|
||||
className="h-9 cursor-pointer rounded-[5px] bg-[#F5222D] px-[18px] text-[14px] font-bold text-white shadow-[0_2px_8px_rgba(34,34,34,0.08)] transition-colors hover:bg-[#CF1322] active:bg-[#A8071A] disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{saving ? "保存中..." : editing ? "保存修改" : "立即注册"}
|
||||
</button>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { loginByToken, loginByUsername } from '@/core/auth/api';
|
||||
import { setStoredAuth } from '@/core/auth';
|
||||
import { setStoredAuth, setStoredUserName } from '@/core/auth';
|
||||
import {
|
||||
persistExternalLoginParams,
|
||||
readExternalLoginParams,
|
||||
@@ -114,6 +114,7 @@ export default function LoginPage() {
|
||||
setErrorMessage('');
|
||||
try {
|
||||
let auth;
|
||||
let loginUserName = '';
|
||||
if (authToken) {
|
||||
// token 换登录:后端用 authToken 调上游接口换出用户名再登录
|
||||
auth = await loginByToken(authToken);
|
||||
@@ -128,11 +129,14 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
auth = await loginByUsername(deerflowUsername, gatePassword);
|
||||
loginUserName = deerflowUsername;
|
||||
}
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setStoredAuth(auth);
|
||||
// 标准登录把用户名写入 userInfo,供顶栏用户区显示(已有则不覆盖)
|
||||
setStoredUserName(loginUserName);
|
||||
persistSidebarEnabled(sidebarEnabled);
|
||||
setStatus('success');
|
||||
router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-rout
|
||||
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
|
||||
import WorkspaceRoutes from "./WorkspaceRoutes";
|
||||
import StrategyRoutes from "./StrategyRoutes";
|
||||
@@ -21,8 +22,15 @@ function PageLayout() {
|
||||
const location = useLocation();
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const [settings] = useLocalSettings();
|
||||
// 界面模式开关(外观设置):用户显式选「全量/简洁」时以其为准;未设置过则回退到
|
||||
// 会话级 sidebarEnabled(外部门户/yUserId 登录时置位)这一旧默认。这样无论是否走门户
|
||||
// 登录,「全量↔简洁」切换都能真正控制顶栏+侧边栏的显示。
|
||||
const layoutMode = settings.display.layoutMode;
|
||||
const chromeByPreference =
|
||||
layoutMode === "full" ? true : layoutMode === "compact" ? false : sidebarEnabled;
|
||||
const pageSidebarAllowed =
|
||||
sidebarEnabled && !isChatsIframeWorkspacePath(location.pathname);
|
||||
chromeByPreference && !isChatsIframeWorkspacePath(location.pathname);
|
||||
|
||||
// h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100%
|
||||
// 高度链,根布局跟随容器高度。全屏下 100% 解析到视口,与 h-screen 等价;无界等嵌套
|
||||
|
||||
@@ -108,8 +108,10 @@ export function WorkspaceLayout({
|
||||
'--header-height': headerHidden ? '0rem' : '4rem',
|
||||
} as CSSProperties
|
||||
}
|
||||
key={String(compactWorkspaceSidebar)}
|
||||
data-page-sidebar-collapsed={pageSidebarCollapsed}
|
||||
defaultOpen
|
||||
// 紧凑模式下历史侧栏默认收起(聊天区更宽),由聊天页右上角图标钮展开
|
||||
defaultOpen={!compactWorkspaceSidebar}
|
||||
>
|
||||
{!workspaceSidebarHidden && (
|
||||
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,476 @@ 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}
|
||||
/>
|
||||
</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}
|
||||
|
||||
@@ -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,7 +14,9 @@ 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";
|
||||
|
||||
@@ -43,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;
|
||||
@@ -67,6 +73,9 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
coordinatorPrompt,
|
||||
savedSummary,
|
||||
onSummarySaved,
|
||||
summaryFlow,
|
||||
reportFlow,
|
||||
dashboardFlow,
|
||||
onReset,
|
||||
openArtifactPreview,
|
||||
seatStubMessages,
|
||||
@@ -104,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;
|
||||
}
|
||||
@@ -1340,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 {
|
||||
@@ -1445,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;
|
||||
@@ -1533,8 +1650,14 @@ export function useStep2Orchestration(
|
||||
Math.min(95, parseFloat((prev + 8).toFixed(1))),
|
||||
);
|
||||
|
||||
// 优化3:续轮强化收敛指引——信息足够就**明确收口**,结论开头标注【已达成共识】,
|
||||
// 减少弱模型该收口时还乱派 / 该继续时草草收尾的两类偏差。
|
||||
nextLeaderTask =
|
||||
"各子智能体已就上一轮派活完成交付,请你结合最新结论判断是否仍需继续派活;若信息已足够,请直接输出最终方案并停止派活。";
|
||||
"各子智能体已就上一轮派活完成交付,请你结合最新结论判断:" +
|
||||
"① 若仍有关键信息缺口或席位职责未覆盖 → 继续派活(按上述派活方式判断串/并行);" +
|
||||
"② 若信息已足够 → **停止派活**,直接综合各席位交付输出最终方案,并在结论最开头用一行标注" +
|
||||
"「【已达成共识】(置信度:高/中/低)」。不要在信息不足时草率收口,也不要在已足够时反复派活。" +
|
||||
PARALLEL_JUDGE_NOTE;
|
||||
}
|
||||
|
||||
if (safetyCounter >= MAX_CYCLES) {
|
||||
@@ -1554,11 +1677,15 @@ export function useStep2Orchestration(
|
||||
appendToDialogue,
|
||||
appendStepIfNew,
|
||||
setDialogueConfidence,
|
||||
setDialogueContent,
|
||||
phaseLabel,
|
||||
finalizeDialogue,
|
||||
highlightSpeaker,
|
||||
markSpeakerStreaming,
|
||||
markSpeakerIdle,
|
||||
markParallelSpeakers,
|
||||
ingestParallelDelta,
|
||||
resetParallelStreams,
|
||||
clearAllStreamingSpeakers,
|
||||
triggerToast,
|
||||
handleArtifactToolPhase,
|
||||
@@ -2514,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);
|
||||
@@ -2531,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();
|
||||
@@ -2608,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,
|
||||
};
|
||||
}
|
||||
@@ -95,6 +95,8 @@ export interface UseStep3ReportReturn {
|
||||
themeMismatch: boolean;
|
||||
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
|
||||
abort: () => void;
|
||||
/** 本流是否允许**自动**打开沙箱预览(视图挂载=true / 卸载=false;并行流防抢沙箱)。 */
|
||||
setPreviewEnabled: (enabled: boolean) => void;
|
||||
/** 点击气泡内工具步骤时打开沙箱(与第 2 步 openArtifactFromStep 同义)。 */
|
||||
openArtifactFromStep: (
|
||||
dialogue: Step2Dialogue,
|
||||
@@ -271,7 +273,12 @@ export function useStep3Report(
|
||||
setArtifacts,
|
||||
select: selectArtifact,
|
||||
setOpen: setArtifactsOpen,
|
||||
open: artifactsOpen,
|
||||
} = useArtifacts();
|
||||
const artifactsOpenRef = useRef(artifactsOpen);
|
||||
useEffect(() => {
|
||||
artifactsOpenRef.current = artifactsOpen;
|
||||
}, [artifactsOpen]);
|
||||
|
||||
const [phase, setPhase] = useState<ReportPhase>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -494,12 +501,38 @@ export function useStep3Report(
|
||||
[selectArtifact, setArtifactsOpen],
|
||||
);
|
||||
|
||||
// 预览开关(2026-06-13,hook 提升页面层后总结/绘制并行共用全局沙箱):只有用户
|
||||
// 当前所在视图的流允许**自动**打开沙箱;显式用户点击不受限。
|
||||
const previewEnabledRef = useRef(false);
|
||||
const lastAutoThreadRef = useRef<string | null>(null);
|
||||
const setPreviewEnabled = useCallback(
|
||||
(enabled: boolean) => {
|
||||
previewEnabledRef.current = enabled;
|
||||
// 进入视图接管:**仅当沙箱面板已打开**时把选中产物换成本流的(修串台);
|
||||
// 面板关着保持关闭(尊重"进第三步默认关沙箱")。
|
||||
if (
|
||||
enabled &&
|
||||
lastAutoOpenedUrlRef.current &&
|
||||
lastAutoThreadRef.current &&
|
||||
artifactsOpenRef.current
|
||||
) {
|
||||
openArtifact(lastAutoThreadRef.current, lastAutoOpenedUrlRef.current);
|
||||
}
|
||||
},
|
||||
[openArtifact],
|
||||
);
|
||||
|
||||
/**
|
||||
* 流式自动打开沙箱 —— url 与上次相同(仅内容在长大)就跳过,避免每秒重复 select;
|
||||
* url 变了(路径逐步长全)就用更完整的取代。沙箱内容随 stub 更新自动刷新,无需重开。
|
||||
*/
|
||||
const autoOpenArtifact = useCallback(
|
||||
(threadId: string, virtualUrl: string) => {
|
||||
lastAutoThreadRef.current = threadId;
|
||||
if (!previewEnabledRef.current) {
|
||||
lastAutoOpenedUrlRef.current = virtualUrl; // 只记录,回到视图时接管
|
||||
return;
|
||||
}
|
||||
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
|
||||
openArtifact(threadId, virtualUrl);
|
||||
},
|
||||
@@ -649,6 +682,7 @@ export function useStep3Report(
|
||||
// 兜底:流式期间没捕捉到 write_file(极少见),收尾后从 outputs 列表直接选真实文件。
|
||||
const openRealArtifactFallback = useCallback(
|
||||
async (threadId: string) => {
|
||||
if (!previewEnabledRef.current) return; // 不在前台不抢沙箱
|
||||
try {
|
||||
const res = await listSessionArtifacts(threadId);
|
||||
const file =
|
||||
@@ -1001,7 +1035,13 @@ export function useStep3Report(
|
||||
],
|
||||
};
|
||||
stubRef.current = stub;
|
||||
lastAutoOpenedUrlRef.current = null;
|
||||
// 记录预览锚点(不打开):进入本视图且沙箱已开着时可立即接管显示自家 HTML(防串台)。
|
||||
lastAutoOpenedUrlRef.current = buildWriteFileVirtualUrl(
|
||||
REPORT_OUTPUT_PATH,
|
||||
messageId,
|
||||
toolCallId,
|
||||
);
|
||||
lastAutoThreadRef.current = synthThreadId;
|
||||
setSeatStubMessages(stub);
|
||||
setReportThreadId(synthThreadId);
|
||||
setActiveArtifactThreadId(synthThreadId);
|
||||
@@ -1031,11 +1071,11 @@ export function useStep3Report(
|
||||
]);
|
||||
setError(null);
|
||||
setPhase("done");
|
||||
|
||||
const virtualUrl = buildWriteFileVirtualUrl(REPORT_OUTPUT_PATH, messageId, toolCallId);
|
||||
openArtifact(synthThreadId, virtualUrl);
|
||||
// 不再自动打开沙箱预览(2026-06-12):进入第三步默认关闭沙箱,只有开始
|
||||
// 写文件(live write_file 流式)时才自动打开;恢复历史时用户点气泡里的
|
||||
// 「点击预览」步骤卡随时可开(stub 已种好)。
|
||||
},
|
||||
[abort, buildWriteFileVirtualUrl, openArtifact],
|
||||
[abort, buildWriteFileVirtualUrl],
|
||||
);
|
||||
|
||||
const redraw = useCallback((revisionInstruction?: string) => {
|
||||
@@ -1061,6 +1101,7 @@ export function useStep3Report(
|
||||
interject,
|
||||
themeMismatch,
|
||||
abort,
|
||||
setPreviewEnabled,
|
||||
openArtifactFromStep,
|
||||
openArtifactPreview,
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ import type {
|
||||
Step2Dialogue,
|
||||
StubAiMessage,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import { detectFlowIntent } from "@/roundtable-planning/lib/flow-graph";
|
||||
import { detectFlowIntent, hasFlowFence } from "@/roundtable-planning/lib/flow-graph";
|
||||
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
|
||||
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
|
||||
@@ -87,6 +87,8 @@ export interface UseStep3SummaryReturn {
|
||||
pause: () => void;
|
||||
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
|
||||
abort: () => void;
|
||||
/** 本流是否允许**自动**打开沙箱预览(视图挂载=true / 卸载=false;并行流防抢沙箱)。 */
|
||||
setPreviewEnabled: (enabled: boolean) => void;
|
||||
openArtifactFromStep: (
|
||||
dialogue: Step2Dialogue,
|
||||
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||
@@ -164,7 +166,8 @@ function buildSummaryPrompt(opts: {
|
||||
"请基于以下圆桌多智能体研讨的成果,产出一份**结构化的方案总结**。" +
|
||||
"用户已明确要求结构化输出/流程图,因此本轮:" +
|
||||
"①**不要**调用 write_file,**不要**生成任何 .md 文件;" +
|
||||
"②把精炼的文字版总结报告直接写在对话回复里(供用户直接阅读,结构遵循编排提示的要求);" +
|
||||
"②把精炼的文字版总结报告直接写在对话回复里(供用户直接阅读,结构遵循编排提示的要求;" +
|
||||
"编排提示未指定结构时,默认对各席位交付内容逐席位格式化输出:席位名→核心结论→关键交付要点→风险/建议);" +
|
||||
"③在回复最末尾**必须**输出一个完整的 ```flow-json 代码块(前端据此实时绘制可交互流程图)。",
|
||||
);
|
||||
} else {
|
||||
@@ -201,9 +204,35 @@ function buildSummaryPrompt(opts: {
|
||||
*/
|
||||
const FLOW_ASK_INSTRUCTION =
|
||||
"(系统指令:用户要求结构化输出/流程图——本轮不要写或修改任何 .md 报告文件," +
|
||||
"在回复正文给出简短的文字说明/总结即可,并在回复最末尾输出一个**完整**的 ```flow-json 代码块" +
|
||||
"在回复正文给出简短的文字说明/总结(用户未指定结构时,默认对各席位交付内容逐席位格式化输出:" +
|
||||
"席位名→核心结论→关键交付要点),并在回复最末尾输出一个**完整**的 ```flow-json 代码块" +
|
||||
"(按你职责设定的「结构化流程数据」契约;输出全量数据而非增量)。)";
|
||||
|
||||
/**
|
||||
* 上一轮主产物是流程图、但本条消息没显式提「结构化/流程图」时的意图引导指令。
|
||||
* 历史痛点:用户画完流程图后说「把验收环节加上」「改一下」,关键词不命中 →
|
||||
* 智能体把它当改报告/问答处理,用户被迫说「重新结构化输出」才能改图。
|
||||
* 这里把「上一轮主产物」上下文显式告诉智能体,让它按语义判断、太模糊就反问。
|
||||
*/
|
||||
const FLOW_CONTEXT_ASK_INSTRUCTION =
|
||||
"(系统指令:上一轮的主产物是结构化流程图。请按语义理解用户这条消息、不要求任何关键词:" +
|
||||
"若是修改/调整类意图且没明确说要改文字报告,默认视为**修改流程图**——不要写 .md 文件," +
|
||||
"在回复末尾输出**完整修订版** ```flow-json 代码块(全量节点+连线,非增量);" +
|
||||
"若明确要改文字报告才用 write_file 整文件重写;若只是提问就只用文字回答;" +
|
||||
"若意图太模糊、无法判断要改什么,不要修改任何产物,在正文向用户提一个简短的澄清问题(给出可选方向)。)";
|
||||
|
||||
/**
|
||||
* 普通报告语境下的意图引导指令(每轮续问都附上):
|
||||
* 按语义理解修改/提问/结构化意图,太模糊就在正文反问,不要乱改。
|
||||
*/
|
||||
const SEMANTIC_ASK_INSTRUCTION =
|
||||
"(系统指令:请按语义理解用户这条消息的意图、不要求任何关键词:" +
|
||||
"修改类意见(哪怕表述笼统)就在原报告基础上增量修改并用 write_file 整文件重写;" +
|
||||
"要求结构化/格式化/可视化(或语义等价说法)就进入流程图模式——不写 .md 文件," +
|
||||
"正文默认对各席位交付内容逐席位格式化输出,末尾输出完整 ```flow-json 块;" +
|
||||
"只是提问就只用文字回答、不改文件;" +
|
||||
"若意图太模糊、无法判断要改什么,不要修改任何产物,在正文向用户提一个简短的澄清问题(给出可选方向)。)";
|
||||
|
||||
/** 恢复草稿后线程已不在内存(无 live thread)时,新建线程续问用的 prompt:带上旧报告做底。 */
|
||||
function buildResumeAskPrompt(opts: {
|
||||
intentReady: ReportIntent | null;
|
||||
@@ -212,10 +241,10 @@ function buildResumeAskPrompt(opts: {
|
||||
coordinatorPrompt?: string | null;
|
||||
previousMd: string;
|
||||
userMessage: string;
|
||||
/** 用户消息命中结构化/流程图意图 → 指示不写文件、直出 flow-json。 */
|
||||
flowIntent?: boolean;
|
||||
/** 按本轮语境选好的意图引导指令(流程图显式 / 流程图上下文 / 普通语义),追加在消息后。 */
|
||||
askInstruction?: string;
|
||||
}): string {
|
||||
const { previousMd, userMessage, flowIntent, ...materials } = opts;
|
||||
const { previousMd, userMessage, askInstruction, ...materials } = opts;
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
"这是一次对已有「方案总结报告」的继续对话。下面先给出研讨原料与你之前已生成的报告," +
|
||||
@@ -234,9 +263,9 @@ function buildResumeAskPrompt(opts: {
|
||||
}
|
||||
parts.push("# 用户的新消息");
|
||||
parts.push(userMessage.trim());
|
||||
if (flowIntent) {
|
||||
if (askInstruction) {
|
||||
parts.push("");
|
||||
parts.push(FLOW_ASK_INSTRUCTION);
|
||||
parts.push(askInstruction);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
@@ -283,7 +312,12 @@ export function useStep3Summary(
|
||||
setArtifacts,
|
||||
select: selectArtifact,
|
||||
setOpen: setArtifactsOpen,
|
||||
open: artifactsOpen,
|
||||
} = useArtifacts();
|
||||
const artifactsOpenRef = useRef(artifactsOpen);
|
||||
useEffect(() => {
|
||||
artifactsOpenRef.current = artifactsOpen;
|
||||
}, [artifactsOpen]);
|
||||
|
||||
const [phase, setPhase] = useState<SummaryPhase>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -303,6 +337,10 @@ export function useStep3Summary(
|
||||
const liveThreadRef = useRef<string | null>(null);
|
||||
// 当前报告 md 全文(续问写底 / 存草稿用)。
|
||||
const currentMdRef = useRef<string>("");
|
||||
// 上一轮主产物是否为流程图(flow-json)——续问意图判断的上下文锚点:
|
||||
// 为 true 时用户的笼统修改意见(「加个环节」「改一下」)默认按改流程图引导,
|
||||
// 不再要求用户说出「结构化输出/流程图」等关键词。
|
||||
const flowActiveRef = useRef(false);
|
||||
const lastAutoOpenedUrlRef = useRef<string | null>(null);
|
||||
|
||||
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)──
|
||||
@@ -483,8 +521,36 @@ export function useStep3Summary(
|
||||
[selectArtifact, setArtifactsOpen],
|
||||
);
|
||||
|
||||
// 预览开关(2026-06-13,hook 提升页面层后并行流共用全局沙箱):只有用户当前所在
|
||||
// 视图的流允许**自动**打开沙箱,否则总结/绘制并行时互相抢预览(选中产物与当前视图
|
||||
// 线程对不上 → 空沙箱)。显式用户点击(openArtifactFromStep 等)不受限。
|
||||
const previewEnabledRef = useRef(false);
|
||||
const lastAutoThreadRef = useRef<string | null>(null);
|
||||
const setPreviewEnabled = useCallback(
|
||||
(enabled: boolean) => {
|
||||
previewEnabledRef.current = enabled;
|
||||
// 进入视图接管:**仅当沙箱面板已打开**时把选中产物换成本流的(修串台);
|
||||
// 面板关着保持关闭(尊重"进第三步默认关沙箱")。
|
||||
if (
|
||||
enabled &&
|
||||
lastAutoOpenedUrlRef.current &&
|
||||
lastAutoThreadRef.current &&
|
||||
artifactsOpenRef.current
|
||||
) {
|
||||
openArtifact(lastAutoThreadRef.current, lastAutoOpenedUrlRef.current);
|
||||
}
|
||||
},
|
||||
[openArtifact],
|
||||
);
|
||||
|
||||
const autoOpenArtifact = useCallback(
|
||||
(threadId: string, virtualUrl: string) => {
|
||||
lastAutoThreadRef.current = threadId;
|
||||
if (!previewEnabledRef.current) {
|
||||
// 视图不在前台:只记录,不抢沙箱(回到视图时 setPreviewEnabled 再接管)。
|
||||
lastAutoOpenedUrlRef.current = virtualUrl;
|
||||
return;
|
||||
}
|
||||
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
|
||||
openArtifact(threadId, virtualUrl);
|
||||
},
|
||||
@@ -618,6 +684,7 @@ export function useStep3Summary(
|
||||
|
||||
const openRealArtifactFallback = useCallback(
|
||||
async (threadId: string) => {
|
||||
if (!previewEnabledRef.current) return; // 不在前台不抢沙箱
|
||||
try {
|
||||
const res = await listSessionArtifacts(threadId);
|
||||
const file = res.files.find((f) => /\.md$/i.test(f.name)) ?? res.files[0];
|
||||
@@ -697,7 +764,7 @@ export function useStep3Summary(
|
||||
path: SUMMARY_OUTPUT_PATH,
|
||||
content: md,
|
||||
});
|
||||
if (opts?.open === false) return;
|
||||
if (opts?.open === false || !previewEnabledRef.current) return;
|
||||
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
|
||||
openArtifact(threadId, virtualUrl);
|
||||
},
|
||||
@@ -720,6 +787,8 @@ export function useStep3Summary(
|
||||
flowRound?: boolean;
|
||||
}): Promise<void> => {
|
||||
const { bubbleId, prompt, aborter, seedThreadId, forceReconcile, files, flowRound } = args;
|
||||
// 本轮开始前的报告底稿——结束后对比判断「这一轮的主产物是报告还是流程图」。
|
||||
const mdBefore = currentMdRef.current;
|
||||
|
||||
let threadId = seedThreadId ?? null;
|
||||
let agentName = SUMMARY_AGENT_NAME;
|
||||
@@ -771,6 +840,14 @@ export function useStep3Summary(
|
||||
});
|
||||
if (aborter.signal.aborted) return;
|
||||
|
||||
// 跟踪本轮主产物:回复带 flow-json → 流程图轮;md 有实质更新 → 报告轮;
|
||||
// 都没有(纯问答轮)→ 维持上一轮状态。供下一次 ask() 选意图引导指令。
|
||||
if (hasFlowFence(res.content)) {
|
||||
flowActiveRef.current = true;
|
||||
} else if (md.trim() && md !== mdBefore) {
|
||||
flowActiveRef.current = false;
|
||||
}
|
||||
|
||||
finalizeDialogue(bubbleId, res.content, "总结完成");
|
||||
setPhase("done");
|
||||
|
||||
@@ -825,6 +902,7 @@ export function useStep3Summary(
|
||||
try {
|
||||
// 编排提示里明确要求了结构化输出/流程图 → 首轮就走流程图模式(不写 md,直出 flow-json)。
|
||||
const flowMode = detectFlowIntent(coordinatorPrompt);
|
||||
flowActiveRef.current = flowMode; // 流式结束后会按真实产物校正
|
||||
const prompt = buildSummaryPrompt({
|
||||
intentReady,
|
||||
seatDeliveries,
|
||||
@@ -865,8 +943,19 @@ export function useStep3Summary(
|
||||
abort();
|
||||
if (wasBusy) freezeStreaming("已打断");
|
||||
|
||||
// 输入框消息命中结构化/流程图意图 → 本轮直出 flow-json、不写 md(md 留在沙箱可切回)。
|
||||
// 意图引导分三档(智能体按语义最终裁决,前端只给上下文,不做硬分类):
|
||||
// ① 显式命中结构化/流程图关键词 → 直出 flow-json、不写 md;
|
||||
// ② 没命中关键词但上一轮主产物是流程图 → 流程图上下文引导(笼统修改默认改图,太模糊则反问);
|
||||
// ③ 普通报告语境 → 语义理解修改/提问/结构化意图,太模糊则反问。
|
||||
const flowIntent = detectFlowIntent(text);
|
||||
const flowContext = !flowIntent && flowActiveRef.current;
|
||||
const askInstruction = flowIntent
|
||||
? FLOW_ASK_INSTRUCTION
|
||||
: flowContext
|
||||
? FLOW_CONTEXT_ASK_INSTRUCTION
|
||||
: SEMANTIC_ASK_INSTRUCTION;
|
||||
// 流程图语境轮:不主动弹 md 沙箱预览(用户多半要看的是画布;真写了文件预览仍会自动打开)。
|
||||
const flowRound = flowIntent || flowContext;
|
||||
|
||||
runningRef.current = true;
|
||||
setError(null);
|
||||
@@ -910,11 +999,11 @@ export function useStep3Summary(
|
||||
setPhase("streaming");
|
||||
|
||||
if (hadLiveThread) {
|
||||
// 真线程仍在 → 续问,发原始消息,靠线程上下文 + SOUL 自判改/答。
|
||||
// 命中流程图意图时附上系统指令(不写文件、末尾输出全量 flow-json)。
|
||||
// 真线程仍在 → 续问,发原始消息 + 按语境选好的意图引导指令,
|
||||
// 靠线程上下文 + SOUL 让智能体按语义自判改图 / 改报告 / 问答 / 反问澄清。
|
||||
const base =
|
||||
text || "我上传了参考文件,请先阅读文件内容,再据此更新报告或回答。";
|
||||
const prompt = flowIntent ? `${base}\n\n${FLOW_ASK_INSTRUCTION}` : base;
|
||||
const prompt = `${base}\n\n${askInstruction}`;
|
||||
await streamOnce({
|
||||
bubbleId,
|
||||
prompt,
|
||||
@@ -922,7 +1011,7 @@ export function useStep3Summary(
|
||||
seedThreadId: liveThreadRef.current,
|
||||
forceReconcile: true,
|
||||
files: fileRefs,
|
||||
flowRound: flowIntent,
|
||||
flowRound,
|
||||
});
|
||||
} else {
|
||||
// 无 live thread(恢复草稿 / 首轮没建好就被打断)→ prompt 带研讨原料 + 旧报告做底。
|
||||
@@ -935,7 +1024,7 @@ export function useStep3Summary(
|
||||
previousMd: currentMdRef.current,
|
||||
userMessage:
|
||||
text || "请阅读我上传的参考文件后,据此更新报告或回答。",
|
||||
flowIntent,
|
||||
askInstruction,
|
||||
});
|
||||
await streamOnce({
|
||||
bubbleId,
|
||||
@@ -944,7 +1033,7 @@ export function useStep3Summary(
|
||||
seedThreadId: threadId,
|
||||
forceReconcile: true,
|
||||
files: fileRefs,
|
||||
flowRound: flowIntent,
|
||||
flowRound,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1021,7 +1110,20 @@ export function useStep3Summary(
|
||||
stubRef.current = stub;
|
||||
currentMdRef.current = snap.md;
|
||||
liveThreadRef.current = realThreadId;
|
||||
lastAutoOpenedUrlRef.current = null;
|
||||
// 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。
|
||||
flowActiveRef.current = hasFlowFence(snap.closing);
|
||||
// 记录预览锚点(不打开):进入本视图且沙箱已开着时可立即接管显示自家 md(防串台)。
|
||||
if (hasMd) {
|
||||
lastAutoOpenedUrlRef.current = buildWriteFileVirtualUrl(
|
||||
SUMMARY_OUTPUT_PATH,
|
||||
messageId,
|
||||
toolCallId,
|
||||
);
|
||||
lastAutoThreadRef.current = synthThreadId;
|
||||
} else {
|
||||
lastAutoOpenedUrlRef.current = null;
|
||||
lastAutoThreadRef.current = null;
|
||||
}
|
||||
setSeatStubMessages(stub);
|
||||
setSummaryThreadId(synthThreadId);
|
||||
setActiveArtifactThreadId(synthThreadId);
|
||||
@@ -1052,13 +1154,11 @@ export function useStep3Summary(
|
||||
]);
|
||||
setError(null);
|
||||
setPhase("done");
|
||||
|
||||
if (hasMd) {
|
||||
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
|
||||
openArtifact(synthThreadId, virtualUrl);
|
||||
}
|
||||
// 不再自动打开沙箱预览(2026-06-12):进入第三步默认关闭沙箱,只有开始
|
||||
// 写文件(live write_file 流式)时才自动打开;恢复历史时用户点气泡里的
|
||||
// 「点击预览」步骤卡随时可开(stub 已种好)。
|
||||
},
|
||||
[abort, buildWriteFileVirtualUrl, openArtifact],
|
||||
[abort, buildWriteFileVirtualUrl],
|
||||
);
|
||||
|
||||
const busy = phase === "creating" || phase === "streaming";
|
||||
@@ -1077,6 +1177,7 @@ export function useStep3Summary(
|
||||
ask,
|
||||
pause,
|
||||
abort,
|
||||
setPreviewEnabled,
|
||||
openArtifactFromStep,
|
||||
openArtifactPreview,
|
||||
};
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface BusinessChain {
|
||||
isPublic?: boolean;
|
||||
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
|
||||
isMine?: boolean;
|
||||
/** 配置者(owner)的可读用户名(后端解析的 email);解析失败时为空。 */
|
||||
ownerName?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import test from "node:test";
|
||||
import {
|
||||
detectFlowIntent,
|
||||
extractFlowGraph,
|
||||
hasFlowFence,
|
||||
layoutFlowGraph,
|
||||
stripFlowFence,
|
||||
} from "./flow-graph";
|
||||
@@ -128,6 +129,16 @@ test("detectFlowIntent:普通措辞不误触发", () => {
|
||||
assert.equal(detectFlowIntent(undefined), false);
|
||||
});
|
||||
|
||||
test("hasFlowFence:判定回复主产物是否为流程图(流式半截也算)", () => {
|
||||
assert.ok(hasFlowFence(FULL)); // 完整闭合块
|
||||
assert.ok(hasFlowFence('说明文字\n```flow-json\n{"nodes":[')); // 流式半截、未闭合
|
||||
assert.equal(hasFlowFence("纯文字报告正文,没有流程图"), false);
|
||||
assert.equal(hasFlowFence("```json\n{}\n```"), false); // 普通 json 块不算
|
||||
assert.equal(hasFlowFence(""), false);
|
||||
assert.equal(hasFlowFence(null), false);
|
||||
assert.equal(hasFlowFence(undefined), false);
|
||||
});
|
||||
|
||||
test("layoutFlowGraph:环不死循环", () => {
|
||||
const text = [
|
||||
"```flow-json",
|
||||
|
||||
@@ -236,6 +236,11 @@ export function detectFlowIntent(text: string | null | undefined): boolean {
|
||||
return FLOW_INTENT_PATTERNS.some((p) => lower.includes(p));
|
||||
}
|
||||
|
||||
/** 文本里是否带 flow-json 代码块(判定某轮回复的主产物是不是流程图)。 */
|
||||
export function hasFlowFence(text: string | null | undefined): boolean {
|
||||
return !!text && text.includes(FLOW_FENCE_OPEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把回复里的 flow-json 块替换成一句人话(气泡里不展示生 JSON)。
|
||||
* 块未闭合(流式中)→「正在输出」措辞;已闭合 → 引导去看流程图画布。
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 后台作业 → Step2 实时画面映射(2026-06-13「查看=恢复流程」)。
|
||||
*
|
||||
* 用户点「查看」一个**运行中**的后台作业时:加载草稿后订阅 `streamJob` SSE,
|
||||
* 把每帧 `RoundtableJob.dialogues`(执行器落库的中性对话记录)映射成前端
|
||||
* `Step2Dialogue` + `seatStubMessages`,灌进 useStep2Orchestration ——
|
||||
* 第二步消息列表像本地编排一样**实时长出**新气泡/步骤卡,而不是静态聊天记录。
|
||||
*
|
||||
* 映射规则与后端 `roundtable_job_executor._to_step2_dialogue` / `_seat_stub_messages`
|
||||
* 逐字段对齐(id/steps/threadId/time/stub 约定一致),保证 SSE 实时帧与作业终态
|
||||
* 写进草稿的 run 形状无缝衔接(终态重载草稿不会跳变)。
|
||||
*/
|
||||
import type {
|
||||
Step2Dialogue,
|
||||
StubAiMessage,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import type { JobDialogue, RoundtableJob } from "@/roundtable-planning/lib/job-types";
|
||||
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
|
||||
/** 工具步骤卡回退文案(对齐执行器 _STEP_LABEL;前端 getStepDisplay 主要按 key 推导)。 */
|
||||
const STEP_LABEL: Record<string, string> = {
|
||||
agent_orchestration: "派活给子智能体",
|
||||
write_file: "写入文件",
|
||||
str_replace: "写入文件",
|
||||
read_file: "读取文件",
|
||||
ls: "列出文件夹",
|
||||
bash: "执行命令",
|
||||
web_search: "搜索相关信息",
|
||||
ask_clarification: "需要你的协助",
|
||||
present_files: "展示文件",
|
||||
};
|
||||
|
||||
function stepsFromToolCalls(
|
||||
toolCalls: NonNullable<JobDialogue["toolCalls"]>,
|
||||
dialogueId: number,
|
||||
ts: string,
|
||||
): NonNullable<Step2Dialogue["steps"]> {
|
||||
const steps: NonNullable<Step2Dialogue["steps"]> = [];
|
||||
toolCalls.forEach((tc, i) => {
|
||||
const name = tc?.name ?? "";
|
||||
if (!name) return;
|
||||
steps.push({
|
||||
id: `${dialogueId}-${i}`,
|
||||
key: `tool_calling:${name}`,
|
||||
label: STEP_LABEL[name] ?? `使用 “${name}” 工具`,
|
||||
ts,
|
||||
args: (tc.args ?? {}) as Record<string, unknown>,
|
||||
});
|
||||
});
|
||||
return steps;
|
||||
}
|
||||
|
||||
function toStep2Dialogue(d: JobDialogue, idxByAgent: Map<string, number>): Step2Dialogue {
|
||||
const time = d.time ?? "";
|
||||
const base: Step2Dialogue = {
|
||||
id: d.id,
|
||||
sender: "",
|
||||
avatarType: "blue",
|
||||
content: d.content ?? "",
|
||||
time,
|
||||
confidence: "",
|
||||
streaming: false,
|
||||
steps: stepsFromToolCalls(d.toolCalls ?? [], d.id, time),
|
||||
};
|
||||
if (d.threadId) base.agentThreadId = d.threadId;
|
||||
if (d.role === "leader") {
|
||||
return { ...base, sender: "总控协调", avatarType: "blue", confidence: "派活说明" };
|
||||
}
|
||||
const aid = d.agentId ?? "";
|
||||
return {
|
||||
...base,
|
||||
sender: d.name || aid,
|
||||
avatarType: getAvatarTypeFor(aid, idxByAgent.get(aid.toLowerCase()) ?? 0),
|
||||
confidence: "子智能体交付",
|
||||
};
|
||||
}
|
||||
|
||||
/** 从各对话的 write_file 重建 seatStubMessages(同 thread 跨轮按 path 去重、后写覆盖)。 */
|
||||
function seatStubsFromDialogues(dialogues: JobDialogue[]): Record<string, StubAiMessage[]> {
|
||||
const byTid = new Map<string, Map<string, NonNullable<JobDialogue["toolCalls"]>[number]>>();
|
||||
for (const d of dialogues) {
|
||||
const tid = d.threadId;
|
||||
if (!tid) continue;
|
||||
for (const tc of d.toolCalls ?? []) {
|
||||
if (tc?.name !== "write_file") continue;
|
||||
const path = String((tc.args as { path?: unknown } | undefined)?.path ?? "");
|
||||
let m = byTid.get(tid);
|
||||
if (!m) {
|
||||
m = new Map();
|
||||
byTid.set(tid, m);
|
||||
}
|
||||
m.set(path, tc); // 后写覆盖
|
||||
}
|
||||
}
|
||||
const out: Record<string, StubAiMessage[]> = {};
|
||||
for (const [tid, byPath] of byTid) {
|
||||
const toolCalls = Array.from(byPath.values()).map((tc, i) => ({
|
||||
id: `tc-${tid}-${i}`,
|
||||
name: "write_file",
|
||||
args: (tc.args ?? {}) as Record<string, unknown>,
|
||||
}));
|
||||
out[tid] = [{ id: `roundtable-stub-${tid}`, type: "ai", content: "", tool_calls: toolCalls }];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface LiveJobFrame {
|
||||
dialogues: Step2Dialogue[];
|
||||
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||
consensusPercentage: number;
|
||||
hasConsensus: boolean;
|
||||
lastLeaderContent: string;
|
||||
}
|
||||
|
||||
/** 把一帧作业快照映射成 Step2 实时画面数据。 */
|
||||
export function mapJobToLiveStep2(job: RoundtableJob): LiveJobFrame {
|
||||
const idxByAgent = new Map<string, number>();
|
||||
job.dispatchChain.forEach((n, i) => idxByAgent.set(n.agentId.toLowerCase(), i));
|
||||
const dialogues = job.dialogues.map((d) => toStep2Dialogue(d, idxByAgent));
|
||||
const lastLeader =
|
||||
[...job.dialogues].reverse().find((d) => d.role === "leader")?.content ?? "";
|
||||
return {
|
||||
dialogues,
|
||||
seatStubMessages: seatStubsFromDialogues(job.dialogues),
|
||||
consensusPercentage: job.consensusPercentage,
|
||||
hasConsensus: job.status === "done",
|
||||
lastLeaderContent: lastLeader,
|
||||
};
|
||||
}
|
||||
@@ -25,8 +25,9 @@ export type JobPhase =
|
||||
| "leader_thinking" // 总控在决策派活
|
||||
| "dispatching" // 正在顺序派活给席位(配合 activeAgentId)
|
||||
| "consensus" // 共识达成(dispatched=[])
|
||||
| "report_drawing" // Step3 报告绘制中
|
||||
| "report_done"; // 绘制完成
|
||||
| "summary_writing" // Step3 方案总结报告撰写中(roundtable-summary 写 md)
|
||||
| "report_drawing" // Step3 结果绘制中(roundtable-report 画 HTML)
|
||||
| "report_done"; // Step3 完成
|
||||
|
||||
/** 派活链路单节点状态(驱动颜色 / 动画)。 */
|
||||
export type ChainNodeState = "pending" | "active" | "done";
|
||||
@@ -55,6 +56,12 @@ export interface JobDialogue {
|
||||
name: string;
|
||||
content: string;
|
||||
cycle: number;
|
||||
/** 本轮工具调用({name, args, id})—— 映射成前端步骤卡 / write_file stub。 */
|
||||
toolCalls?: Array<{ name: string; args?: Record<string, unknown>; id?: string }>;
|
||||
/** 该气泡对应的 leader/seat 线程 id(点开产物用)。 */
|
||||
threadId?: string | null;
|
||||
/** 气泡时间戳(HH:MM:SS,后端创建时盖章)。 */
|
||||
time?: string;
|
||||
}
|
||||
|
||||
/** 一条后台作业的完整快照(§2.5)——对齐后端 `RoundtableJobResponse`(camelCase)。 */
|
||||
@@ -75,8 +82,23 @@ export interface RoundtableJob {
|
||||
error: string | null;
|
||||
/** 研讨对话记录(leader 说明 + 各席位交付),跑完 / 进行中都可看真实内容。 */
|
||||
dialogues: JobDialogue[];
|
||||
/** Step3 报告快照(done 时有):{ html, generatedAt, model, summary }。 */
|
||||
step3: { html: string; generatedAt?: string; model?: string; summary?: string } | null;
|
||||
/** Step3 快照(done 时有):结果绘制 { html, … } + 嵌套的方案总结报告 summaryReport。 */
|
||||
step3: {
|
||||
html?: string;
|
||||
generatedAt?: string;
|
||||
model?: string;
|
||||
summary?: string;
|
||||
/** 方案总结报告(roundtable-summary 的 md)——与草稿 DraftStep3SummarySnapshot 同形状。 */
|
||||
summaryReport?: {
|
||||
md: string;
|
||||
threadId?: string;
|
||||
generatedAt?: string;
|
||||
model?: string;
|
||||
closing?: string;
|
||||
} | null;
|
||||
/** 大屏多页(模式二)的 report-json 数据字符串(前端按当前主题渲染)。 */
|
||||
dashboardJson?: string | null;
|
||||
} | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* report-template.ts 纯函数单测(大屏多页模式的契约层)。
|
||||
* 运行方式(项目无 vitest,对齐 flow-graph.test.ts):
|
||||
* npx esbuild src/roundtable-planning/lib/report-template.test.ts --bundle --format=esm \
|
||||
* --platform=node --outfile=.tmp-tests/report-template.test.mjs
|
||||
* node --test .tmp-tests/report-template.test.mjs
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildDashboardPrompt,
|
||||
extractReportData,
|
||||
isDashboardHtml,
|
||||
renderDashboardHtml,
|
||||
RT_DASHBOARD_MARKER,
|
||||
stripReportFence,
|
||||
} from "./report-template";
|
||||
|
||||
const SAMPLE = [
|
||||
"下面是大屏数据。",
|
||||
"```report-json",
|
||||
JSON.stringify({
|
||||
title: "智能机房选址方案大屏",
|
||||
subtitle: "三地候选 · 综合评审",
|
||||
kpis: [
|
||||
{ label: "总投资", value: "1.2", unit: "亿元", trend: "up" },
|
||||
{ label: "工期", value: "14", unit: "个月" },
|
||||
],
|
||||
consensus: { headline: "推荐 B 地块方案", points: ["供电冗余最高", "扩容空间充足"] },
|
||||
charts: [
|
||||
{ type: "bar", title: "候选地块评分", items: [{ label: "A", value: 72 }, { label: "B", value: 89 }] },
|
||||
{ type: "donut", title: "投资构成", items: [{ label: "土建", value: 40 }, { label: "设备", value: 60 }] },
|
||||
],
|
||||
timeline: [{ label: "阶段一", desc: "勘察与立项" }],
|
||||
seats: [
|
||||
{ id: "seat-1", name: "情报收集", summary: "完成三地基础数据采集", sections: [{ heading: "数据来源", bullets: ["政务公开", "实地走访"] }], metrics: [{ label: "覆盖率", value: 92 }], risks: [], dependsOn: [] },
|
||||
{ id: "seat-2", name: "方案设计", summary: "B 地块双回路供电方案", sections: [], metrics: [], risks: [{ label: "供电瓶颈", level: "high" }], dependsOn: ["seat-1"] },
|
||||
],
|
||||
}),
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
test("extractReportData:完整块解析 + 字段归一化", () => {
|
||||
const d = extractReportData(SAMPLE)!;
|
||||
assert.equal(d.title, "智能机房选址方案大屏");
|
||||
assert.equal(d.kpis.length, 2);
|
||||
assert.equal(d.consensus.headline, "推荐 B 地块方案");
|
||||
assert.equal(d.charts.length, 2);
|
||||
assert.equal(d.seats.length, 2);
|
||||
assert.deepEqual(d.seats[1].dependsOn, ["seat-1"]);
|
||||
});
|
||||
|
||||
test("extractReportData:dependsOn 只保留真实存在的席位 id(剔除模型编造/自引用)", () => {
|
||||
const text = [
|
||||
"```report-json",
|
||||
JSON.stringify({
|
||||
title: "t",
|
||||
seats: [
|
||||
{ id: "s1", name: "甲", dependsOn: ["s1", "s2", "ghost"] },
|
||||
{ id: "s2", name: "乙", dependsOn: [] },
|
||||
],
|
||||
}),
|
||||
"```",
|
||||
].join("\n");
|
||||
const d = extractReportData(text)!;
|
||||
assert.deepEqual(d.seats[0].dependsOn, ["s2"]);
|
||||
});
|
||||
|
||||
test("extractReportData:无 fence / 坏 JSON / 空数据返回 null", () => {
|
||||
assert.equal(extractReportData("没有数据块"), null);
|
||||
assert.equal(extractReportData("```report-json\n{broken\n```"), null);
|
||||
assert.equal(extractReportData("```report-json\n{}\n```"), null);
|
||||
assert.equal(extractReportData(null), null);
|
||||
});
|
||||
|
||||
test("renderDashboardHtml:完整文档 + marker + 总览/席位多页 + :target 导航", () => {
|
||||
const d = extractReportData(SAMPLE)!;
|
||||
const html = renderDashboardHtml(d, { generatedAt: "2026-06-13 10:00" });
|
||||
assert.ok(html.startsWith("<!doctype html>"));
|
||||
assert.ok(html.includes(RT_DASHBOARD_MARKER));
|
||||
assert.ok(html.trimEnd().endsWith("</html>"));
|
||||
assert.ok(isDashboardHtml(html));
|
||||
// 多页结构:总览 + 每席位一页
|
||||
assert.ok(html.includes('id="overview"'));
|
||||
assert.ok(html.includes('id="seat-1"'));
|
||||
assert.ok(html.includes('id="seat-2"'));
|
||||
// 纯 CSS :target 导航(零 JS:无 allow-scripts 沙箱也能切页)
|
||||
assert.ok(html.includes(".page:target"));
|
||||
assert.ok(!html.includes("<script"));
|
||||
// 席位卡片跳转 + 依赖互相跳转(方案设计 依赖→ 情报收集)
|
||||
assert.ok(html.includes('href="#seat-2"'));
|
||||
assert.ok(html.includes('class="dep" href="#seat-1"'));
|
||||
// 反向「被依赖」chip(情报收集页里出现 方案设计 ← 承接)
|
||||
assert.ok(html.includes("承接本席位"));
|
||||
// 共识详情纯 CSS 弹窗
|
||||
assert.ok(html.includes('id="consensus-modal"'));
|
||||
// 图表 SVG 由模板生成
|
||||
assert.ok(html.includes("<svg"));
|
||||
});
|
||||
|
||||
test("renderDashboardHtml:主题跟随 —— dark=深蓝规范 / light=浅红规范", () => {
|
||||
const d = extractReportData(SAMPLE)!;
|
||||
const dark = renderDashboardHtml(d, { theme: "dark" });
|
||||
// 深蓝规范:页面 #0C1836 / 卡片 #122347 / 主色 #1677FF(tokens.css §1)
|
||||
assert.ok(dark.includes("#0C1836"));
|
||||
assert.ok(dark.includes("#122347"));
|
||||
assert.ok(dark.includes("#1677FF"));
|
||||
const light = renderDashboardHtml(d, { theme: "light" });
|
||||
// 蓝图工程图风(参考设计图):页面 #F8FAFC / 玫瑰红主色 #E11D48 / slate 文字
|
||||
assert.ok(light.includes("#F8FAFC"));
|
||||
assert.ok(light.includes("#E11D48"));
|
||||
assert.ok(light.includes("#1E293B"));
|
||||
assert.ok(!light.includes("#0C1836"));
|
||||
// 缺省 = dark(向后兼容)
|
||||
const def = renderDashboardHtml(d);
|
||||
assert.ok(def.includes("#0C1836"));
|
||||
});
|
||||
|
||||
test("renderDashboardHtml:模型文本全部 HTML 转义(防注入/破版)", () => {
|
||||
const text = [
|
||||
"```report-json",
|
||||
JSON.stringify({
|
||||
title: '<img src=x onerror=alert(1)>',
|
||||
seats: [{ id: "s1", name: "甲<b>", summary: 'a"b<c>', dependsOn: [] }],
|
||||
consensus: { headline: "<script>bad()</script>", points: [] },
|
||||
}),
|
||||
"```",
|
||||
].join("\n");
|
||||
const d = extractReportData(text)!;
|
||||
const html = renderDashboardHtml(d);
|
||||
assert.ok(!html.includes("<img src=x"));
|
||||
assert.ok(!html.includes("<script>bad"));
|
||||
assert.ok(html.includes("<script>bad()</script>"));
|
||||
});
|
||||
|
||||
test("stripReportFence:闭合块替换为引导语;未闭合(流式中)用「正在输出」措辞", () => {
|
||||
const done = stripReportFence(SAMPLE, false);
|
||||
assert.ok(done.includes("已生成大屏数据"));
|
||||
assert.ok(!done.includes("```report-json"));
|
||||
const streaming = stripReportFence('说明\n```report-json\n{"title":', true);
|
||||
assert.ok(streaming.includes("正在输出大屏结构化数据"));
|
||||
assert.ok(!streaming.includes("```report-json"));
|
||||
// 无 fence 原样返回
|
||||
assert.equal(stripReportFence("普通文字", false), "普通文字");
|
||||
});
|
||||
|
||||
test("buildDashboardPrompt:禁工具声明 + 原料齐全 + 契约说明", () => {
|
||||
const prompt = buildDashboardPrompt({
|
||||
intentText: "选址决策",
|
||||
seatDeliveries: [{ sender: "情报收集", content: "三地数据" }],
|
||||
consensusText: "推荐 B 地块",
|
||||
});
|
||||
assert.ok(prompt.includes("不要调用任何工具"));
|
||||
assert.ok(prompt.includes("选址决策"));
|
||||
assert.ok(prompt.includes("情报收集") && prompt.includes("三地数据"));
|
||||
assert.ok(prompt.includes("推荐 B 地块"));
|
||||
assert.ok(prompt.includes("```report-json"));
|
||||
assert.ok(prompt.includes("dependsOn"));
|
||||
});
|
||||
@@ -0,0 +1,988 @@
|
||||
/**
|
||||
* Step3「结果绘制 · 大屏多页模式(模式二)」—— report-json 契约 + 模板渲染器。
|
||||
*
|
||||
* 设计动机(2026-06-13):模式一让模型从零写整份 50KB+ 自包含 HTML,慢(1-3 分钟)
|
||||
* 且有超长截断风险。模式二把 HTML 骨架/样式/图表**全部预置在本模板里**,模型只输出
|
||||
* 1-2KB 的结构化 ```report-json 数据(与 Step3 总结报告的 flow-json 同一套路),
|
||||
* 前端代码确定性地渲染成完整大屏 HTML —— 快一个数量级、零截断、风格稳定。
|
||||
*
|
||||
* ## report-json 契约(模型输出)
|
||||
* ```report-json
|
||||
* {"title":"…","subtitle":"…",
|
||||
* "kpis":[{"label":"总投资","value":"1.2","unit":"亿元","trend":"up"}],
|
||||
* "consensus":{"headline":"一句话最终结论","points":["…"]},
|
||||
* "charts":[{"type":"bar|donut|radar","title":"…","items":[{"label":"…","value":30}]}],
|
||||
* "timeline":[{"label":"阶段一","desc":"…"}],
|
||||
* "seats":[{"id":"seat-1","name":"方案设计","summary":"一两句结论",
|
||||
* "sections":[{"heading":"…","bullets":["…"]}],
|
||||
* "metrics":[{"label":"可行性","value":86}],
|
||||
* "risks":[{"label":"…","level":"high|mid|low"}],
|
||||
* "dependsOn":["seat-0"]}]}
|
||||
* ```
|
||||
*
|
||||
* ## 生成的页面结构(单文件 · 零 JS · 离线自包含)
|
||||
* - 大屏展示风:深海军蓝渐变底 + 网格底纹 + 青色霓虹描边玻璃卡片 + 四角装饰括号;
|
||||
* - **总览页**(KPI 带 + 共识结论 + 总览图表 + 席位卡片矩阵 + 执行时间线)
|
||||
* + **每席位一页**(该席位交付的结构化展示 + 指标条 + 风险徽标 + 依赖面板);
|
||||
* - 导航纯 CSS `:target`:页签/席位卡片/依赖 chips 都是 `#seat-x` 锚点跳转,
|
||||
* `.page:target` 显示目标页、`body:has(.page:target)` 隐藏默认总览 ——
|
||||
* **无 allow-scripts 的沙箱 iframe / 公开分享页都能正常切页**;
|
||||
* - 「弹窗」同样用 :target 纯 CSS overlay(共识详情);
|
||||
* - 席位间依赖(dependsOn)渲染成双向 chips(依赖 → / ← 支撑),点击互相跳转。
|
||||
*
|
||||
* 文件头嵌入 `RT_DASHBOARD_MARKER` 注释,恢复草稿时据此识别「这是模板大屏」。
|
||||
*/
|
||||
|
||||
import darkBgSvgRaw from "@/assets/img/layout-bg-theme-dark-blue.svg?raw";
|
||||
|
||||
// ── 契约类型 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReportKpi {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
trend?: "up" | "down" | "flat";
|
||||
}
|
||||
|
||||
export interface ReportChartItem {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ReportChart {
|
||||
type: "bar" | "donut" | "radar" | "line";
|
||||
title: string;
|
||||
items: ReportChartItem[];
|
||||
}
|
||||
|
||||
export interface ReportSeatSection {
|
||||
heading: string;
|
||||
bullets: string[];
|
||||
}
|
||||
|
||||
export interface ReportSeatMetric {
|
||||
label: string;
|
||||
/** 0-100。 */
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ReportSeatRisk {
|
||||
label: string;
|
||||
level: "high" | "mid" | "low";
|
||||
}
|
||||
|
||||
export interface ReportSeat {
|
||||
id: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
sections: ReportSeatSection[];
|
||||
metrics: ReportSeatMetric[];
|
||||
risks: ReportSeatRisk[];
|
||||
/** 依赖的其它席位 id(产物承接关系),渲染成互相跳转 chips。 */
|
||||
dependsOn: string[];
|
||||
}
|
||||
|
||||
export interface ReportData {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
kpis: ReportKpi[];
|
||||
consensus: { headline: string; points: string[] };
|
||||
charts: ReportChart[];
|
||||
timeline: Array<{ label: string; desc: string }>;
|
||||
seats: ReportSeat[];
|
||||
}
|
||||
|
||||
/** 模板大屏 HTML 的识别标记(恢复草稿时区分模式一/二)。 */
|
||||
export const RT_DASHBOARD_MARKER = "<!-- rt-dashboard-v1 -->";
|
||||
|
||||
/** 大屏 HTML 在沙箱预览里的虚拟产物路径(write_file stub 用,前端渲染、非真实文件)。 */
|
||||
export const DASHBOARD_OUTPUT_PATH = "/mnt/user-data/outputs/方案研讨大屏.html";
|
||||
|
||||
export function isDashboardHtml(html: string | null | undefined): boolean {
|
||||
return !!html && html.includes(RT_DASHBOARD_MARKER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把回复里的 report-json 块替换成一句人话(气泡里不展示生 JSON,对齐 flow-json 的
|
||||
* stripFlowFence)。块未闭合(流式中)→「正在输出」措辞;已闭合 → 引导看右侧沙箱。
|
||||
*/
|
||||
export function stripReportFence(text: string, streaming: boolean): string {
|
||||
// 优先 ```report-json,其次 ```json(模型常用);都没有则裸 JSON 对象(含契约关键键)。
|
||||
let start = text.lastIndexOf(FENCE);
|
||||
let fenceLen = FENCE.length;
|
||||
if (start < 0) {
|
||||
const j = text.toLowerCase().lastIndexOf("```json");
|
||||
if (j >= 0) {
|
||||
start = j;
|
||||
fenceLen = "```json".length;
|
||||
}
|
||||
}
|
||||
if (start >= 0) {
|
||||
const after = text.slice(start + fenceLen);
|
||||
const close = after.indexOf("```");
|
||||
const tail = close >= 0 ? after.slice(close + 3) : "";
|
||||
return `${text.slice(0, start).trimEnd()}${close < 0 && streaming ? STREAM_MSG : DONE_MSG}${tail}`;
|
||||
}
|
||||
// 裸 JSON(无围栏):首个 { 起,看起来是本契约才替换成提示。
|
||||
const first = text.indexOf("{");
|
||||
if (first >= 0 && looksLikeReportJson(text.slice(first))) {
|
||||
const last = text.lastIndexOf("}");
|
||||
const closed = last > first;
|
||||
const tail = closed ? text.slice(last + 1) : "";
|
||||
return `${text.slice(0, first).trimEnd()}${!closed && streaming ? STREAM_MSG : DONE_MSG}${tail}`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// ── 解析(容错:取最后一个 report-json 代码块;缺字段给安全默认) ─────────────────
|
||||
|
||||
const FENCE = "```report-json";
|
||||
const STREAM_MSG = "\n\n> 📊 正在输出大屏结构化数据,右侧沙箱稍后自动渲染…";
|
||||
const DONE_MSG = "\n\n> 📊 已生成大屏数据,右侧沙箱已渲染预览(可继续输入修改要求调整)。";
|
||||
|
||||
/** 文本是否像本契约 JSON(含关键键),避免把无关 {} 误当数据。 */
|
||||
function looksLikeReportJson(s: string): boolean {
|
||||
return /"(seats|consensus|kpis|charts|timeline)"\s*:/.test(s);
|
||||
}
|
||||
|
||||
/** 收集所有 ```lang\n...``` 代码块(lang 可空)。 */
|
||||
function collectFencedBlocks(text: string): Array<{ lang: string; body: string }> {
|
||||
const blocks: Array<{ lang: string; body: string }> = [];
|
||||
const re = /```([^\n`]*)\r?\n([\s\S]*?)```/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
blocks.push({ lang: m[1].trim().toLowerCase(), body: m[2].trim() });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** 扫描出所有顶层平衡的 {…} 对象(字符串内的花括号不计),用于从夹杂文字里捞裸 JSON。 */
|
||||
function balancedJsonObjects(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let start = -1;
|
||||
let inStr = false;
|
||||
let esc = false;
|
||||
for (let i = 0; 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;
|
||||
else if (ch === "{") {
|
||||
if (depth === 0) start = i;
|
||||
depth++;
|
||||
} else if (ch === "}" && depth > 0) {
|
||||
depth--;
|
||||
if (depth === 0 && start >= 0) {
|
||||
out.push(text.slice(start, i + 1));
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function str(v: unknown, fallback = ""): string {
|
||||
return typeof v === "string" ? v : fallback;
|
||||
}
|
||||
|
||||
function num(v: unknown): number {
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function arr(v: unknown): unknown[] {
|
||||
return Array.isArray(v) ? v : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从模型回复里提取并归一化 report-json(找不到 / 解析失败 → null)。
|
||||
* 容错:模型未必严格用 ```report-json 围栏(DeepSeek 等常用 ```json 甚至裸 JSON)——
|
||||
* 依次尝试 report-json / json / 无标注 代码块(同级取最后一个),再退到未闭合围栏内容、
|
||||
* 整段裸 JSON 对象。每个候选都经 coerce 校验,命中契约形状才返回。
|
||||
*/
|
||||
export function extractReportData(text: string | null | undefined): ReportData | null {
|
||||
if (!text) return null;
|
||||
const candidates: string[] = [];
|
||||
const rank = (lang: string) =>
|
||||
lang === "report-json" ? 0 : lang === "json" || lang === "jsonc" ? 1 : lang === "" ? 2 : 3;
|
||||
collectFencedBlocks(text)
|
||||
.map((b, i) => ({ b, i }))
|
||||
.sort((x, y) => rank(x.b.lang) - rank(y.b.lang) || y.i - x.i)
|
||||
.forEach(({ b }) => candidates.push(b.body));
|
||||
// 未闭合围栏(流式截断):取 report-json fence 之后到结尾。
|
||||
const f = text.lastIndexOf(FENCE);
|
||||
if (f >= 0) candidates.push(text.slice(f + FENCE.length).trim());
|
||||
// 裸 JSON(无围栏 / 夹在文字里):所有顶层平衡 {…} 对象,靠后的先试,契约形状的优先。
|
||||
const objs = balancedJsonObjects(text);
|
||||
for (const o of objs.filter(looksLikeReportJson).reverse()) candidates.push(o);
|
||||
for (const o of objs.reverse()) candidates.push(o);
|
||||
|
||||
for (const body of candidates) {
|
||||
const data = body ? parseReportJson(body) : null;
|
||||
if (data) return data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 解析裸 JSON 字符串(草稿/后台作业存的 step3.dashboardJson)并归一化。 */
|
||||
export function parseReportJson(jsonStr: string | null | undefined): ReportData | null {
|
||||
if (!jsonStr?.trim()) return null;
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return coerceReportData(raw);
|
||||
}
|
||||
|
||||
/** 把已 JSON.parse 的对象归一化成 ReportData(缺字段给安全默认;非法 → null)。 */
|
||||
export function coerceReportData(raw: unknown): ReportData | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const o = raw as Record<string, unknown>;
|
||||
|
||||
const seats: ReportSeat[] = arr(o.seats)
|
||||
.map((s, i) => {
|
||||
const so = (s ?? {}) as Record<string, unknown>;
|
||||
const name = str(so.name).trim();
|
||||
if (!name) return null;
|
||||
const id = str(so.id).trim() || `seat-${i + 1}`;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
summary: str(so.summary),
|
||||
sections: arr(so.sections)
|
||||
.map((sec) => {
|
||||
const co = (sec ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
heading: str(co.heading),
|
||||
bullets: arr(co.bullets).map((b) => str(b)).filter(Boolean),
|
||||
};
|
||||
})
|
||||
.filter((sec) => sec.heading || sec.bullets.length > 0),
|
||||
metrics: arr(so.metrics)
|
||||
.map((m) => {
|
||||
const mo = (m ?? {}) as Record<string, unknown>;
|
||||
return { label: str(mo.label), value: Math.min(100, Math.max(0, num(mo.value))) };
|
||||
})
|
||||
.filter((m) => m.label),
|
||||
risks: arr(so.risks)
|
||||
.map((r) => {
|
||||
const ro = (r ?? {}) as Record<string, unknown>;
|
||||
const level = str(ro.level);
|
||||
return {
|
||||
label: str(ro.label),
|
||||
level: (level === "high" || level === "mid" || level === "low" ? level : "mid") as
|
||||
| "high"
|
||||
| "mid"
|
||||
| "low",
|
||||
};
|
||||
})
|
||||
.filter((r) => r.label),
|
||||
dependsOn: arr(so.dependsOn).map((d) => str(d).trim()).filter(Boolean),
|
||||
} as ReportSeat;
|
||||
})
|
||||
.filter((s): s is ReportSeat => s !== null);
|
||||
|
||||
// dependsOn 只保留真实存在的席位 id(模型可能编 id)。
|
||||
const idSet = new Set(seats.map((s) => s.id));
|
||||
for (const s of seats) s.dependsOn = s.dependsOn.filter((d) => idSet.has(d) && d !== s.id);
|
||||
|
||||
const consensusRaw = (o.consensus ?? {}) as Record<string, unknown>;
|
||||
const data: ReportData = {
|
||||
title: str(o.title, "方案研讨大屏"),
|
||||
subtitle: str(o.subtitle),
|
||||
kpis: arr(o.kpis)
|
||||
.map((k) => {
|
||||
const ko = (k ?? {}) as Record<string, unknown>;
|
||||
const trend = str(ko.trend);
|
||||
return {
|
||||
label: str(ko.label),
|
||||
value: str(ko.value, String(ko.value ?? "")),
|
||||
unit: str(ko.unit) || undefined,
|
||||
trend: (trend === "up" || trend === "down" || trend === "flat" ? trend : undefined) as
|
||||
| "up"
|
||||
| "down"
|
||||
| "flat"
|
||||
| undefined,
|
||||
};
|
||||
})
|
||||
.filter((k) => k.label && k.value)
|
||||
.slice(0, 6),
|
||||
consensus: {
|
||||
headline: str(consensusRaw.headline),
|
||||
points: arr(consensusRaw.points).map((p) => str(p)).filter(Boolean),
|
||||
},
|
||||
charts: arr(o.charts)
|
||||
.map((c) => {
|
||||
const co = (c ?? {}) as Record<string, unknown>;
|
||||
const type = str(co.type);
|
||||
return {
|
||||
type: (type === "bar" || type === "donut" || type === "radar" || type === "line"
|
||||
? type
|
||||
: "bar") as "bar" | "donut" | "radar" | "line",
|
||||
title: str(co.title),
|
||||
items: arr(co.items)
|
||||
.map((it) => {
|
||||
const io = (it ?? {}) as Record<string, unknown>;
|
||||
return { label: str(io.label), value: num(io.value) };
|
||||
})
|
||||
.filter((it) => it.label),
|
||||
};
|
||||
})
|
||||
.filter((c) => c.items.length > 0)
|
||||
.slice(0, 3),
|
||||
timeline: arr(o.timeline)
|
||||
.map((t) => {
|
||||
const to = (t ?? {}) as Record<string, unknown>;
|
||||
return { label: str(to.label), desc: str(to.desc) };
|
||||
})
|
||||
.filter((t) => t.label),
|
||||
seats,
|
||||
};
|
||||
if (data.seats.length === 0 && !data.consensus.headline) return null;
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── 模型 prompt(只要 report-json,不调工具、不写文件) ───────────────────────────
|
||||
|
||||
export function buildDashboardPrompt(opts: {
|
||||
intentText: string;
|
||||
seatDeliveries: Array<{ sender: string; content: string }>;
|
||||
consensusText: string;
|
||||
}): string {
|
||||
const parts: string[] = [
|
||||
"【结构化数据模式】本轮**不要调用任何工具、不要写任何文件、不要输出 HTML**。",
|
||||
"请通读以下圆桌研讨成果,把它们提炼成一份大屏展示数据,在回复**最末尾**输出一个完整的 ```report-json 代码块(契约见下),代码块之外只允许一两句简短说明。",
|
||||
"",
|
||||
"# 一、任务意图",
|
||||
(opts.intentText || "").trim() || "(未提供)",
|
||||
"",
|
||||
"# 二、各席位最终交付",
|
||||
];
|
||||
opts.seatDeliveries.forEach((d, i) => {
|
||||
parts.push(`## ${i + 1}. ${d.sender}`);
|
||||
parts.push((d.content || "").trim() || "(无内容)");
|
||||
parts.push("");
|
||||
});
|
||||
parts.push("# 三、总控最终共识结论");
|
||||
parts.push((opts.consensusText || "").trim() || "(未提供)");
|
||||
parts.push("");
|
||||
parts.push(
|
||||
[
|
||||
"```report-json 契约(严格 JSON,全程简体中文):",
|
||||
'{"title":"大屏主标题(≤20字)","subtitle":"一句话定位",',
|
||||
' "kpis":[{"label":"指标名","value":"数值","unit":"单位","trend":"up|down|flat"}], // 3-6 个核心指标,从研讨内容提取或合理估算',
|
||||
' "consensus":{"headline":"一句话最终结论","points":["结论要点,3-6 条"]},',
|
||||
' "charts":[{"type":"line|bar|donut|radar","title":"图表标题","items":[{"label":"维度","value":数字}]}], // 1-3 个总览图表:line=趋势(销量/渗透率随时间)、donut=占比(份额/构成)、radar=多维对比(竞争力)、bar=排名;3-8 项/图',
|
||||
' "timeline":[{"label":"阶段名","desc":"一句话说明"}], // 执行路线 3-6 阶段',
|
||||
' "seats":[{"id":"seat-1","name":"席位名(与交付席位一致)","summary":"该席位结论一两句",',
|
||||
' "sections":[{"heading":"小节标题","bullets":["要点,每条≤40字"]}], // 2-4 节,提炼该席位交付',
|
||||
' "metrics":[{"label":"量化指标名","value":0到100}], // 0-3 个',
|
||||
' "risks":[{"label":"风险点","level":"high|mid|low"}], // 0-4 个',
|
||||
' "dependsOn":["它承接哪些席位的产物(席位id)"]}]} // 每个交付席位一项;依据交付内容判断依赖关系',
|
||||
"硬性要求:seats 覆盖**每一个**有交付的席位;id 用 seat-1..seat-N;dependsOn 只引用本清单内的 id;JSON 必须可被 JSON.parse 解析(无注释、无尾逗号)。",
|
||||
].join("\n"),
|
||||
);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
// ── 主题(2026-06-13):跟随应用当前主题绘制 ─────────────────────────────────────
|
||||
// light = 《浅红色 UI 规范》(docs/浅红色UI规范):白/浅灰大底、#F5222D 品牌红强调、
|
||||
// 卡片 4px 圆角、语义色 成功#189D72/警告#FF9900/错误#F5222D/信息#0079FE;
|
||||
// dark = 《深蓝色 UI 规范》(docs/ui规范-深蓝/tokens.css):页面#0C1836、卡片#122347、
|
||||
// 主色#1677FF、辅色#13C2C2、文字#C5CFE0、语义 成功#52C41A/警告#FAAD14/错误#F5222D。
|
||||
|
||||
export type DashboardTheme = "light" | "dark";
|
||||
|
||||
interface Palette {
|
||||
bgPage: string;
|
||||
bgGradient: string;
|
||||
gridLine: string;
|
||||
card: string;
|
||||
cardBorder: string;
|
||||
cardGlow: string;
|
||||
bracket: string;
|
||||
text: string;
|
||||
textSecondary: string;
|
||||
textStrong: string;
|
||||
heading: string;
|
||||
primary: string;
|
||||
link: string;
|
||||
titleColor: string;
|
||||
navBg: string;
|
||||
/** 深色主题大屏背景图(自包含 HTML 内联 data URI);浅色为空串走纯色底。 */
|
||||
bgImage: string;
|
||||
/** 导航选中态 tab 背景(浅=白,深=深蓝卡片色)。 */
|
||||
navActiveBg: string;
|
||||
/** 导航 tab 悬停背景。 */
|
||||
tabHoverBg: string;
|
||||
trackBg: string;
|
||||
/** 图表 / KPI / 席位强调色序(规范图表色序)。 */
|
||||
accents: string[];
|
||||
risk: { high: string; mid: string; low: string };
|
||||
modalMask: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深色主题大屏背景图:从 SVG(内嵌 JPEG)里取出其 data URI,内联进自包含 HTML
|
||||
* 当背景图用 —— 避免 iframe srcDoc / blob 新窗口 / 下载分享场景下相对路径取不到图
|
||||
* (保持离线自包含;草稿存的是 report-json 不是 HTML,不会被这张图撑大)。
|
||||
*/
|
||||
const DARK_BG_DATA_URI: string = (() => {
|
||||
const m = /href="(data:image\/[^"]+)"/.exec(darkBgSvgRaw);
|
||||
return m ? m[1] : "";
|
||||
})();
|
||||
|
||||
const PALETTES: Record<DashboardTheme, Palette> = {
|
||||
dark: {
|
||||
bgPage: "#0C1836",
|
||||
bgGradient:
|
||||
"radial-gradient(1200px 600px at 70% -10%,rgba(22,119,255,.14),transparent),linear-gradient(160deg,#060E24,#0C1836)",
|
||||
gridLine: "rgba(64,150,255,.06)",
|
||||
card: "linear-gradient(160deg,#1A2F5A,#122347)",
|
||||
cardBorder: "rgba(64,150,255,.28)",
|
||||
cardGlow: "0 0 24px rgba(6,14,36,.5) inset,0 4px 18px rgba(6,14,36,.35)",
|
||||
bracket: "#4096FF",
|
||||
text: "#C5CFE0",
|
||||
textSecondary: "#6E88B0",
|
||||
textStrong: "#FFFFFF",
|
||||
heading: "#91CAFF",
|
||||
primary: "#1677FF",
|
||||
link: "#4096FF",
|
||||
titleColor: "linear-gradient(90deg,#91CAFF,#4096FF 45%,#13C2C2)",
|
||||
navBg: "rgba(10,20,46,.78)",
|
||||
bgImage: DARK_BG_DATA_URI,
|
||||
navActiveBg: "#122347",
|
||||
tabHoverBg: "rgba(64,150,255,.12)",
|
||||
trackBg: "rgba(110,136,176,.22)",
|
||||
accents: ["#4096FF", "#13C2C2", "#52C41A", "#FAAD14", "#FF7875", "#69B1FF"],
|
||||
risk: { high: "#F5222D", mid: "#FAAD14", low: "#52C41A" },
|
||||
modalMask: "rgba(6,14,36,.78)",
|
||||
},
|
||||
// 蓝图/工程图风(参考大屏设计图):白底 + slate 网格、玫瑰红(rose-600)强调、
|
||||
// slate 中性文字、卡片四角红色角标、席位色序(scheme红/ux靛/ad绿/risk琥珀/summary天蓝)。
|
||||
light: {
|
||||
bgPage: "#F8FAFC",
|
||||
bgGradient:
|
||||
"radial-gradient(1100px 520px at 78% -12%,rgba(225,29,72,.05),transparent),linear-gradient(180deg,#FFFFFF,#F8FAFC)",
|
||||
gridLine: "rgba(148,163,184,.16)",
|
||||
card: "#FFFFFF",
|
||||
cardBorder: "rgba(226,232,240,.9)",
|
||||
cardGlow: "0 3px 10px rgba(15,23,42,.04)",
|
||||
bracket: "#E11D48",
|
||||
text: "#475569",
|
||||
textSecondary: "#64748B",
|
||||
textStrong: "#1E293B",
|
||||
heading: "#1E293B",
|
||||
primary: "#E11D48",
|
||||
link: "#E11D48",
|
||||
titleColor: "linear-gradient(90deg,#1E293B,#334155)",
|
||||
navBg: "#F1F5F9",
|
||||
bgImage: "",
|
||||
navActiveBg: "#FFFFFF",
|
||||
tabHoverBg: "rgba(255,255,255,.6)",
|
||||
trackBg: "#F1F5F9",
|
||||
accents: ["#E11D48", "#4F46E5", "#059669", "#D97706", "#0284C7", "#7C3AED"],
|
||||
risk: { high: "#DC2626", mid: "#D97706", low: "#059669" },
|
||||
modalMask: "rgba(15,23,42,.42)",
|
||||
},
|
||||
};
|
||||
|
||||
// ── 渲染(确定性模板,全部文本 HTML 转义) ────────────────────────────────────────
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// 渲染期间的当前色板:renderDashboardHtml 开头按主题设置(同步渲染,无并发竞争),
|
||||
// 各 SVG/片段帮助函数直接读,避免把 palette 穿透每个函数签名。
|
||||
let P: Palette = PALETTES.dark;
|
||||
|
||||
function svgBar(chart: ReportChart): string {
|
||||
const items = chart.items.slice(0, 6);
|
||||
const max = Math.max(...items.map((i) => i.value), 1);
|
||||
const W = 300;
|
||||
const rowH = 30;
|
||||
const H = items.length * rowH + 6;
|
||||
const rows = items
|
||||
.map((it, i) => {
|
||||
const w = Math.max(4, (it.value / max) * (W - 110));
|
||||
const y = i * rowH + 6;
|
||||
const color = P.accents[i % P.accents.length];
|
||||
return (
|
||||
`<text x="0" y="${y + 13}" class="cl">${esc(it.label)}</text>` +
|
||||
`<rect x="92" y="${y + 3}" width="${W - 110}" height="14" rx="7" class="ct"/>` +
|
||||
`<rect x="92" y="${y + 3}" width="${w}" height="14" rx="7" fill="${color}" opacity="0.85"/>` +
|
||||
`<text x="${W - 12}" y="${y + 14}" class="cv" text-anchor="end">${esc(String(it.value))}</text>`
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
return `<svg viewBox="0 0 ${W} ${H}" role="img">${rows}</svg>`;
|
||||
}
|
||||
|
||||
function svgDonut(chart: ReportChart): string {
|
||||
const items = chart.items.slice(0, 6);
|
||||
const total = items.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 = items
|
||||
.map((it, i) => {
|
||||
const len = (Math.max(0, it.value) / total) * C;
|
||||
const seg = `<circle cx="70" cy="70" r="${R}" fill="none" stroke="${P.accents[i % P.accents.length]}" stroke-width="${SW}" stroke-dasharray="${len.toFixed(2)} ${(C - len).toFixed(2)}" stroke-dashoffset="${(-acc).toFixed(2)}" transform="rotate(-90 70 70)"/>`;
|
||||
acc += len;
|
||||
return seg;
|
||||
})
|
||||
.join("");
|
||||
// 中心:总分类数 + 标签(对齐设计图环形图的镂空中心)。
|
||||
const center = `<text x="70" y="68" text-anchor="middle" class="dc-num">${items.length}</text><text x="70" y="82" text-anchor="middle" class="dc-cap">类</text>`;
|
||||
return `<svg viewBox="0 0 140 140" role="img"><circle cx="70" cy="70" r="${R}" fill="none" stroke="var(--track)" stroke-width="${SW}"/>${segs}${center}</svg>`;
|
||||
}
|
||||
|
||||
// 折线图(趋势):多取前 8 点;自动归一化到画布。设计图的销量趋势用折线。
|
||||
function svgLine(chart: ReportChart): string {
|
||||
const items = chart.items.slice(0, 8);
|
||||
if (items.length < 2) return svgBar(chart);
|
||||
const W = 460;
|
||||
const H = 180;
|
||||
const padL = 36;
|
||||
const padR = 16;
|
||||
const padT = 16;
|
||||
const padB = 30;
|
||||
const max = Math.max(...items.map((i) => i.value), 1);
|
||||
const min = Math.min(...items.map((i) => i.value), 0);
|
||||
const span = max - min || 1;
|
||||
const x = (i: number) => padL + (i / (items.length - 1)) * (W - padL - padR);
|
||||
const y = (v: number) => padT + (1 - (v - min) / span) * (H - padT - padB);
|
||||
const pts = items.map((it, i) => `${x(i).toFixed(1)},${y(it.value).toFixed(1)}`).join(" ");
|
||||
const grid = [0, 0.25, 0.5, 0.75, 1]
|
||||
.map((k) => {
|
||||
const gy = (padT + k * (H - padT - padB)).toFixed(1);
|
||||
return `<line x1="${padL}" y1="${gy}" x2="${W - padR}" y2="${gy}" class="gr"/>`;
|
||||
})
|
||||
.join("");
|
||||
const area = `<polygon points="${padL},${(H - padB).toFixed(1)} ${pts} ${(W - padR).toFixed(1)},${(H - padB).toFixed(1)}" fill="${P.primary}12"/>`;
|
||||
const dots = items
|
||||
.map((it, i) => `<circle cx="${x(i).toFixed(1)}" cy="${y(it.value).toFixed(1)}" r="3" fill="#fff" stroke="${P.primary}" stroke-width="2"/>`)
|
||||
.join("");
|
||||
const labels = items
|
||||
.map((it, i) => `<text x="${x(i).toFixed(1)}" y="${H - 10}" text-anchor="middle" class="cl">${esc(it.label)}</text>`)
|
||||
.join("");
|
||||
return `<svg viewBox="0 0 ${W} ${H}" role="img">${grid}${area}<polyline points="${pts}" fill="none" stroke="${P.primary}" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"/>${dots}${labels}</svg>`;
|
||||
}
|
||||
|
||||
// 雷达:只画网格 + 数据多边形 + 顶点(**不在 SVG 内放文字标签**,标签由 chartLegend
|
||||
// 作 HTML 旁注,避免 SVG 文字溢出/重叠——这是参考实现不错乱的关键)。
|
||||
function svgRadar(chart: ReportChart): string {
|
||||
const items = chart.items.slice(0, 6);
|
||||
const n = Math.max(items.length, 3);
|
||||
const cx = 100;
|
||||
const cy = 100;
|
||||
const R = 72;
|
||||
const max = Math.max(...items.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(",");
|
||||
const grid = [0.25, 0.5, 0.75, 1]
|
||||
.map((k) => `<polygon points="${Array.from({ length: n }, (_, i) => ptS(i, R * k)).join(" ")}" class="gr"/>`)
|
||||
.join("");
|
||||
const axes = Array.from({ length: n }, (_, i) => {
|
||||
const [px, py] = pt(i, R);
|
||||
return `<line x1="${cx}" y1="${cy}" x2="${px.toFixed(1)}" y2="${py.toFixed(1)}" class="gr"/>`;
|
||||
}).join("");
|
||||
const poly = `<polygon points="${items.map((it, i) => ptS(i, (Math.max(0, it.value) / max) * R)).join(" ")}" fill="${P.primary}26" stroke="${P.primary}" stroke-width="2"/>`;
|
||||
const dots = items
|
||||
.map((it, i) => {
|
||||
const [px, py] = pt(i, (Math.max(0, it.value) / max) * R);
|
||||
return `<circle cx="${px.toFixed(1)}" cy="${py.toFixed(1)}" r="2.4" fill="${P.primary}"/>`;
|
||||
})
|
||||
.join("");
|
||||
return `<svg viewBox="0 0 200 200" role="img">${grid}${axes}${poly}${dots}</svg>`;
|
||||
}
|
||||
|
||||
// 图表旁注(色点 + 名称 + 值):donut 用分类色点、radar 用主色点。
|
||||
function chartLegend(chart: ReportChart, singleColor: boolean): string {
|
||||
return `<div class="clist">${chart.items
|
||||
.slice(0, 6)
|
||||
.map(
|
||||
(it, i) =>
|
||||
`<div class="cli"><span class="cdot" style="background:${singleColor ? P.primary : P.accents[i % P.accents.length]}"></span><span class="cnm">${esc(it.label)}</span><b>${esc(String(it.value))}</b></div>`,
|
||||
)
|
||||
.join("")}</div>`;
|
||||
}
|
||||
|
||||
function chartHtml(chart: ReportChart): string {
|
||||
const t = `<h4 class="ctitle">${esc(chart.title || "图表")}</h4>`;
|
||||
if (chart.type === "donut")
|
||||
return `<div class="card chart">${t}<div class="cbody"><div class="cgraph cdonut">${svgDonut(chart)}</div>${chartLegend(chart, false)}</div></div>`;
|
||||
if (chart.type === "radar")
|
||||
return `<div class="card chart">${t}<div class="cbody"><div class="cgraph cradar">${svgRadar(chart)}</div>${chartLegend(chart, true)}</div></div>`;
|
||||
if (chart.type === "line")
|
||||
return `<div class="card chart">${t}<div class="cgraph wide">${svgLine(chart)}</div></div>`;
|
||||
return `<div class="card chart">${t}<div class="cgraph wide">${svgBar(chart)}</div></div>`;
|
||||
}
|
||||
|
||||
const RISK_LABEL = { high: "高", mid: "中", low: "低" } as const;
|
||||
|
||||
function riskChips(risks: ReportSeatRisk[]): string {
|
||||
if (risks.length === 0) return "";
|
||||
return `<div class="chips">${risks
|
||||
.map((r) => {
|
||||
const color = P.risk[r.level]; // 主题语义色:错误/警告/成功
|
||||
return `<span class="chip" style="border-color:${color};color:${color};background:${color}14">${RISK_LABEL[r.level]}风险 · ${esc(r.label)}</span>`;
|
||||
})
|
||||
.join("")}</div>`;
|
||||
}
|
||||
|
||||
function metricBars(metrics: ReportSeatMetric[]): string {
|
||||
if (metrics.length === 0) return "";
|
||||
return `<div class="metrics">${metrics
|
||||
.map(
|
||||
(m, i) =>
|
||||
`<div class="metric"><div class="mrow"><span>${esc(m.label)}</span><b>${m.value}</b></div><div class="track"><div class="fill" style="width:${m.value}%;background:${P.accents[i % P.accents.length]}"></div></div></div>`,
|
||||
)
|
||||
.join("")}</div>`;
|
||||
}
|
||||
|
||||
/** 依赖双向 chips:deps = 本席位依赖谁;supports = 谁依赖本席位(反向推导)。 */
|
||||
function depChips(seat: ReportSeat, all: ReportSeat[]): string {
|
||||
const byId = new Map(all.map((s) => [s.id, s]));
|
||||
const supports = all.filter((s) => s.dependsOn.includes(seat.id));
|
||||
const dep = seat.dependsOn
|
||||
.map((d) => byId.get(d))
|
||||
.filter((s): s is ReportSeat => !!s)
|
||||
.map((s) => `<a class="dep" href="#${esc(s.id)}">依赖 → ${esc(s.name)}</a>`)
|
||||
.join("");
|
||||
const sup = supports
|
||||
.map((s) => `<a class="dep sup" href="#${esc(s.id)}">${esc(s.name)} ← 承接本席位</a>`)
|
||||
.join("");
|
||||
if (!dep && !sup) return "";
|
||||
return `<div class="deps">${dep}${sup}</div>`;
|
||||
}
|
||||
|
||||
/** 渲染大屏多页 HTML(自包含 · 零 JS · :target 导航 · 跟随应用主题)。 */
|
||||
export function renderDashboardHtml(
|
||||
data: ReportData,
|
||||
opts?: { generatedAt?: string; theme?: DashboardTheme },
|
||||
): string {
|
||||
P = PALETTES[opts?.theme ?? "dark"];
|
||||
const seats = data.seats;
|
||||
// 导航图标(内联 SVG,currentColor 跟随选中态变红):总览=指南针,席位按序循环。
|
||||
const SVG = (paths: string) =>
|
||||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
|
||||
const NAV_COMPASS = SVG(
|
||||
'<circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>',
|
||||
);
|
||||
const NAV_ICONS = [
|
||||
SVG('<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>'),
|
||||
SVG('<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>'),
|
||||
SVG('<path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>'),
|
||||
SVG('<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="M12 8v4"/><path d="M12 16h.01"/>'),
|
||||
SVG('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v5h5"/><path d="M16 13H8"/><path d="M16 17H8"/>'),
|
||||
SVG('<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>'),
|
||||
];
|
||||
const nav =
|
||||
`<a class="tab" href="#overview">${NAV_COMPASS}<span>总览</span></a>` +
|
||||
seats
|
||||
.map(
|
||||
(s, i) =>
|
||||
`<a class="tab" href="#${esc(s.id)}">${NAV_ICONS[i % NAV_ICONS.length]}<span>${esc(s.name)}</span></a>`,
|
||||
)
|
||||
.join("");
|
||||
// 导航当前页高亮(纯 CSS):用 :has 反查 #page:target → 高亮对应 tab;
|
||||
// overview 在「无任何 :target」时默认高亮。每个 seat 生成一组规则。
|
||||
const sel = (id: string) => `.tab[href="#${id}"]`;
|
||||
const ovBase = `body:not(:has(.page:target)) ${sel("overview")},body:has(#overview:target) ${sel("overview")}`;
|
||||
const seatBase = seats.map((s) => `body:has(#${esc(s.id)}:target) ${sel(s.id)}`).join(",");
|
||||
const allActive = seatBase ? `${ovBase},${seatBase}` : ovBase;
|
||||
const navActiveCss = `${allActive}{background:var(--navactive);color:var(--primary);box-shadow:0 1px 5px rgba(15,23,42,.1);font-weight:700;border-color:${P.primary}33}`;
|
||||
|
||||
const KPI_ICONS = ["📊", "📈", "⚠️", "🧩", "🏆", "📅"];
|
||||
const kpis = data.kpis
|
||||
.map((k, i) => {
|
||||
const ac = P.accents[i % P.accents.length];
|
||||
return `<div class="card kpi">
|
||||
<div class="khead"><span class="klabel">${esc(k.label)}</span><span class="kico" style="background:${ac}1a;color:${ac}">${KPI_ICONS[i % KPI_ICONS.length]}</span></div>
|
||||
<div class="kval">${esc(k.value)}<small>${esc(k.unit ?? "")}</small>${
|
||||
k.trend === "up" ? '<i class="tr up">▲</i>' : k.trend === "down" ? '<i class="tr dn">▼</i>' : ""
|
||||
}</div></div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const seatCards = seats
|
||||
.map((s, i) => {
|
||||
const color = P.accents[i % P.accents.length];
|
||||
return `<a class="card seat" href="#${esc(s.id)}" style="--ac:${color}">
|
||||
<div class="shead"><i></i><h3>${esc(s.name)}</h3><span class="go">席位页 →</span></div>
|
||||
<p>${esc(s.summary || "(无摘要)")}</p>
|
||||
${metricBars(s.metrics.slice(0, 2))}
|
||||
${riskChips(s.risks.slice(0, 2))}
|
||||
</a>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const timeline = data.timeline.length
|
||||
? `<div class="card tl"><h3>执行路线</h3><ol>${data.timeline
|
||||
.map((t) => `<li><b>${esc(t.label)}</b><span>${esc(t.desc)}</span></li>`)
|
||||
.join("")}</ol></div>`
|
||||
: "";
|
||||
|
||||
const consensusPoints = data.consensus.points.map((p) => `<li>${esc(p)}</li>`).join("");
|
||||
|
||||
const console = data.charts.length
|
||||
? `<section class="console">
|
||||
<div class="chead"><h2 class="sec">📊 数据研判中心</h2><span class="status">STATUS: REAL-TIME CONSOLE</span></div>
|
||||
<div class="charts3">${data.charts.map(chartHtml).join("")}</div>
|
||||
</section>`
|
||||
: "";
|
||||
const overview = `<section class="page" id="overview">
|
||||
<div class="kpis">${kpis}</div>
|
||||
<div class="card consensus"><h3>总控最终共识</h3>
|
||||
<p class="headline">${esc(data.consensus.headline || "(未提供)")}</p>
|
||||
<ul>${consensusPoints}</ul>
|
||||
<a class="more" href="#consensus-modal">查看研讨详情 →</a>
|
||||
</div>
|
||||
${console}
|
||||
<h2 class="sec">📋 席位交付总览 <small>点击卡片进入对应席位专页</small></h2>
|
||||
<div class="seats">${seatCards}</div>
|
||||
${timeline}
|
||||
</section>`;
|
||||
|
||||
const seatPages = seats
|
||||
.map((s, i) => {
|
||||
const color = P.accents[i % P.accents.length];
|
||||
const prev = seats[(i - 1 + seats.length) % seats.length];
|
||||
const next = seats[(i + 1) % seats.length];
|
||||
const sections = s.sections
|
||||
.map(
|
||||
(sec) =>
|
||||
`<div class="card sect"><h3>${esc(sec.heading)}</h3><ul>${sec.bullets
|
||||
.map((b) => `<li>${esc(b)}</li>`)
|
||||
.join("")}</ul></div>`,
|
||||
)
|
||||
.join("");
|
||||
// 风险提示 + 席位依赖关系:移到主内容下方整行展示(去掉原右栏「席位评定量化得分」)。
|
||||
// 两者都有 → 下方两列并排;只剩依赖关系(无风险)→ 依赖关系占满整行,主内容已自适应满宽。
|
||||
const risksCard = s.risks.length
|
||||
? `<div class="card"><h3>风险提示 / 关注点</h3>${riskChips(s.risks)}</div>`
|
||||
: "";
|
||||
const depCard = `<div class="card"><h3>席位依赖关系</h3>${depChips(s, seats) || '<p class="empty">无显式依赖</p>'}</div>`;
|
||||
const bottomCards = [risksCard, depCard].filter(Boolean);
|
||||
const bottom = bottomCards.length
|
||||
? `<div class="dbottom" style="grid-template-columns:${
|
||||
bottomCards.length === 2 ? "1fr 1fr" : "1fr"
|
||||
}">${bottomCards.join("")}</div>`
|
||||
: "";
|
||||
return `<section class="page" id="${esc(s.id)}" style="--ac:${color}">
|
||||
<div class="pbar">
|
||||
<a class="back" href="#overview">← 返回总览</a>
|
||||
<div class="pnav"><a href="#${esc(prev.id)}">‹ ${esc(prev.name)}</a><a href="#${esc(next.id)}">${esc(next.name)} ›</a></div>
|
||||
</div>
|
||||
<header class="phead"><i></i><div><h2>${esc(s.name)} <span class="ptag">SEAT · ${esc(s.id.toUpperCase())}</span></h2><p>${esc(s.summary)}</p></div></header>
|
||||
<div class="dmain">
|
||||
<div class="card"><h3>席位核心交付 · 使命概述</h3><p class="lead">${esc(s.summary || "(无摘要)")}</p></div>
|
||||
<div class="psections">${sections || '<div class="card sect"><p class="empty">(该席位无结构化小节)</p></div>'}</div>
|
||||
</div>
|
||||
${bottom}
|
||||
</section>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const modal = `<div class="modal" id="consensus-modal"><div class="mbody card">
|
||||
<a class="mclose" href="#overview">×</a>
|
||||
<h3>总控最终共识 · 全部要点</h3>
|
||||
<p class="headline">${esc(data.consensus.headline)}</p>
|
||||
<ul>${consensusPoints}</ul>
|
||||
</div></div>`;
|
||||
|
||||
const generated = opts?.generatedAt ? `生成于 ${esc(opts.generatedAt)}` : "";
|
||||
|
||||
return `<!doctype html>${RT_DASHBOARD_MARKER}
|
||||
<html lang="zh-CN"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${esc(data.title)}</title>
|
||||
<style>
|
||||
/* 蓝图工程图风模板(light=参考大屏设计图 / dark=深蓝 token,渲染时注入 palette) */
|
||||
:root{--line:${P.cardBorder};--txt:${P.text};--dim:${P.textSecondary};--strong:${P.textStrong};
|
||||
--primary:${P.primary};--link:${P.link};--heading:${P.heading};--track:${P.trackBg};--bracket:${P.bracket};
|
||||
--navbg:${P.navBg};--navactive:${P.navActiveBg};--tabhover:${P.tabHoverBg}}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:"Inter","Noto Sans SC","Microsoft YaHei",system-ui,sans-serif;color:var(--txt);min-height:100vh;
|
||||
font-size:13px;background:${P.bgImage ? `url("${P.bgImage}") center top/cover no-repeat fixed,` : ""}${P.bgPage}}
|
||||
/* 蓝图圆点网格底纹(对齐设计原图的 radial dots) */
|
||||
body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:0;
|
||||
background-image:radial-gradient(${P.gridLine} 1.3px,transparent 1.3px);background-size:20px 20px}
|
||||
.accentbar{position:relative;z-index:2;height:4px;background:linear-gradient(90deg,${P.accents[0]},${P.accents[3]} 50%,${P.accents[4]})}
|
||||
main{position:relative;z-index:1;padding:20px 26px 48px;max-width:1500px;margin:0 auto}
|
||||
/* Header:标题 + 副标题(换行)+ 生成时间药丸(非吸顶,居中容器内) */
|
||||
.top{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:14px;
|
||||
padding-bottom:16px;margin-bottom:20px;border-bottom:1px solid ${P.primary}24}
|
||||
.top .ttl{min-width:0}
|
||||
.top h1{font-size:22px;font-weight:800;letter-spacing:.3px;color:var(--strong);line-height:1.25}
|
||||
.top .sub{margin-top:6px;font-size:12.5px;color:var(--dim);letter-spacing:.3px}
|
||||
.top .meta{font-size:11px;color:var(--dim);font-family:"JetBrains Mono",Consolas,monospace;letter-spacing:.5px;
|
||||
background:#fff;border:1px solid var(--line);border-radius:6px;padding:6px 12px;white-space:nowrap}
|
||||
/* Nav:浅灰圆角胶囊容器 + 图标文字 tab(对齐设计原图) */
|
||||
nav{position:relative;z-index:1;margin-bottom:22px}
|
||||
.navwrap{display:inline-flex;flex-wrap:wrap;gap:6px;background:var(--navbg);border:1px solid var(--line);border-radius:10px;padding:5px;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}
|
||||
.tab{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;padding:6px 12px;
|
||||
border:1px solid transparent;border-radius:7px;color:var(--dim);text-decoration:none;transition:.15s;background:transparent}
|
||||
.tab:hover{color:var(--strong);background:var(--tabhover)}
|
||||
.tab svg{width:14px;height:14px;flex:none}
|
||||
.page{display:none;animation:fi .25s ease}
|
||||
#overview{display:block}
|
||||
.page:target{display:block}
|
||||
body:has(.page:target) #overview:not(:target){display:none}
|
||||
@keyframes fi{from{opacity:0;transform:translateY(8px)}to{opacity:1}}
|
||||
/* 蓝图卡片:白底 + 四角玫瑰红角标 + 极淡网格 overlay */
|
||||
.card{position:relative;border:1px solid var(--line);border-radius:6px;padding:17px 19px;
|
||||
background:${P.card};box-shadow:${P.cardGlow};transition:box-shadow .3s,transform .3s,border-color .3s}
|
||||
.card::before,.card::after{content:"";position:absolute;width:12px;height:12px;border-color:var(--bracket);border-style:solid;z-index:2}
|
||||
.card::before{top:-1px;left:-1px;border-width:2px 0 0 2px}
|
||||
.card::after{bottom:-1px;right:-1px;border-width:0 2px 2px 0}
|
||||
.card>*{position:relative;z-index:1}
|
||||
.card h3{font-size:13.5px;font-weight:700;letter-spacing:.3px;color:var(--strong);margin-bottom:12px;
|
||||
display:flex;align-items:center;gap:7px;padding-bottom:11px;border-bottom:1px solid var(--line)}
|
||||
.card h3::before{content:"";width:9px;height:9px;border-radius:2px;background:var(--primary);flex:none}
|
||||
/* KPI */
|
||||
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:13px;margin-bottom:18px}
|
||||
.kpi{padding:15px 17px;min-height:104px;display:flex;flex-direction:column;justify-content:space-between}
|
||||
.kpi .khead{display:flex;justify-content:space-between;align-items:flex-start;gap:6px}
|
||||
.kpi .klabel{font-size:11.5px;color:var(--dim);font-weight:500;letter-spacing:.3px}
|
||||
.kpi .kico{width:30px;height:30px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:15px;flex:none}
|
||||
.kpi .kval{font-size:30px;font-weight:800;letter-spacing:-.5px;color:var(--strong);font-family:"JetBrains Mono",Consolas,monospace;line-height:1;display:flex;align-items:baseline;gap:3px;margin-top:8px}
|
||||
.kpi small{font-size:12px;font-weight:500;color:var(--dim)}
|
||||
.kpi .tr{font-size:11px;font-weight:700;padding:1px 5px;border-radius:3px;margin-left:4px}
|
||||
.tr.up{color:${P.risk.low};background:${P.risk.low}1a}.tr.dn{color:${P.risk.high};background:${P.risk.high}1a}
|
||||
.ovgrid{display:grid;grid-template-columns:1.25fr 1fr;gap:13px;margin-bottom:20px;align-items:start}
|
||||
@media(max-width:980px){.ovgrid{grid-template-columns:1fr}}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin-bottom:14px}
|
||||
@media(max-width:760px){.grid2{grid-template-columns:1fr}}
|
||||
/* 共识:玫瑰红竖条摘要 + 要点列表 */
|
||||
.consensus .headline{font-size:13.5px;font-weight:600;color:var(--strong);line-height:1.7;margin-bottom:13px;
|
||||
background:${P.primary}0d;padding:12px 14px;border-radius:4px;border-left:3px solid var(--primary)}
|
||||
.consensus ul{list-style:none;display:grid;gap:11px}
|
||||
.consensus li{display:flex;gap:9px;font-size:12px;line-height:1.65;color:var(--txt)}
|
||||
.consensus li::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--primary);margin-top:6px;flex:none}
|
||||
.consensus .more{display:inline-block;margin-top:13px;font-size:11.5px;font-weight:600;color:var(--primary);text-decoration:none}
|
||||
.consensus .more:hover{text-decoration:underline}
|
||||
.charts{display:grid;gap:13px}
|
||||
.chart svg{width:100%;height:auto;display:block}
|
||||
.cl{fill:var(--dim);font-size:9.5px}.cv{fill:var(--strong);font-size:11px;font-weight:700;font-family:monospace}
|
||||
.ct{fill:var(--track)}.gr{fill:none;stroke:${P.gridLine.replace(/[\d.]+\)$/, "0.6)")};stroke:var(--line);stroke-width:1}
|
||||
.dc-num{fill:var(--strong);font-size:26px;font-weight:800;font-family:monospace}
|
||||
.dc-cap{fill:var(--dim);font-size:9px}
|
||||
.donut{display:flex;align-items:center;gap:14px;flex-wrap:wrap;justify-content:center}.donut svg{width:128px;flex:none}
|
||||
.lgs{display:grid;gap:7px;min-width:130px}.lg{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--txt)}
|
||||
.lg span{flex:1}.lg b{color:var(--strong);font-family:monospace;font-weight:700}
|
||||
.lg i{display:inline-block;width:9px;height:9px;border-radius:2px;flex:none}
|
||||
.sec{font-size:15px;font-weight:700;color:var(--strong);margin:8px 0 13px;display:flex;align-items:center;gap:8px}
|
||||
.sec small{font-size:11.5px;color:var(--dim);font-weight:400}
|
||||
.seats{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:13px;margin-bottom:20px}
|
||||
.seat{display:flex;flex-direction:column;text-decoration:none;color:inherit}
|
||||
.seat:hover{border-color:var(--ac);box-shadow:0 8px 22px rgba(15,23,42,.09);transform:translateY(-2px)}
|
||||
.shead{display:flex;align-items:center;gap:7px;padding-bottom:10px;border-bottom:1px solid var(--line);margin-bottom:0}
|
||||
.shead::before{display:none}
|
||||
.shead i{width:9px;height:9px;border-radius:50%;background:var(--ac);flex:none}
|
||||
.shead h3{margin:0;padding:0;border:0;font-size:12.5px;flex:1}
|
||||
.shead h3::before{display:none}
|
||||
.go{font-size:10px;color:var(--dim);display:flex;align-items:center;gap:2px}
|
||||
.seat:hover .go{color:var(--ac)}
|
||||
.seat .anly{font-size:10px;color:var(--dim);font-family:monospace;text-transform:uppercase;letter-spacing:.5px;margin:11px 0 6px}
|
||||
.seat p{font-size:11.5px;color:var(--dim);line-height:1.65;margin-bottom:12px;flex:1;
|
||||
display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.metrics{display:grid;gap:9px}
|
||||
.metric span{font-size:10.5px;color:var(--dim);font-weight:500}
|
||||
.metric .mrow{display:flex;justify-content:space-between;margin-bottom:4px}
|
||||
.metric b{font-size:11px;color:var(--strong);font-family:monospace;font-weight:700}
|
||||
.track{height:6px;border-radius:99px;background:var(--track);overflow:hidden}
|
||||
.fill{height:100%;border-radius:99px}
|
||||
.chips{display:flex;flex-direction:column;gap:6px;margin-top:12px;padding-top:11px;border-top:1px solid var(--line)}
|
||||
.chip{font-size:10px;padding:3px 8px;border-radius:4px;border-left:2px solid;display:flex;align-items:center;gap:5px}
|
||||
.chip::before{content:"⚠";font-size:9px}
|
||||
.tl{margin-top:2px}
|
||||
.tl ol{list-style:none;display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:11px}
|
||||
.tl li{position:relative;padding:12px 14px;border:1px solid var(--line);border-left:3px solid var(--primary);background:var(--track);border-radius:0 5px 5px 0}
|
||||
.tl b{display:block;font-size:12.5px;font-weight:700;color:var(--strong);margin-bottom:5px}
|
||||
.tl span{font-size:11px;color:var(--dim);line-height:1.6}
|
||||
/* 席位专页 */
|
||||
.pbar{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;font-size:12px}
|
||||
.pbar a{color:var(--primary);text-decoration:none;font-weight:600}.pnav{display:flex;gap:16px}.pnav a{color:var(--dim);font-weight:500}.pnav a:hover{color:var(--primary)}
|
||||
.phead{display:flex;align-items:center;gap:13px;margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid var(--line)}
|
||||
.phead i{width:6px;height:38px;border-radius:3px;background:var(--ac);flex:none}
|
||||
.phead h2{font-size:20px;font-weight:800;color:var(--strong)}
|
||||
.phead p{font-size:12px;color:var(--dim);margin-top:4px;line-height:1.6}
|
||||
.deps{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:16px}
|
||||
.dep{font-size:11px;font-weight:600;padding:4px 11px;border:1px dashed var(--primary);border-radius:4px;color:var(--primary);text-decoration:none}
|
||||
.dep.sup{border-color:${P.accents[4]};color:${P.accents[4]}}
|
||||
.dep:hover{background:var(--primary)12}
|
||||
.psections{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:13px;margin-bottom:14px}
|
||||
.sect ul{list-style:none;display:grid;gap:8px;font-size:12px;line-height:1.65}
|
||||
.sect li{display:flex;gap:8px;color:var(--txt)}
|
||||
.sect li::before{content:"";width:5px;height:5px;border-radius:50%;background:var(--primary);margin-top:7px;flex:none}
|
||||
.sect .empty{font-size:12px;color:var(--dim)}
|
||||
.modal{display:none;position:fixed;inset:0;z-index:20;background:${P.modalMask};padding:6vh 16px}
|
||||
.modal:target{display:flex;justify-content:center;align-items:flex-start}
|
||||
.mbody{max-width:700px;width:100%;max-height:84vh;overflow:auto}
|
||||
.mbody ul{list-style:none;display:grid;gap:9px;font-size:13px;margin-top:6px}
|
||||
.mbody li{display:flex;gap:9px;line-height:1.65}.mbody li::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--primary);margin-top:7px;flex:none}
|
||||
.mclose{position:absolute;top:10px;right:16px;font-size:22px;color:var(--dim);text-decoration:none;line-height:1}
|
||||
.mclose:hover{color:var(--primary)}
|
||||
/* ── 导航当前页高亮(注入纯 CSS :target 规则) ── */
|
||||
${navActiveCss}
|
||||
/* ── 数据研判中心:三列网格 + 图表尺寸约束(修每行一个、巨大) ── */
|
||||
.console{margin-bottom:20px}
|
||||
.chead{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:13px}
|
||||
.chead .sec{margin:0}
|
||||
.status{font-size:10px;font-family:"JetBrains Mono",Consolas,monospace;color:var(--dim);background:var(--track);border:1px solid var(--line);padding:3px 8px;border-radius:4px;letter-spacing:.5px;white-space:nowrap}
|
||||
.charts3{display:grid;grid-template-columns:repeat(3,1fr);gap:13px}
|
||||
@media(max-width:1100px){.charts3{grid-template-columns:repeat(2,1fr)}}
|
||||
@media(max-width:720px){.charts3{grid-template-columns:1fr}}
|
||||
.chart{display:flex;flex-direction:column}
|
||||
.ctitle{font-size:12.5px;font-weight:700;color:var(--strong);margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:7px}
|
||||
.ctitle::before{content:"";width:8px;height:8px;border-radius:2px;background:var(--primary);flex:none}
|
||||
.cbody{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
.cgraph{flex:none}
|
||||
.cgraph svg{display:block}
|
||||
.cgraph.cdonut svg{width:128px;height:128px}
|
||||
.cgraph.cradar svg{width:150px;height:150px}
|
||||
.cgraph.wide{width:100%}
|
||||
.cgraph.wide svg{width:100%;height:auto;max-height:210px}
|
||||
.clist{flex:1;min-width:120px;display:grid;gap:7px;align-content:center}
|
||||
.cli{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--txt)}
|
||||
.cli .cdot{width:9px;height:9px;border-radius:2px;flex:none}
|
||||
.cli .cnm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.cli b{color:var(--strong);font-family:"JetBrains Mono",Consolas,monospace;font-weight:700}
|
||||
/* ── 席位专页:主内容满宽自适应 + 下方风险/依赖整行(两列;只剩依赖则占满) ── */
|
||||
.dmain{display:grid;gap:14px;align-content:start;margin-bottom:14px}
|
||||
.dbottom{display:grid;gap:14px;align-items:stretch}
|
||||
@media(max-width:980px){.dbottom{grid-template-columns:1fr !important}}
|
||||
.lead{font-size:12.5px;color:var(--txt);line-height:1.7;margin:0}
|
||||
.empty{font-size:12px;color:var(--dim)}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="accentbar"></div>
|
||||
<main>
|
||||
<header class="top"><div class="ttl"><h1>${esc(data.title)}</h1>${
|
||||
data.subtitle ? `<p class="sub">${esc(data.subtitle)}</p>` : ""
|
||||
}</div>${generated ? `<span class="meta">⏱ ${generated}</span>` : ""}</header>
|
||||
<nav><div class="navwrap">${nav}</div></nav>
|
||||
${overview}
|
||||
${seatPages}
|
||||
</main>
|
||||
${modal}
|
||||
</body></html>`;
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
*
|
||||
* 详见 `docs/roundtable-dag-orchestration.md` §2(Phase 2,分层编辑器)。
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { nanoid } from "nanoid";
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
Network,
|
||||
Plus,
|
||||
Save,
|
||||
Sliders,
|
||||
SplitSquareVertical,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
@@ -62,13 +63,6 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { listAgents } from "@/core/agents";
|
||||
|
||||
@@ -112,7 +106,7 @@ import {
|
||||
type StageGroups,
|
||||
} from "@/roundtable-planning/lib/chain-stages";
|
||||
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
|
||||
import { ChainFlowDiagram } from "@/roundtable-planning/components/ChainFlowDiagram";
|
||||
import { AgentDetailSheet } from "@/roundtable-planning/components/AgentDetailSheet";
|
||||
|
||||
const POOL_PREFIX = "pool::";
|
||||
@@ -139,6 +133,11 @@ export default function BusinessChainEditorPage() {
|
||||
// ─── 数据 ────────────────────────────────────────────────────────────
|
||||
const [pool, setPool] = useState<RecommendCandidate[]>([]);
|
||||
const [poolLoading, setPoolLoading] = useState(true);
|
||||
// 名称解析专用:一次性拉「全量」智能体(不受左栏搜索/标签过滤影响),用于把链条里
|
||||
// 落库的名称快照刷新成最新名称——agent 改名后此页能同步显示。解析不到才回退到快照。
|
||||
const [agentLiveById, setAgentLiveById] = useState<
|
||||
Map<string, { name: string; description?: string }>
|
||||
>(new Map());
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
// 左栏检索/筛选 —— 服务端过滤(listAgents 支持 search + tag_ids)。
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -160,7 +159,8 @@ export default function BusinessChainEditorPage() {
|
||||
|
||||
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [flowOpen, setFlowOpen] = useState(false);
|
||||
// 两步向导:1=链条编排(双栏拖拽编辑器),2=基本信息(标题/描述/总控提示词 + 流程图预览)。
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
// 详情抽屉:左栏点卡片本体、右栏点「查看详情」都打开它,展示该智能体详情。
|
||||
const [detailAgent, setDetailAgent] = useState<{ id: string; name?: string } | null>(null);
|
||||
const showDetail = useCallback((id: string, name?: string) => {
|
||||
@@ -182,28 +182,32 @@ export default function BusinessChainEditorPage() {
|
||||
);
|
||||
const seatCount = useMemo(() => countSeats(stageGroups), [stageGroups]);
|
||||
|
||||
// ─── Phase 7 只读流程图预览(§2.2 底部 / §7)──────────────────────────────
|
||||
// 把当前分层(去掉空 stage)映射成 DagFlowChart 的 stages,并提供席位名/配色解析。
|
||||
const previewStages = useMemo(
|
||||
// 展示用 stageGroups:把每个席位的名称/描述刷新为最新(agent 改名同步),
|
||||
// 解析不到再回退落库快照。仅用于显示,编辑操作仍作用于原始 stageGroups。
|
||||
const displayStageGroups = useMemo<StageGroups>(
|
||||
() =>
|
||||
stageGroups
|
||||
stageGroups.map((g) => ({
|
||||
...g,
|
||||
seats: g.seats.map((s) => {
|
||||
const live = agentLiveById.get(s.agent_id.toLowerCase());
|
||||
return live
|
||||
? { ...s, name: live.name || s.name, description: live.description ?? s.description }
|
||||
: s;
|
||||
}),
|
||||
})),
|
||||
[stageGroups, agentLiveById],
|
||||
);
|
||||
|
||||
// ─── Phase 7 只读流程图预览(§2.2 底部 / §7)──────────────────────────────
|
||||
// 步骤二右侧「精美流程图」的数据源:过滤空 stage,带上各阶段目标作为依赖标签。
|
||||
const flowStages = useMemo(
|
||||
() =>
|
||||
displayStageGroups
|
||||
.filter((g) => g.seats.length > 0)
|
||||
.map((g, i) => ({ id: `stage-${i}`, agentIds: g.seats.map((s) => s.agent_id) })),
|
||||
[stageGroups],
|
||||
);
|
||||
const seatNameById = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const g of stageGroups)
|
||||
for (const s of g.seats) m.set(s.agent_id.toLowerCase(), s.name || s.agent_id);
|
||||
return m;
|
||||
}, [stageGroups]);
|
||||
const resolvePreviewSeat = useCallback(
|
||||
(agentId: string, order: number) => ({
|
||||
name: seatNameById.get(agentId.toLowerCase()) ?? agentId,
|
||||
avatarType: getAvatarTypeFor(agentId, order),
|
||||
}),
|
||||
[seatNameById],
|
||||
.map((g) => ({ seats: g.seats, goal: g.goal ?? null })),
|
||||
[displayStageGroups],
|
||||
);
|
||||
const hasFlowContent = flowStages.length > 0;
|
||||
|
||||
// ─── 链条列表(一次性)─────────────────────────────────────────────────
|
||||
const refreshChains = useCallback(async () => {
|
||||
@@ -243,6 +247,28 @@ export default function BusinessChainEditorPage() {
|
||||
};
|
||||
}, [search, filterTagIds]);
|
||||
|
||||
// ─── 全量名称表(一次性,不受搜索过滤):用于刷新席位的快照名称 ──────────────
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const agents = filterRecommendCandidates(await listAgents());
|
||||
if (cancelled) return;
|
||||
const m = new Map<string, { name: string; description?: string }>();
|
||||
// 大小写不敏感:席位落库的 agent_id 与 agent.id 大小写可能不一致(同页 seatNameById
|
||||
// 也按 toLowerCase 匹配),统一小写键避免解析失败回退到旧快照名。
|
||||
for (const a of agents)
|
||||
m.set(a.agent_id.toLowerCase(), { name: a.name, description: a.description });
|
||||
setAgentLiveById(m);
|
||||
} catch {
|
||||
// 静默失败:解析不到就回退到席位的名称快照。
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ─── 链条选择 / 新建 ──────────────────────────────────────────────────
|
||||
const loadChain = useCallback((chain: BusinessChain) => {
|
||||
setCurrentChainId(chain.id);
|
||||
@@ -252,6 +278,7 @@ export default function BusinessChainEditorPage() {
|
||||
setDescription(chain.description ?? "");
|
||||
setCoordinatorPrompt(chain.coordinatorPrompt ?? "");
|
||||
setStageGroups(chainToStageGroups(chain));
|
||||
setStep(1);
|
||||
}, []);
|
||||
|
||||
const startNewChain = useCallback(() => {
|
||||
@@ -262,6 +289,7 @@ export default function BusinessChainEditorPage() {
|
||||
setDescription("");
|
||||
setCoordinatorPrompt("");
|
||||
setStageGroups([]);
|
||||
setStep(1);
|
||||
}, []);
|
||||
|
||||
// ─── URL 参数 chainId:从「业务链条」弹窗点「设置」进来时自动切到该链条 ──────
|
||||
@@ -508,11 +536,15 @@ export default function BusinessChainEditorPage() {
|
||||
// 展平 + 折叠:seats 用候选池最新 name/description 覆盖快照;stages 在「全单席位且无
|
||||
// 目标」时折叠为 null(线性链),有并行或填了「本阶段目标」时落显式分层。
|
||||
const flat = stageGroupsToSeats(stageGroups);
|
||||
const payloadSeats = flat.map((s) => ({
|
||||
agent_id: s.agent_id,
|
||||
name: poolById.get(s.agent_id)?.name ?? s.name,
|
||||
description: poolById.get(s.agent_id)?.description ?? s.description ?? "",
|
||||
}));
|
||||
const payloadSeats = flat.map((s) => {
|
||||
// 优先全量名称表(不受搜索过滤)→ 候选池 → 落库快照,保证改名后保存写入最新名。
|
||||
const live = agentLiveById.get(s.agent_id.toLowerCase()) ?? poolById.get(s.agent_id);
|
||||
return {
|
||||
agent_id: s.agent_id,
|
||||
name: live?.name ?? s.name,
|
||||
description: live?.description ?? s.description ?? "",
|
||||
};
|
||||
});
|
||||
// 总控编排提示:trim 后空串落 null(清空),与后端「空=不注入」一致。
|
||||
const coordPrompt = coordinatorPrompt.trim() || null;
|
||||
// 填了「总控编排提示」也强制落显式分层 —— 该提示仅 dag 模式注入,否则纯线性链里被静默忽略。
|
||||
@@ -615,6 +647,119 @@ export default function BusinessChainEditorPage() {
|
||||
// ─── Render ──────────────────────────────────────────────────────────
|
||||
const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…";
|
||||
|
||||
// 步骤一左侧「基本信息」表单(标题 / 描述 / 总控提示词,描述与总控均为多行文本)。
|
||||
// 抽成元素:无编排内容时居中单列,有内容时与右侧流程图并排(左列)。
|
||||
// 样式按设计稿「配置参数面板」还原:label 在上、圆角输入;按钮配色沿用现有 primary。
|
||||
const FIELD =
|
||||
"w-full rounded-md border border-border bg-muted/30 px-3.5 py-2 text-sm text-foreground outline-none transition placeholder:text-muted-foreground/55 hover:bg-muted/50 focus:border-primary focus:bg-card";
|
||||
const basicInfoForm = (
|
||||
<div className="space-y-5">
|
||||
{/* 面板头:配置参数面板 / 实时编译中 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex select-none items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
<Sliders className="size-3" />
|
||||
配置参数面板
|
||||
</span>
|
||||
<span className="flex select-none items-center gap-1 text-[11px] font-medium text-primary">
|
||||
实时编译中
|
||||
<span className="ml-1 size-1.5 animate-pulse rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="chain-title" className="flex items-center gap-1 text-xs font-semibold text-foreground">
|
||||
标题
|
||||
<span className="text-destructive">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="chain-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="如:应急方案三段链"
|
||||
maxLength={120}
|
||||
className={cn(FIELD, "font-medium")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="chain-desc" className="text-xs font-semibold text-foreground">
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
id="chain-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={2000}
|
||||
rows={2}
|
||||
placeholder="链条用途说明:解决什么问题、适用什么场景(可选)…"
|
||||
className={cn(FIELD, "resize-none leading-relaxed")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 总控提示词:label + AI 优化(配色沿用现有 primary)+ 大号等宽编辑区 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label htmlFor="chain-coord" className="flex items-center gap-1 text-xs font-semibold text-foreground">
|
||||
总控提示词
|
||||
<span className="text-[10px] font-normal text-muted-foreground">(写给总控怎么分发、汇总)</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOptimizeCoordinatorPrompt}
|
||||
disabled={optimizingCoordinator || !coordinatorPrompt.trim()}
|
||||
title={
|
||||
coordinatorPrompt.trim()
|
||||
? "让 AI 按链条结构重写这段提示词(更明确的派活方式、交付口径与总结要求)"
|
||||
: "先写一版提示词,AI 才能在它基础上优化"
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1 border border-primary/30 bg-primary/5 px-2.5 py-1 text-[11px] font-semibold text-primary transition hover:bg-primary/10 active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
R_CTRL,
|
||||
)}
|
||||
>
|
||||
{optimizingCoordinator ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Wand2 className="size-3" />
|
||||
)}
|
||||
{optimizingCoordinator ? "优化中…" : "AI 优化提示词"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
id="chain-coord"
|
||||
value={coordinatorPrompt}
|
||||
onChange={(e) => setCoordinatorPrompt(e.target.value)}
|
||||
maxLength={4000}
|
||||
placeholder="例如:你是本次研讨的总控。请严格按阶段顺序派活,每阶段先明确产出目标再分配;要求各席位的交付聚焦本职、显式承接上一阶段结论;最终阶段做综合收口,给出可落地的全案。"
|
||||
className={cn(
|
||||
FIELD,
|
||||
"h-[260px] resize-y p-4 font-mono text-[12px] leading-relaxed",
|
||||
)}
|
||||
/>
|
||||
<div className="pointer-events-none absolute bottom-2 right-2 rounded-md border border-border bg-card/90 px-2 py-1 font-mono text-[9px] text-muted-foreground">
|
||||
Markdown 格式可用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示:分层并行调度规则 */}
|
||||
<div className={cn("space-y-2 border border-primary/15 bg-primary/[0.05] p-4", R_CARD)}>
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-primary">
|
||||
<Info className="size-4" />
|
||||
分层并行调度规则
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
处于同一<strong className="font-semibold text-foreground">阶段(Stage)</strong>的席位会被并行下发任务以压缩时延;阶段之间串行,由总控在下一阶段承接并合并上一阶段产出。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
|
||||
{/* 顶栏 */}
|
||||
@@ -786,94 +931,70 @@ export default function BusinessChainEditorPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 标题 + 描述 */}
|
||||
<section className="shrink-0 border-b border-border bg-card px-6 py-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-stretch overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
|
||||
R_CTRL,
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-[64px] select-none items-center justify-center border-r border-border bg-muted/50 px-4 text-sm font-medium text-muted-foreground">
|
||||
标题
|
||||
</span>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="如:应急方案三段链"
|
||||
maxLength={120}
|
||||
className="h-auto rounded-none border-0 bg-transparent px-4 py-2.5 text-sm font-medium shadow-none focus-visible:ring-0 dark:border-0 dark:bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-stretch overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
|
||||
R_CTRL,
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-[64px] select-none items-center justify-center border-r border-border bg-muted/50 px-4 text-sm font-medium text-muted-foreground">
|
||||
描述
|
||||
</span>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="链条用途说明(可选)"
|
||||
maxLength={500}
|
||||
className="h-auto rounded-none border-0 bg-transparent px-4 py-2.5 text-sm font-medium shadow-none focus-visible:ring-0 dark:border-0 dark:bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 总控编排提示:写给总控(协调)智能体的整体派活指引,仅分层 DAG 模式生效。 */}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
|
||||
R_CTRL,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border bg-muted/50 px-4 py-2">
|
||||
<span className="select-none text-sm font-medium text-muted-foreground">总控提示词</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="select-none text-[10px] text-muted-foreground/70">
|
||||
写给总控:怎么派活、做什么(仅分层研讨生效,可选)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOptimizeCoordinatorPrompt}
|
||||
disabled={optimizingCoordinator || !coordinatorPrompt.trim()}
|
||||
title={
|
||||
coordinatorPrompt.trim()
|
||||
? "让 AI 按链条结构重写这段提示词(更明确的派活方式、交付口径与总结要求)"
|
||||
: "先写一版提示词,AI 才能在它基础上优化"
|
||||
}
|
||||
{/* 步骤导航:① 基本信息 → ② 链条编排(点击可切换)。 */}
|
||||
<nav className="flex shrink-0 items-center justify-center gap-3 border-b border-border bg-card px-6 py-2.5">
|
||||
{([
|
||||
{ n: 1 as const, label: "链条编排" },
|
||||
{ n: 2 as const, label: "基本信息" },
|
||||
]).map((s, i) => (
|
||||
<Fragment key={s.n}>
|
||||
{i > 0 && <span className="h-px w-10 bg-border" />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(s.n)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 border px-3 py-1 text-sm font-medium transition",
|
||||
R_CTRL,
|
||||
step === s.n
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1 border border-primary/30 bg-primary/5 px-2 py-0.5 text-[10px] font-semibold text-primary transition hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
R_CTRL,
|
||||
"flex size-5 items-center justify-center rounded-full text-[11px] font-bold tabular-nums",
|
||||
step === s.n ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{optimizingCoordinator ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Wand2 className="size-3" />
|
||||
)}
|
||||
{optimizingCoordinator ? "优化中…" : "AI 优化"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={coordinatorPrompt}
|
||||
onChange={(e) => setCoordinatorPrompt(e.target.value)}
|
||||
maxLength={4000}
|
||||
rows={3}
|
||||
placeholder="例如:你是本次研讨的总控。请严格按阶段顺序派活,每阶段先明确产出目标再分配;要求各席位的交付聚焦本职、显式承接上一阶段结论;最终阶段做综合收口,给出可落地的全案。"
|
||||
className="w-full resize-y bg-card px-4 py-2.5 text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/60 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{s.n}
|
||||
</span>
|
||||
{s.label}
|
||||
</button>
|
||||
</Fragment>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 双栏拖拽编辑器 */}
|
||||
{/* 步骤二:左=基本信息,右=精美流程图预览(有编排内容时)。无内容则居中单列。 */}
|
||||
{step === 2 && (
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
{hasFlowContent ? (
|
||||
<>
|
||||
{/* 左:基本信息(这几个描述信息) */}
|
||||
<section className="w-[400px] shrink-0 overflow-y-auto border-r border-border bg-card px-6 py-5">
|
||||
{basicInfoForm}
|
||||
</section>
|
||||
{/* 右:编排流程图(直角连线 / 点节点看详情 / 连线显示依赖 / 缩放平移全屏) */}
|
||||
<section className="relative min-w-0 flex-1">
|
||||
<ChainFlowDiagram
|
||||
stages={flowStages}
|
||||
onNodeClick={showDetail}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-3xl overflow-y-auto px-6 py-6">
|
||||
{basicInfoForm}
|
||||
<p className="mt-4 text-center text-xs text-muted-foreground">
|
||||
还没有编排内容。点击上方「① 链条编排」去配置链条,保存后这里会显示流程图预览。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 双栏拖拽编辑器(步骤一)*/}
|
||||
{step === 1 && (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
@@ -942,9 +1063,9 @@ export default function BusinessChainEditorPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 右栏:分层 Stage */}
|
||||
{/* 右栏:分层 Stage(用展示版,名称已按实时 agent 刷新) */}
|
||||
<StagesColumn
|
||||
stageGroups={stageGroups}
|
||||
stageGroups={displayStageGroups}
|
||||
seatCount={seatCount}
|
||||
seatCountInvalid={seatCountInvalid}
|
||||
hasEmptyStage={hasEmptyStage}
|
||||
@@ -975,10 +1096,11 @@ export default function BusinessChainEditorPage() {
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。
|
||||
改为弹窗查看:底栏一个入口按钮,点击后在 Dialog 内展示完整流程图。 */}
|
||||
{previewStages.length > 0 && (
|
||||
底栏入口按钮直接进入第二步「基本信息」,右侧展示完整流程图。 */}
|
||||
{step === 1 && hasFlowContent && (
|
||||
<section className="shrink-0 flex items-center justify-between gap-2 border-t border-border bg-card px-6 py-2">
|
||||
<span className="flex items-center gap-2 text-[11px] font-semibold text-muted-foreground">
|
||||
<span>编排预览</span>
|
||||
@@ -988,7 +1110,7 @@ export default function BusinessChainEditorPage() {
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFlowOpen(true)}
|
||||
onClick={() => setStep(2)}
|
||||
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" />
|
||||
@@ -997,27 +1119,6 @@ export default function BusinessChainEditorPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 编排流程图弹窗 */}
|
||||
{previewStages.length > 0 && (
|
||||
<Dialog open={flowOpen} onOpenChange={setFlowOpen}>
|
||||
<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 pt-2">
|
||||
<DagFlowChart
|
||||
stages={previewStages}
|
||||
resolveSeat={resolvePreviewSeat}
|
||||
showReportMilestone={false}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* 智能体详情抽屉(左栏卡片 / 右栏席位共用) */}
|
||||
<AgentDetailSheet
|
||||
agentId={detailAgent?.id ?? null}
|
||||
@@ -1028,10 +1129,12 @@ export default function BusinessChainEditorPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 页脚 */}
|
||||
<footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground">
|
||||
分层编排:同一 Stage 内的席位并行研讨,Stage 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
|
||||
</footer>
|
||||
{/* 页脚(步骤一):分层编排说明。步骤切换走顶部步骤导航。 */}
|
||||
{step === 1 && (
|
||||
<footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground">
|
||||
分层编排:同一 Stage 内的席位并行研讨,Stage 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 大屏多页「新窗口打开」的独立全屏宿主页 —— 渲染应用内 React 大屏(`DashboardApp`)。
|
||||
*
|
||||
* 浏览器新标签页是一份全新文档,React 数据无法经 props 直接穿过去,故走 localStorage:
|
||||
* - Step3 在点「新窗口打开」时把当前 `DashboardDataValue` 写进 localStorage(键由 URL `?k=` 带过来);
|
||||
* - 本页读出该键、解析后注入 `<DashboardApp data={...} />`;读不到则回落 DEMO(与应用内一致)。
|
||||
*
|
||||
* 路由挂在 `App.tsx` 顶层(`PageRoutes` 之外、`AppProviders` 之下),天然无页面侧边栏 /
|
||||
* workspace 导航,整屏只渲染大屏本体——正是「新窗口打开」想要的纯净大屏视图。
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
|
||||
import type { DashboardDataValue } from "@/roundtable-planning/dashboard/data-context";
|
||||
|
||||
/** localStorage 键前缀:Step3 写入、本页读取;前缀统一便于清理历史键。 */
|
||||
export const DASHBOARD_VIEW_STORAGE_PREFIX = "rt-dash-view:";
|
||||
|
||||
export default function DashboardStandalonePage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const data = useMemo<DashboardDataValue | undefined>(() => {
|
||||
const key = searchParams.get("k");
|
||||
if (!key || typeof window === "undefined") return undefined;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return undefined;
|
||||
return JSON.parse(raw) as DashboardDataValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
return <DashboardApp data={data} />;
|
||||
}
|
||||
@@ -79,6 +79,11 @@ import {
|
||||
type Step2Snapshot,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import { useDraftPersistence } from "@/roundtable-planning/hooks/useDraftPersistence";
|
||||
import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
|
||||
import { useStep3Report } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import { useStep3Dashboard } from "@/roundtable-planning/hooks/useStep3Dashboard";
|
||||
import { isDashboardHtml } from "@/roundtable-planning/lib/report-template";
|
||||
import { stripReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
import {
|
||||
BUILTIN_AGENT_META,
|
||||
makeRolesFromTemplates,
|
||||
@@ -97,7 +102,12 @@ import {
|
||||
} from "@/roundtable-planning/lib/business-chain";
|
||||
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
|
||||
import type { IntentSummary } from "@/roundtable-planning/api/intent";
|
||||
import { getJobByDraft, startJob } from "@/roundtable-planning/api/roundtable-jobs";
|
||||
import {
|
||||
cancelJob,
|
||||
getJobByDraft,
|
||||
startJob,
|
||||
startJobKeepalive,
|
||||
} from "@/roundtable-planning/api/roundtable-jobs";
|
||||
import { getChain } from "@/roundtable-planning/api/chains";
|
||||
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
|
||||
import { listAgents } from "@/core/agents";
|
||||
@@ -233,6 +243,21 @@ export default function RoundtablePlanningPage() {
|
||||
// 见 artifacts/context.tsx 的 sidebar.setOpen(false)),圆桌「参与角色」栏走 step2SidebarExpanded。
|
||||
const sidebar = useSidebarSafe();
|
||||
|
||||
// ─── 沙箱默认关闭策略(2026-06-12)────────────────────────────
|
||||
// 步骤切换(1↔2↔3 任意方向)时**默认关闭**沙箱预览:
|
||||
// - 进第三步不再自动弹出旧产物预览(只有 live write_file 开始写文件时才自动打开);
|
||||
// - 第三步回第二步不再遗留一个「没有文件的空沙箱」(步骤切换前选中的产物在新步骤
|
||||
// 的 stub 映射里多半不存在 → 表现为空白沙箱)。
|
||||
// 用 deselect(raw 关闭)而**不是** wrapped setOpen(false) —— 后者会把 autoOpen 置
|
||||
// false,导致新步骤里 live 写文件时自动打开被守卫拦掉(见 useStep2Orchestration 注释)。
|
||||
const { deselect: deselectArtifactOnStepChange } = useArtifacts();
|
||||
const prevStepRef = useRef(currentStep);
|
||||
useEffect(() => {
|
||||
if (prevStepRef.current === currentStep) return;
|
||||
prevStepRef.current = currentStep;
|
||||
deselectArtifactOnStepChange();
|
||||
}, [currentStep, deselectArtifactOnStepChange]);
|
||||
|
||||
// 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在
|
||||
// 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。
|
||||
const [roles, setRoles] = useState<RoleEntry[]>([]);
|
||||
@@ -706,6 +731,9 @@ export default function RoundtablePlanningPage() {
|
||||
step2.resetStep2State();
|
||||
step2.setSelectedAgents(normalized);
|
||||
step2.resetInitGate();
|
||||
// 这是从第一步确认席位、开启的一轮**实时**研讨(非查看历史/草稿) → 清掉
|
||||
// “查看历史”闸门,否则之前看过历史记录留下的 true 会一直把「后台挂起」按钮藏掉。
|
||||
drafts.loadedFromDraftRef.current = false;
|
||||
// 新一轮研讨 → 允许共识达成后再次向父页面回传。
|
||||
consensusEmittedRef.current = false;
|
||||
// result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活;
|
||||
@@ -724,7 +752,7 @@ export default function RoundtablePlanningPage() {
|
||||
: "成功确认!已切换至第二阶段圆桌研讨系统",
|
||||
);
|
||||
},
|
||||
[buildCurrentStep2Snapshot, mergeActiveRun, step2, step3Report, triggerToast],
|
||||
[buildCurrentStep2Snapshot, drafts, mergeActiveRun, step2, step3Report, triggerToast],
|
||||
);
|
||||
|
||||
// ─── 会商自动化串联(模块 C) ──────────────────────────────────
|
||||
@@ -869,6 +897,179 @@ export default function RoundtablePlanningPage() {
|
||||
emitConsensusToParent();
|
||||
}, [step2.hasConsensus, inWujieEmbed, emitConsensusToParent]);
|
||||
|
||||
// ── Step3 三个生成流提升到页面层(2026-06-13)────────────────────────────
|
||||
// 总结报告 / 结果绘制(经典) / 大屏多页的流式 run **不随 Step3 子视图卸载而中断**:
|
||||
// 用户切回第二步、在总结/绘制/最终方案视图间往返,流都在页面层继续;
|
||||
// 回头进入对应视图即可续看进行中的过程或最终结果。
|
||||
const step3SeatDeliveries = useMemo(
|
||||
() =>
|
||||
step2.step2RoundtableDialogues
|
||||
.filter((d) => d.confidence === "子智能体交付")
|
||||
.map((d) => ({
|
||||
sender: String(d.sender ?? ""),
|
||||
content: stripReasoning(String(d.content ?? "")),
|
||||
})),
|
||||
[step2.step2RoundtableDialogues],
|
||||
);
|
||||
const handleReportSaved = useCallback((snap: DraftStep3Snapshot) => {
|
||||
// **合并**而非整体替换:snap 只带 HTML 报告字段,整体替换会把已存的
|
||||
// summaryReport(总结报告快照)抹掉 —— 重新绘制后总结就丢了。
|
||||
setStep3Report((prev) => ({ ...(prev ?? {}), ...snap }));
|
||||
setStep2Runs((prev) =>
|
||||
prev.map((run) =>
|
||||
run.id === activeStep2RunIdRef.current
|
||||
? { ...run, step3: { ...(run.step3 ?? {}), ...snap } }
|
||||
: run,
|
||||
),
|
||||
);
|
||||
setStep3SavedTick((t) => t + 1);
|
||||
}, []);
|
||||
const handleSummarySaved = useCallback((snap: NonNullable<DraftStep3Snapshot["summaryReport"]>) => {
|
||||
// 把总结结果合并进 step3(与 HTML 报告并存,不互相覆盖)。
|
||||
setStep3Report((prev) => ({ ...(prev ?? {}), summaryReport: snap }));
|
||||
setStep2Runs((prev) =>
|
||||
prev.map((run) =>
|
||||
run.id === activeStep2RunIdRef.current
|
||||
? { ...run, step3: { ...(run.step3 ?? {}), summaryReport: snap } }
|
||||
: run,
|
||||
),
|
||||
);
|
||||
setStep3SavedTick((t) => t + 1);
|
||||
}, []);
|
||||
|
||||
const summaryFlow = useStep3Summary({
|
||||
intentReady: step1.intentReady,
|
||||
seatDeliveries: step3SeatDeliveries,
|
||||
consensusText: step2.lastLeaderContent,
|
||||
selectedModel,
|
||||
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
|
||||
savedSummary: step3Report?.summaryReport ?? null,
|
||||
onSummarySaved: handleSummarySaved,
|
||||
});
|
||||
const reportFlow = useStep3Report({
|
||||
intentReady: step1.intentReady,
|
||||
seatDeliveries: step3SeatDeliveries,
|
||||
consensusText: step2.lastLeaderContent,
|
||||
selectedModel,
|
||||
savedReport: step3Report,
|
||||
onReportSaved: handleReportSaved,
|
||||
});
|
||||
const dashboardFlow = useStep3Dashboard({
|
||||
intentReady: step1.intentReady,
|
||||
seatDeliveries: step3SeatDeliveries,
|
||||
consensusText: step2.lastLeaderContent,
|
||||
selectedModel,
|
||||
themeMode: isDark ? "dark" : "light",
|
||||
savedReport: step3Report,
|
||||
onReportSaved: handleReportSaved,
|
||||
});
|
||||
|
||||
// 进入第三步:总结报告 + 结果绘制(经典) + 大屏多页 **三流并行**自动启动(互无依赖);
|
||||
// 已有存稿则恢复展示。只在「本次会话首次进入第三步」时编排一次 —— 之后进出第三步
|
||||
// 全是续看(流在页面层不中断);切换草稿 / 新建任务时复位入口闸门。
|
||||
const step3EnteredRef = useRef(false);
|
||||
// step3Report 用 ref 持最新值,供 hydrate 同步读取(避免闭包取到旧草稿快照)。
|
||||
const step3ReportRef = useRef(step3Report);
|
||||
step3ReportRef.current = step3Report;
|
||||
|
||||
// 切草稿即在**渲染期**同步复位入口闸门:保证随后的 hydrate effect 能重新跑一次
|
||||
// (此前闸门只在 currentStep 变化时复位,切历史记录若仍停在第三步 currentStep 不变,
|
||||
// 大屏/报告就留着上一条记录的数据不刷新 —— 即本次修复的 bug 根因)。
|
||||
const prevDraftIdRef = useRef(drafts.currentDraftId);
|
||||
if (prevDraftIdRef.current !== drafts.currentDraftId) {
|
||||
const wasRealDraft = prevDraftIdRef.current != null;
|
||||
prevDraftIdRef.current = drafts.currentDraftId;
|
||||
// 仅「真正切到另一份草稿」才复位入口闸门重新 hydrate。草稿**首次创建**(null→id,
|
||||
// 多由本会话第一次自动保存触发)不算切换——若这时复位,正在并行跑的 Step3 三流(尤其
|
||||
// 大屏)会被下方 hydrate 当成「未启动」重新 start(),既丢了在途产物又反复重画。
|
||||
if (wasRealDraft) step3EnteredRef.current = false;
|
||||
}
|
||||
|
||||
// 三流 hydrate/start(按当前草稿快照恢复或新建)。进第三步、切草稿后停留第三步都复用它,
|
||||
// 保证总结/经典/大屏的展示数据跟随当前草稿实时切换。
|
||||
const hydrateStep3Flows = useCallback(() => {
|
||||
step3EnteredRef.current = true;
|
||||
const rep = step3ReportRef.current;
|
||||
// ① 方案总结报告
|
||||
const savedSum = rep?.summaryReport;
|
||||
if (savedSum && (savedSum.md.trim() || (savedSum.closing ?? "").trim())) {
|
||||
summaryFlow.hydrateSaved(savedSum);
|
||||
} else {
|
||||
summaryFlow.start();
|
||||
}
|
||||
// ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker),
|
||||
// 那不是经典产物 → 经典照常启动。
|
||||
if (rep?.html && !isDashboardHtml(rep.html)) {
|
||||
reportFlow.hydrateSaved(rep);
|
||||
} else {
|
||||
reportFlow.start(); // 与总结报告并行绘制,进入「结果绘制」即见进行中过程/结果
|
||||
}
|
||||
// ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。
|
||||
if (rep?.dashboardJson) {
|
||||
dashboardFlow.hydrateFromJson(rep.dashboardJson);
|
||||
} else if (rep?.html && isDashboardHtml(rep.html)) {
|
||||
dashboardFlow.hydrateSaved(rep.html);
|
||||
} else {
|
||||
dashboardFlow.start();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [summaryFlow, reportFlow, dashboardFlow]);
|
||||
|
||||
// 切换草稿:先停掉旧草稿的在途 Step3 流(防串台)。声明在 hydrate effect **之前**,
|
||||
// 保证同一提交内先 abort 旧流、再由下方 effect hydrate 新草稿,顺序不颠倒。
|
||||
// **仅在真正切到另一份草稿时中断**——草稿首次创建(null→id)不是切换,那时三流可能正
|
||||
// 跑着(尤其大屏绘制),误 abort 会让产物没存上、随后还被重新 hydrate 重画。
|
||||
const abortPrevDraftIdRef = useRef(drafts.currentDraftId);
|
||||
useEffect(() => {
|
||||
const prev = abortPrevDraftIdRef.current;
|
||||
abortPrevDraftIdRef.current = drafts.currentDraftId;
|
||||
if (prev && drafts.currentDraftId && prev !== drafts.currentDraftId) {
|
||||
summaryFlow.abort();
|
||||
reportFlow.abort();
|
||||
dashboardFlow.abort();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [drafts.currentDraftId]);
|
||||
|
||||
// 进第三步 / 切草稿后仍停留第三步:按当前草稿快照恢复或新建三流(闸门已在渲染期复位)。
|
||||
useEffect(() => {
|
||||
if (currentStep !== 3 || step3EnteredRef.current) return;
|
||||
hydrateStep3Flows();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentStep, drafts.currentDraftId]);
|
||||
|
||||
// ── 「查看=接管回前端」(2026-06-13):查看运行中的后台作业 → 接管续跑 ──
|
||||
// 取消后端作业 → 加载作业最近写回草稿的快照(含作业真实线程 + 完整对话)→ 解除
|
||||
// 「看历史」只读闸门 → 由前端在那些线程上**继续实时流式编排**(leader 自由派活续轮)。
|
||||
// 这样「后台挂起 → 查看」会把会商真正搬回前端,按钮/暂停/干预/再挂起全部回归,而不是
|
||||
// 停在只读跟随的后台态。
|
||||
const jobFollowUnsubRef = useRef<(() => void) | null>(null);
|
||||
const stopJobFollow = useCallback(() => {
|
||||
jobFollowUnsubRef.current?.();
|
||||
jobFollowUnsubRef.current = null;
|
||||
}, []);
|
||||
const takeOverRunningJob = useCallback(
|
||||
async (jobId: string, draftId: string) => {
|
||||
stopJobFollow();
|
||||
step2.stopOrchestration();
|
||||
// 1. 取消后端作业:避免前后端双跑/抢同一线程(409),线程随后由前端接管续跑。
|
||||
// executor.cancel 会 task.cancel() 中断在跑的 run,DB 同步置 cancelled。
|
||||
await cancelJob(jobId).catch(() => null);
|
||||
// 2. 加载作业最近写回草稿的快照:恢复 threadIds(作业真实线程)+ 协调名 + 完整对话。
|
||||
// hydrateFromDraft 已同步写好 threadIds/coordinatorName 的 ref,供下一步接管即时读取。
|
||||
await drafts.handleLoadDraft(draftId);
|
||||
setCurrentStep(2); // 覆盖 hydrate 可能切到的 furthestStep,停在第二步继续研讨
|
||||
// 3. 关掉「看历史」只读闸门 → 后台挂起/暂停/干预按钮回归,回到前端可交互态。
|
||||
drafts.loadedFromDraftRef.current = false;
|
||||
// 4. 前端接管:在作业线程上继续实时流式编排(无线程则回退为只读查看,不再续跑)。
|
||||
step2.resumeLiveTakeover();
|
||||
void drafts.refresh();
|
||||
},
|
||||
[stopJobFollow, step2, drafts, setCurrentStep],
|
||||
);
|
||||
// 卸载兜底:离开页面停止跟随(后台作业本身继续跑)。
|
||||
useEffect(() => stopJobFollow, [stopJobFollow]);
|
||||
|
||||
// 把页面重置回「第一步全新任务」——新建任务 + 后台挂起后都复用。
|
||||
// 注意:只清前端状态,不删后端已建的草稿 / 已挂起的后台作业(它们继续在后台跑,
|
||||
// 在历史记录里可见)。
|
||||
@@ -882,6 +1083,13 @@ export default function RoundtablePlanningPage() {
|
||||
setOrchestrationPlan(null);
|
||||
setDagExecMode("fast");
|
||||
setStep3Report(null);
|
||||
// 停掉页面层的 Step3 三个生成流(新任务不该带着旧任务的流跑)+ 复位进入闸门,
|
||||
// 并停止后台作业实时跟随(作业本身继续在后台跑)。
|
||||
summaryFlow.abort();
|
||||
reportFlow.abort();
|
||||
dashboardFlow.abort();
|
||||
stopJobFollow();
|
||||
step3EnteredRef.current = false;
|
||||
step1.stopIntentStream();
|
||||
drafts.loadedFromDraftRef.current = false;
|
||||
drafts.setCurrentDraftId(null);
|
||||
@@ -891,6 +1099,7 @@ export default function RoundtablePlanningPage() {
|
||||
// 同步触发 Step 1 init,而不是依赖 useEffect —— 已经在 Step 1 时
|
||||
// currentStep 不变,useEffect 不会 re-fire。
|
||||
step1.runStep1Init();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [drafts, step1, step2]);
|
||||
|
||||
const handleStartNewTask = useCallback(() => {
|
||||
@@ -906,49 +1115,39 @@ export default function RoundtablePlanningPage() {
|
||||
});
|
||||
}, [resetToStep1]);
|
||||
|
||||
// 「后台挂起」:把当前研讨交给后端自动跑到 Step3 结果绘制完成;模型沿用第二步选择。
|
||||
// 挂起后页面像「新建任务」一样重置回第一步(后台作业继续在历史记录里跑)。
|
||||
const handleBackgroundSuspend = useCallback(async () => {
|
||||
// 「后台挂起」核心(手动按钮 / 离开页面自动挂起共用):幂等保证草稿存在 →
|
||||
// 同草稿已有在跑作业则跳过 → 启动后台作业。后端另有「同草稿未终态作业幂等返回」
|
||||
// 兜底(覆盖跨标签页 / beforeunload 与本函数并发触发),绝不重复建作业。
|
||||
const startBackgroundJob = useCallback(async (): Promise<boolean> => {
|
||||
const agents = step2.selectedAgents.map((a) => ({ agentId: a.agent_id, name: a.name }));
|
||||
if (agents.length === 0) {
|
||||
triggerToast("⚠️ 请先选择研讨席位再后台挂起");
|
||||
return;
|
||||
if (agents.length === 0) return false;
|
||||
// 后台挂起发起前**立刻强制存一次**当前聊天(保住发起时刻的本地研讨记录,防丢)。
|
||||
const draftId = await drafts.saveDraftNow(2);
|
||||
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
|
||||
const existing = draftId ? await getJobByDraft(draftId).catch(() => null) : null;
|
||||
const alreadyRunning =
|
||||
existing &&
|
||||
(existing.status === "running" ||
|
||||
existing.status === "awaiting_input" ||
|
||||
existing.status === "queued");
|
||||
if (!alreadyRunning) {
|
||||
await startJob({
|
||||
draftId: draftId ?? undefined,
|
||||
intent: buildInitialIntent(),
|
||||
agents,
|
||||
model: selectedModel || undefined, // 模型沿用第二步选择
|
||||
// 后台作业引擎支持 dag(后端 _run_dag:stage 串行 / stage 内并行)——
|
||||
// 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。
|
||||
orchestrationMode,
|
||||
chain: chainMeta,
|
||||
// dag 模式:把整份分层计划带给后端(含 stages / 阶段目标 / 总控提示词 / finalSynthesis)。
|
||||
// 非 dag 时为 null(后端按 mode 走 chain/recommend)。
|
||||
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
|
||||
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
|
||||
// (那些线程可能有活跃 run,复用会撞 409)。
|
||||
});
|
||||
}
|
||||
try {
|
||||
// 确保后端存在对应草稿(作业按 draftId 关联,历史下拉据此显示「进行中」)。
|
||||
const draftId = await drafts.ensureDraft(2);
|
||||
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
|
||||
const existing = draftId ? await getJobByDraft(draftId).catch(() => null) : null;
|
||||
const alreadyRunning =
|
||||
existing &&
|
||||
(existing.status === "running" ||
|
||||
existing.status === "awaiting_input" ||
|
||||
existing.status === "queued");
|
||||
if (!alreadyRunning) {
|
||||
await startJob({
|
||||
draftId: draftId ?? undefined,
|
||||
intent: buildInitialIntent(),
|
||||
agents,
|
||||
model: selectedModel || undefined, // 模型沿用第二步选择
|
||||
// 后台作业引擎现已支持 dag(后端 _run_dag:stage 串行 / stage 内并行)——
|
||||
// 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。
|
||||
orchestrationMode,
|
||||
chain: chainMeta,
|
||||
// dag 模式:把整份分层计划带给后端(含 stages / 阶段目标 / 总控提示词 / finalSynthesis)。
|
||||
// 非 dag 时为 null(后端按 mode 走 chain/recommend)。
|
||||
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
|
||||
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
|
||||
// (那些线程可能有活跃 run,复用会撞 409)。
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
// 重置回第一步(同新建任务);后台作业在历史记录里继续跑、可点开看进度。
|
||||
resetToStep1();
|
||||
triggerToast("🚀 已转入后台,任务会自动跑到结果绘制完成;可在「历史记录」查看进度");
|
||||
void drafts.refresh();
|
||||
return true;
|
||||
}, [
|
||||
step2,
|
||||
drafts,
|
||||
@@ -957,10 +1156,86 @@ export default function RoundtablePlanningPage() {
|
||||
orchestrationPlan,
|
||||
chainMeta,
|
||||
buildInitialIntent,
|
||||
resetToStep1,
|
||||
triggerToast,
|
||||
]);
|
||||
|
||||
// 「后台挂起」按钮:把当前研讨交给后端自动跑完 Step2 研讨 + Step3(总结报告 + 结果绘制)。
|
||||
// 挂起后页面像「新建任务」一样重置回第一步(后台作业继续在历史记录里跑)。
|
||||
const handleBackgroundSuspend = useCallback(async () => {
|
||||
if (step2.selectedAgents.length === 0) {
|
||||
triggerToast("⚠️ 请先选择研讨席位再后台挂起");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await startBackgroundJob();
|
||||
} catch (err) {
|
||||
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
// 重置回第一步(同新建任务);后台作业在历史记录里继续跑、可点开看进度。
|
||||
resetToStep1();
|
||||
triggerToast("🚀 已转入后台,任务会自动跑完研讨、总结报告与结果绘制;可在「历史记录」查看进度");
|
||||
void drafts.refresh();
|
||||
}, [step2, drafts, startBackgroundJob, resetToStep1, triggerToast]);
|
||||
|
||||
// ── 自动后台挂起:研讨进行中离开本页面(路由卸载)或刷新 / 关标签页时,任务自动转后台 ──
|
||||
// 最新状态进 ref:卸载 cleanup 与 beforeunload 都是「拍快照时刻」的同步回调,
|
||||
// 不能被旧渲染的闭包钉死。
|
||||
const autoSuspendRef = useRef<{ running: boolean; fire: () => void; beacon: () => void }>({
|
||||
running: false,
|
||||
fire: () => {},
|
||||
beacon: () => {},
|
||||
});
|
||||
useEffect(() => {
|
||||
autoSuspendRef.current = {
|
||||
// 只认 isOrchestrating(本地编排循环**真正在生成中**的锁)——
|
||||
// isRoundTableRunning 是「会商进行」的 UI 开关,查看聊天记录(加载草稿 hydrate)
|
||||
// 后它可能为 true,曾导致看历史时切页/刷新被误触发后台挂起、从头重跑一单。
|
||||
running: step2.isOrchestrating,
|
||||
// 路由卸载用:可走完整异步链(ensureDraft → 去重 → startJob),fetch 卸载后照常完成。
|
||||
fire: () => {
|
||||
void startBackgroundJob().catch(() => {});
|
||||
},
|
||||
// beforeunload 用:无法 await —— 用 keepalive fetch 同步发出(浏览器保证发完)。
|
||||
// draftId 取当前已存在的草稿(研讨启动时已 ensureDraft,见下方 effect)。
|
||||
beacon: () => {
|
||||
const agents = step2.selectedAgents.map((a) => ({ agentId: a.agent_id, name: a.name }));
|
||||
if (agents.length === 0) return;
|
||||
startJobKeepalive({
|
||||
draftId: drafts.currentDraftId ?? undefined,
|
||||
intent: buildInitialIntent(),
|
||||
agents,
|
||||
model: selectedModel || undefined,
|
||||
orchestrationMode,
|
||||
chain: chainMeta,
|
||||
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
// 研讨一启动(真正进入生成)就确保草稿存在:自动挂起 / beforeunload beacon 需要
|
||||
// draftId 同步可取,历史下拉的「进行中」徽标也按 draftId 关联。ensureDraft 幂等。
|
||||
useEffect(() => {
|
||||
if (step2.isOrchestrating) void drafts.ensureDraft(2);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step2.isOrchestrating]);
|
||||
// 路由卸载(切到其它页面):正在研讨 → 自动转后台挂起。
|
||||
useEffect(
|
||||
() => () => {
|
||||
const snap = autoSuspendRef.current;
|
||||
if (snap.running) snap.fire();
|
||||
},
|
||||
[],
|
||||
);
|
||||
// 刷新 / 关标签页:keepalive 兜底。后端幂等闸门保证与其它触发源并发也不重复建作业。
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const snap = autoSuspendRef.current;
|
||||
if (snap.running) snap.beacon();
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, []);
|
||||
|
||||
// 本地 demo handler:继续追问 / 跳过此项 —— 不发请求,只本地 push 一条气泡。
|
||||
const handleSkipQuestion = useCallback(() => {
|
||||
step1.appendLocalMessage({
|
||||
@@ -1033,15 +1308,16 @@ export default function RoundtablePlanningPage() {
|
||||
if (!o) setChainModalJobId(null);
|
||||
}}
|
||||
onView={(job) => {
|
||||
// 「查看」:进入该任务。done → 加载草稿(含 step3)落到 Step3 看报告;
|
||||
// 进行中 / 待澄清 → 进去后置暂停态,避免前后端「双跑」同一批 thread。
|
||||
// 「查看」(2026-06-13 接管语义):done → 加载草稿(含 step3)看报告;
|
||||
// **运行中/待澄清 → 接管回前端**:取消后端作业、加载聊天记录、解除只读,
|
||||
// 由前端在作业线程上继续实时流式编排(不再是只读跟随后台)。
|
||||
setChainModalJobId(null);
|
||||
if (!job.draftId) return;
|
||||
if (job.status === "running" || job.status === "awaiting_input") {
|
||||
step2.setIsRoundTableRunning(false);
|
||||
step2.stopOrchestration();
|
||||
void takeOverRunningJob(job.id, job.draftId);
|
||||
return;
|
||||
}
|
||||
drafts.handleLoadDraft(job.draftId);
|
||||
void drafts.handleLoadDraft(job.draftId);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1263,6 +1539,7 @@ export default function RoundtablePlanningPage() {
|
||||
);
|
||||
}}
|
||||
onBackgroundSuspend={handleBackgroundSuspend}
|
||||
isViewingHistory={drafts.loadedFromDraftRef.current}
|
||||
onTriggerToast={triggerToast}
|
||||
/>
|
||||
)}
|
||||
@@ -1281,30 +1558,15 @@ export default function RoundtablePlanningPage() {
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
savedReport={step3Report}
|
||||
onReportSaved={(snap) => {
|
||||
setStep3Report(snap);
|
||||
setStep2Runs((prev) =>
|
||||
prev.map((run) =>
|
||||
run.id === activeStep2RunIdRef.current ? { ...run, step3: snap } : run,
|
||||
),
|
||||
);
|
||||
setStep3SavedTick((t) => t + 1);
|
||||
}}
|
||||
onReportSaved={handleReportSaved}
|
||||
// 业务链总控编排提示 → 注入「总结报告」智能体(带 coordinatorPrompt 的链走 dag)。
|
||||
coordinatorPrompt={orchestrationPlan?.coordinatorPrompt ?? null}
|
||||
savedSummary={step3Report?.summaryReport ?? null}
|
||||
onSummarySaved={(snap) => {
|
||||
// 把总结结果合并进 step3(与 HTML 报告并存,不互相覆盖)。
|
||||
setStep3Report((prev) => ({ ...(prev ?? {}), summaryReport: snap }));
|
||||
setStep2Runs((prev) =>
|
||||
prev.map((run) =>
|
||||
run.id === activeStep2RunIdRef.current
|
||||
? { ...run, step3: { ...(run.step3 ?? {}), summaryReport: snap } }
|
||||
: run,
|
||||
),
|
||||
);
|
||||
setStep3SavedTick((t) => t + 1);
|
||||
}}
|
||||
onSummarySaved={handleSummarySaved}
|
||||
// 页面层常驻的三个生成流(切走不断流,进入视图续看)。
|
||||
summaryFlow={summaryFlow}
|
||||
reportFlow={reportFlow}
|
||||
dashboardFlow={dashboardFlow}
|
||||
onReset={handleStartNewTask}
|
||||
openArtifactPreview={step2.openArtifactPreview}
|
||||
seatStubMessages={step2.seatStubMessages}
|
||||
@@ -1506,8 +1768,8 @@ function HistoryDropdown(props: HistoryDropdownProps) {
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-[360px] max-h-[60vh] overflow-y-auto bg-white border border-slate-200 rounded-2xl shadow-xl z-50 dark:bg-secondary dark:border-border">
|
||||
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10 dark:bg-secondary dark:border-border">
|
||||
<div className="absolute right-0 top-full mt-2 w-[360px] max-h-[60vh] overflow-y-auto bg-white border border-slate-200 rounded-2xl shadow-xl z-50 dark:bg-[#0d1d3b] dark:border-border">
|
||||
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10 dark:bg-[#0d1d3b] dark:border-border">
|
||||
<span className="text-xs font-black text-slate-800 tracking-wide dark:text-foreground">历史草稿</span>
|
||||
<span className="text-[10px] font-mono text-slate-400">{drafts.length} 条</span>
|
||||
</div>
|
||||
|
||||
@@ -69,28 +69,28 @@
|
||||
* 着色,所以这里统一覆盖即可整页换肤;浅色模式(无 .dark)保持红色主题不受影响。 */
|
||||
.dark .roundtable-planning,
|
||||
.roundtable-planning--dark {
|
||||
/* —— 背景 / 容器(ZS-004 深蓝系) —— */
|
||||
--background: #0c1836; /* zs-004-09 页面背景 */
|
||||
--foreground: #c5cfe0; /* zs-004-02 正文字体 */
|
||||
--card: #122347; /* zs-004-08 卡片背景 */
|
||||
/* —— 背景 / 容器(ZS-004 深蓝系,半透明版 —— 让背景图透出)—— */
|
||||
--background: rgba(6, 14, 36, 0.92); /* zs-004-09 页面底,半透明 */
|
||||
--foreground: #c5cfe0; /* zs-004-02 正文字体 */
|
||||
--card: rgba(3, 38, 80, 0.82); /* 卡片底:对齐 sidebar-contents 半透明深蓝 */
|
||||
--card-foreground: #c5cfe0;
|
||||
--popover: #122347; /* 下拉 / 浮层与卡片同底 */
|
||||
--popover: rgba(3, 38, 80, 0.92); /* 下拉 / 浮层,略深一档 */
|
||||
--popover-foreground: #c5cfe0;
|
||||
--muted: #16294d; /* 次级容器(步骤条 / slate-50 表面) */
|
||||
--muted-foreground: #6e88b0; /* zs-004-04 次级字体 */
|
||||
--secondary: #1a2f5a; /* zs-004-07 次级按钮底 */
|
||||
--muted: rgba(2, 24, 58, 0.75); /* 次级容器(步骤条 / slate-50 表面) */
|
||||
--muted-foreground: #6e88b0; /* zs-004-04 次级字体 */
|
||||
--secondary: rgba(26, 47, 90, 0.78); /* zs-004-07 次级按钮底,半透明 */
|
||||
--secondary-foreground: #c5cfe0;
|
||||
--accent: #1a2f5a; /* hover / 软高亮表面 */
|
||||
--accent-foreground: #e8edf5; /* zs-004-01 */
|
||||
--accent: rgba(26, 47, 90, 0.78); /* hover / 软高亮表面,半透明 */
|
||||
--accent-foreground: #e8edf5; /* zs-004-01 */
|
||||
|
||||
/* —— 主色 / 链接(ZS-002 渐蓝系) —— */
|
||||
--primary: #1677ff; /* zs-002-06 主操作 */
|
||||
--primary-foreground: #ffffff;
|
||||
--ring: rgba(22, 119, 255, 0.45);
|
||||
|
||||
/* —— 边框 / 控件描边(取较浅阶以保证深底上可见) —— */
|
||||
--border: #2d4473; /* zs-004-06 容器边框 */
|
||||
--input: #4a6491; /* zs-004-05 控件描边 */
|
||||
/* —— 边框 / 控件描边:对齐 sidebar-contents 的蓝色发光边框 —— */
|
||||
--border: rgba(43, 143, 254, 0.55); /* sidebar border-color 同款蓝边 */
|
||||
--input: rgba(90, 169, 255, 0.35); /* 控件描边,略浅 */
|
||||
|
||||
/* —— 功能语义色 —— */
|
||||
--destructive: #f5222d; /* zs-001-06 错误 */
|
||||
@@ -110,7 +110,16 @@
|
||||
--rt-primary-hover: var(--primary);
|
||||
--rt-primary-soft: var(--accent);
|
||||
color: var(--foreground);
|
||||
/* 整页背景换深蓝主题底图(仅深色;浅色保持红白主题不受影响)。
|
||||
* background-image 叠在 background-color 上 —— 底色作为图加载前/未覆盖区域的回落;
|
||||
* cover 铺满、fixed 固定不随内容滚动(大屏背板观感,与应用渲染大屏一致)。
|
||||
* url() 里的 SVG 由 Vite 当独立资源输出(非内联),浏览器缓存。 */
|
||||
background-color: var(--background);
|
||||
background-image: url("../../assets/img/layout-bg-theme-dark-blue.svg");
|
||||
background-size: cover;
|
||||
background-position: center top;
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.dark .roundtable-planning ::-webkit-scrollbar-thumb,
|
||||
@@ -146,7 +155,7 @@
|
||||
background-color: var(--background) !important;
|
||||
}
|
||||
|
||||
/* ---- 表面:白 / 近白底 → 卡片底 ---- */
|
||||
/* ---- 表面:白 / 近白底 → 卡片底(半透明,对齐 sidebar-contents 风格) ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(.bg-white, .bg-white\/70, .hover\:bg-white:hover, .bg-\[\#fcfcfd\], .bg-\[\#fffdfa\]) {
|
||||
background-color: var(--card) !important;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Bell, ChevronDown, FileText, Moon, Palette, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Bell, ChevronDown, FileText, Shirt, User } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -12,7 +12,8 @@ import type { MenuItem } from "@/core/page-layout/sidebar-menu";
|
||||
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
import { useTheme, type ThemeType } from "../contexts/ThemeContext";
|
||||
import { readUserInfo } from "./lib/light-app";
|
||||
|
||||
const iconButtonClass = (isDark: boolean) =>
|
||||
cn(
|
||||
@@ -22,19 +23,121 @@ const iconButtonClass = (isDark: boolean) =>
|
||||
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
|
||||
);
|
||||
|
||||
// 深色顶栏菜单左右分组(设计稿:左 问答中心/任务管理/应用中心,右 综合管理/资源管理)
|
||||
const LEFT_MENU_IDS = ["qa-center", "task-management", "light-app-center"];
|
||||
const RIGHT_MENU_IDS = ["comprehensive-management", "resource-management"];
|
||||
|
||||
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark)
|
||||
const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
|
||||
{ id: "light", label: "红色", color: "#cf1322" },
|
||||
{ id: "lightblue", label: "蓝色", color: "#0062d9" },
|
||||
{ id: "dark", label: "深蓝色", color: "#002466" },
|
||||
];
|
||||
|
||||
function ThemeSwitcher({ isDark }: { isDark: boolean }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelClose = () => {
|
||||
if (closeTimer.current) {
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
};
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
closeTimer.current = setTimeout(() => setOpen(false), 120);
|
||||
};
|
||||
|
||||
useEffect(() => () => cancelClose(), []);
|
||||
|
||||
// 切换按钮:悬浮只改背景,不改图标颜色(显式锁定 hover 文字色等于常态色)
|
||||
const triggerClass = cn(
|
||||
"size-8 shrink-0 rounded-sm",
|
||||
isDark
|
||||
? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400"
|
||||
: "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
}}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={triggerClass}
|
||||
title="换肤"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Shirt className="size-4" />
|
||||
</Button>
|
||||
{open ? (
|
||||
<div className="absolute top-full left-1/2 z-50 -translate-x-1/2 pt-2">
|
||||
<div className="relative flex items-center gap-2 rounded-lg border border-black/5 bg-white px-2.5 py-1.5 shadow-lg">
|
||||
<div className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 rounded-[1px] border-t border-l border-black/5 bg-white" />
|
||||
{THEME_SWATCHES.map((swatch) => {
|
||||
const active = theme === swatch.id;
|
||||
return (
|
||||
<button
|
||||
key={swatch.id}
|
||||
type="button"
|
||||
title={swatch.label}
|
||||
aria-label={swatch.label}
|
||||
aria-pressed={active}
|
||||
onClick={() => {
|
||||
setTheme(swatch.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="size-5 rounded-full transition-transform hover:scale-110"
|
||||
style={{
|
||||
backgroundColor: swatch.color,
|
||||
boxShadow: active
|
||||
? `0 0 0 1.5px #ffffff, 0 0 0 3px ${swatch.color}`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HeaderProps = {
|
||||
collapsed?: boolean;
|
||||
};
|
||||
|
||||
const Header = ({ collapsed }: HeaderProps) => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const auth = getStoredAuth();
|
||||
const isAdmin = auth?.system_role === "admin";
|
||||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||||
|
||||
// 登录时写入的真实用户名(外部登录存于 localStorage.userInfo.userName);
|
||||
// 缺省回退到 stored auth 的邮箱前缀。
|
||||
const displayName = useMemo(() => {
|
||||
const info = readUserInfo();
|
||||
const name = typeof info.userName === "string" ? info.userName.trim() : "";
|
||||
if (name) return name;
|
||||
const email = auth?.email?.trim();
|
||||
return email ? email.split("@")[0] : "用户";
|
||||
}, [auth?.email]);
|
||||
const showTopNav = isDark && !collapsed && !isWujieEnv();
|
||||
|
||||
const hasActiveChild = (item: MenuItem): boolean => {
|
||||
@@ -67,6 +170,9 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
const activeSecondItem = activeTopItem?.children?.find(hasActiveChild);
|
||||
const thirdSubNavItems = activeSecondItem?.children ?? [];
|
||||
|
||||
const leftMenuItems = visibleMenuItems.filter((i) => LEFT_MENU_IDS.includes(i.id));
|
||||
const rightMenuItems = visibleMenuItems.filter((i) => RIGHT_MENU_IDS.includes(i.id));
|
||||
|
||||
const handleNavItemClick = (item: MenuItem) => {
|
||||
const path = item.path ?? findFirstRoutePath(item.children);
|
||||
if (path) {
|
||||
@@ -74,149 +180,160 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderTopItem = (item: MenuItem) => {
|
||||
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`top-submenu-item ${isSelected ? "top-submenu-item--active" : ""}`}
|
||||
onClick={() => handleNavItemClick(item)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用
|
||||
const actionCluster = (
|
||||
<>
|
||||
<ThemeSwitcher isDark={isDark} />
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setNotificationOpen(true)}
|
||||
className={cn(iconButtonClass(isDark), "relative")}
|
||||
title="通知"
|
||||
>
|
||||
<Bell className="size-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-1 right-1 flex h-4 min-w-4 items-center justify-center rounded-sm px-1 text-[10px] leading-none font-medium",
|
||||
isDark
|
||||
? "bg-cyan-500 text-white"
|
||||
: "bg-white tabular-nums text-[var(--brand-900)]",
|
||||
)}
|
||||
>
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mx-1 h-8 w-px",
|
||||
isDark ? "bg-blue-800/30" : "bg-white/30",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 gap-2 rounded-sm px-2",
|
||||
isDark
|
||||
? "text-slate-200 hover:bg-slate-800/50"
|
||||
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-sm",
|
||||
isDark
|
||||
? "bg-gradient-to-br from-blue-500 to-cyan-400"
|
||||
: "bg-white",
|
||||
)}
|
||||
>
|
||||
<User
|
||||
className={cn(
|
||||
"size-4",
|
||||
isDark ? "text-white" : "text-[var(--brand-900)]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden text-left sm:block">
|
||||
<p className="text-sm leading-tight font-medium text-white">
|
||||
{displayName}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
isDark ? "text-slate-500" : "text-white/80",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NotificationDialog
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<header
|
||||
className={cn(
|
||||
"strategy-app-header theme-transition flex h-16 items-center justify-between border-b px-4",
|
||||
isDark ? "border-blue-800/30" : "border-[var(--brand-950)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",
|
||||
isDark
|
||||
? "bg-gradient-to-br from-blue-500 to-cyan-400"
|
||||
: "bg-white/20",
|
||||
)}
|
||||
>
|
||||
<FileText className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="text-sm font-semibold text-white">
|
||||
CM助手
|
||||
</span>
|
||||
)}
|
||||
{showTopNav && visibleMenuItems.length ? (
|
||||
<nav className="top-submenu-bar" aria-label="一级菜单">
|
||||
<div className="top-submenu-list">
|
||||
{visibleMenuItems.map((item) => {
|
||||
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
|
||||
<header className="strategy-app-header theme-transition relative flex h-16 items-center justify-between px-4">
|
||||
{showTopNav ? (
|
||||
<>
|
||||
{/* 左分区(flex-1):菜单靠右贴近中间基座 */}
|
||||
<div className="flex h-full min-w-0 flex-1 items-center justify-end">
|
||||
<nav
|
||||
className="top-submenu-bar top-submenu-bar--group"
|
||||
aria-label="左侧菜单"
|
||||
>
|
||||
<div className="top-submenu-list">
|
||||
{leftMenuItems.map(renderTopItem)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`top-submenu-item ${isSelected ? "top-submenu-item--active" : ""}`}
|
||||
onClick={() => handleNavItemClick(item)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* 中:CM助手(落在背景图发光基座上,纯文字无图标) */}
|
||||
<div className="header-center-title">CM助手</div>
|
||||
|
||||
{/* 右分区(flex-1):菜单靠左贴近中间基座,操作区靠最右 */}
|
||||
<div className="flex h-full min-w-0 flex-1 items-center justify-between">
|
||||
<nav
|
||||
className="top-submenu-bar top-submenu-bar--group"
|
||||
aria-label="右侧菜单"
|
||||
>
|
||||
<div className="top-submenu-list">
|
||||
{rightMenuItems.map(renderTopItem)}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{actionCluster}
|
||||
</div>
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={toggleTheme}
|
||||
className={iconButtonClass(isDark)}
|
||||
title={isDark ? "切换到红白主题" : "切换到深色主题"}
|
||||
>
|
||||
{isDark ? (
|
||||
<Palette className="size-4" />
|
||||
) : (
|
||||
<Moon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setNotificationOpen(true)}
|
||||
className={cn(iconButtonClass(isDark), "relative")}
|
||||
title="通知"
|
||||
>
|
||||
<Bell className="size-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex h-full min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-1 right-1 flex h-4 min-w-4 items-center justify-center rounded-sm px-1 text-[10px] leading-none font-medium",
|
||||
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",
|
||||
isDark
|
||||
? "bg-cyan-500 text-white"
|
||||
: "bg-white tabular-nums text-[var(--brand-900)]",
|
||||
? "bg-gradient-to-br from-blue-500 to-cyan-400"
|
||||
: "bg-white/20",
|
||||
)}
|
||||
>
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mx-1 h-8 w-px",
|
||||
isDark ? "bg-blue-800/30" : "bg-white/30",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 gap-2 rounded-sm px-2",
|
||||
isDark
|
||||
? "text-slate-200 hover:bg-slate-800/50"
|
||||
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-sm",
|
||||
isDark
|
||||
? "bg-gradient-to-br from-blue-500 to-cyan-400"
|
||||
: "bg-white",
|
||||
<FileText className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="text-sm font-semibold text-white">
|
||||
CM助手
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
<User
|
||||
className={cn(
|
||||
"size-4",
|
||||
isDark ? "text-white" : "text-[var(--brand-900)]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden text-left sm:block">
|
||||
<p className="text-sm leading-tight font-medium text-white">
|
||||
张策略
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs leading-tight",
|
||||
isDark ? "text-blue-400/60" : "text-white/75",
|
||||
)}
|
||||
>
|
||||
策略分析师
|
||||
</p>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{actionCluster}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
isDark ? "text-slate-500" : "text-white/80",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
{showTopNav && thirdSubNavItems.length ? (
|
||||
<nav className="third-submenu-bar" aria-label="三级菜单">
|
||||
|
||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user