From b58e731c195b31227d263db27c190dc46dedeff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 12 Jun 2026 15:43:58 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E5=9C=86=E6=A1=8C=E4=BC=9A=E5=95=86?= =?UTF-8?q?=E5=90=84=E6=AD=A5=E6=8E=A5=E5=85=A5=E6=B5=81=E5=BC=8F=E5=9B=9E?= =?UTF-8?q?=E7=AD=94=E9=80=9F=E5=BA=A6=E6=8C=87=E7=A4=BA=E5=99=A8=EF=BC=8C?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E4=B8=BB=E8=81=8A=E5=A4=A9=20SpeedPill=20?= =?UTF-8?q?=E4=B8=8E=E5=85=A8=E5=B1=80=E5=BC=80=E5=85=B3=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 DialogueSpeedMeter 与 useDialogueSpeedMetrics,在 Step1/2/3 气泡底部展示 tok/s 与耗时。 Co-authored-by: Cursor --- .../messages/message-token-usage.tsx | 4 +- .../components/DialogueSpeedMeter.tsx | 27 ++++ .../components/Step1Panel.tsx | 18 +++ .../components/Step2Panel.tsx | 21 +++ .../components/Step3ReportMode.tsx | 7 + .../components/Step3SummaryMode.tsx | 7 + .../hooks/useDialogueSpeedMetrics.ts | 153 ++++++++++++++++++ 7 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/components/DialogueSpeedMeter.tsx create mode 100644 frontend-web/src/roundtable-planning/hooks/useDialogueSpeedMetrics.ts diff --git a/frontend-web/src/components/workspace/messages/message-token-usage.tsx b/frontend-web/src/components/workspace/messages/message-token-usage.tsx index 16019b2c..f45b2ef0 100644 --- a/frontend-web/src/components/workspace/messages/message-token-usage.tsx +++ b/frontend-web/src/components/workspace/messages/message-token-usage.tsx @@ -23,7 +23,7 @@ function classifySpeed(tps: number): { color: string; label: string } { return { color: "#ef4444", label: "缓慢" }; } -function formatDuration(ms: number): string { +export function formatDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)} ms`; if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`; const mins = Math.floor(ms / 60_000); @@ -31,7 +31,7 @@ function formatDuration(ms: number): string { return `${mins}m ${secs}s`; } -function SpeedPill({ metric }: { metric: MessageMetric }) { +export function SpeedPill({ metric }: { metric: MessageMetric }) { const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC)); const { color, label } = classifySpeed(metric.tokensPerSec); const display = diff --git a/frontend-web/src/roundtable-planning/components/DialogueSpeedMeter.tsx b/frontend-web/src/roundtable-planning/components/DialogueSpeedMeter.tsx new file mode 100644 index 00000000..8993a505 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/DialogueSpeedMeter.tsx @@ -0,0 +1,27 @@ +/** + * 圆桌会商气泡底部的「回答速度指示器」—— 复用主聊天的 `SpeedPill`(同款图标 + + * tok/s 数值 + 绿→红渐变条)与 `formatDuration`,外壳套圆桌的细分隔线 + 小字号, + * 让多智能体会商页的每条模型发言与主页面观感一致。 + * + * 仅展示用:是否渲染由调用方按 `streamSpeedMeterEnabled` 开关 + 是否有度量决定 + * (与主页面共用同一个全局开关)。`durationMs <= 0`(尚无可计时数据)时不渲染。 + */ +import { + SpeedPill, + formatDuration, +} from "@/components/workspace/messages/message-token-usage"; +import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics"; + +export function DialogueSpeedMeter({ metric }: { metric: MessageMetric }) { + if (metric.durationMs <= 0) { + return null; + } + return ( +
+ + + {formatDuration(metric.durationMs)} + +
+ ); +} diff --git a/frontend-web/src/roundtable-planning/components/Step1Panel.tsx b/frontend-web/src/roundtable-planning/components/Step1Panel.tsx index 168b4c23..695bae14 100644 --- a/frontend-web/src/roundtable-planning/components/Step1Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step1Panel.tsx @@ -35,7 +35,10 @@ function formatFileSize(bytes: number): string { } import { Button } from "@/components/ui/button"; +import { useLocalSettings } from "@/core/settings"; +import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics"; import { Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars"; +import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpeedMeter"; import { clarificationKey, IntentClarificationCard, @@ -51,6 +54,7 @@ import { import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; +import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics"; import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent"; import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning"; import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants"; @@ -108,6 +112,11 @@ export function Step1Panel(props: Step1PanelProps) { const { atBottom: step1AtBottom, scrollToBottom: scrollStep1ToBottom } = useAutoScroll(step1ScrollRef, [interactiveMessages]); + // 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。 + const [localSettings] = useLocalSettings(); + const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; + const messageMetrics = useDialogueSpeedMetrics(interactiveMessages); + // 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。 const [inputText, setInputText] = useState(""); // 待发送的本地附件(点发送时连同文本一起上传)。 @@ -165,6 +174,7 @@ export function Step1Panel(props: Step1PanelProps) { ; @@ -354,6 +369,7 @@ interface Step1MessageProps { function Step1Message({ msg, + metric, intentReady, pendingIntentClarificationMsgId, intentClarificationSelections, @@ -520,6 +536,8 @@ function Step1Message({ )} )} + + {metric && } ); } diff --git a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx index 943b1914..37409cb3 100644 --- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx @@ -47,6 +47,10 @@ import { /** 后台挂起入口 —— 后端 _run_dag 已支持业务链分层并行,放出该入口。 */ const SHOW_BACKGROUND_SUSPEND = true; +import { useLocalSettings } from "@/core/settings"; +import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics"; + +import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpeedMeter"; import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; @@ -62,6 +66,7 @@ import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsC import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip"; import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart"; import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; +import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics"; import type { AgentConfig, ParallelSeatView, @@ -189,6 +194,12 @@ export function Step2Panel(props: Step2PanelProps) { const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } = useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]); + // 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。 + // 度量始终计算(成本低、便于中途开启时也有时长基线),仅展示按开关门控。 + const [localSettings] = useLocalSettings(); + const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; + const dialogueMetrics = useDialogueSpeedMetrics(step2RoundtableDialogues); + // 并行进入/退出、或切换主区显示席位时,布局在「平铺列表 ↔ 两列/单列网格」之间切换, // scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随 // (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。 @@ -456,6 +467,7 @@ export function Step2Panel(props: Step2PanelProps) { {msg ? ( openArtifactFromStep(msg, step)} onFileCardClick={(path) => { @@ -476,6 +488,7 @@ export function Step2Panel(props: Step2PanelProps) { openArtifactFromStep(msg, step)} onFileCardClick={(path) => { @@ -687,6 +700,11 @@ export function Step2Panel(props: Step2PanelProps) { */ export interface Step2MessageProps { msg: Step2Dialogue; + /** + * 本条气泡的「回答速度」度量(tok/s + 时长)。仅对流式过的模型发言有值; + * 由调用方按全局开关 streamSpeedMeterEnabled 决定是否传入(关则传 undefined)。 + */ + metric?: MessageMetric; /** 把派活 chip 的 agent id 解析成中文席位名。 */ resolveAgentName?: (idOrName: string) => string; onArtifactStepClick?: ( @@ -698,6 +716,7 @@ export interface Step2MessageProps { export function Step2Message({ msg, + metric, resolveAgentName, onArtifactStepClick, onFileCardClick, @@ -749,6 +768,8 @@ export function Step2Message({ disabled={!msg.agentThreadId} /> )} + + {metric && } ); } diff --git a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx index 4c57d8c7..93394a0a 100644 --- a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx @@ -30,6 +30,7 @@ import { } from "lucide-react"; import { useArtifacts } from "@/components/workspace/artifacts"; +import { useLocalSettings } from "@/core/settings"; import { cn } from "@/lib/utils"; import { buildArtifactDownloadUrl, @@ -45,6 +46,7 @@ import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2Sandbo import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; +import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics"; import { useStep3Report, type ReportIntent, @@ -480,6 +482,10 @@ function AgentProgressPanel({ const inputLocked = phase === "creating"; const scrollRef = useRef(null); const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); + // 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。 + const [localSettings] = useLocalSettings(); + const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; + const dialogueMetrics = useDialogueSpeedMetrics(dialogues); const [revisionText, setRevisionText] = useState(""); const handleRevisionSubmit = (e: FormEvent) => { @@ -548,6 +554,7 @@ function AgentProgressPanel({ onArtifactStepClick(msg, step)} onFileCardClick={(path) => { if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path); diff --git a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx index 6d22d527..1832e725 100644 --- a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx @@ -31,6 +31,7 @@ import { } from "lucide-react"; import { useArtifacts } from "@/components/workspace/artifacts"; +import { useLocalSettings } from "@/core/settings"; import { cn } from "@/lib/utils"; import { buildArtifactDownloadUrl, @@ -54,6 +55,7 @@ import { markdownComponents } from "@/roundtable-planning/components/markdown-co import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode"; 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 type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report"; @@ -573,6 +575,10 @@ function AgentProgressPanel({ }) { const scrollRef = useRef(null); const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); + // 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。 + const [localSettings] = useLocalSettings(); + const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; + const dialogueMetrics = useDialogueSpeedMetrics(dialogues); const [askText, setAskText] = useState(""); // 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。 const [askFiles, setAskFiles] = useState([]); @@ -655,6 +661,7 @@ function AgentProgressPanel({ onArtifactStepClick(msg, step)} onFileCardClick={(path) => { if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path); diff --git a/frontend-web/src/roundtable-planning/hooks/useDialogueSpeedMetrics.ts b/frontend-web/src/roundtable-planning/hooks/useDialogueSpeedMetrics.ts new file mode 100644 index 00000000..d2851dfb --- /dev/null +++ b/frontend-web/src/roundtable-planning/hooks/useDialogueSpeedMetrics.ts @@ -0,0 +1,153 @@ +/** + * 圆桌各步消息气泡的「回答速度指示器」度量 —— 对齐主聊天 + * `components/workspace/messages/use-message-metrics.ts` 的算法。Step 1 的 + * `InteractiveMessage`、Step 2 / Step 3 的 `Step2Dialogue` 在「id + content + + * streaming」上同形,故本 hook 只依赖这三字段的最小形状,三步通用: + * + * - 圆桌气泡没有 `usage_metadata`,统一走字符估算(≈ 2 字符 / token)。 + * - 以「这条气泡是否 **流式过**」作为「它是不是真正的模型生成」的判据: + * 只有 `streaming === true` 出现过的气泡才建度量项。用户提问 / 系统播报 / + * 澄清回复这类一次性整条推入、从不流式的气泡天然不会有速度胶囊。 + * - 流式中用滑动窗口算实时 tok/s;流式结束(streaming 翻 false)冻结时长与速率。 + * + * 返回 `Map`,复用主聊天的 `MessageMetric` 形状, + * 以便直接喂给同一个 `SpeedPill`。 + */ +import { useEffect, useRef, useState } from "react"; + +import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics"; + +/** 本 hook 需要的最小气泡形状(Step 1/2/3 的消息类型都满足)。 */ +export interface SpeedTrackableMessage { + id: number; + content?: string; + streaming?: boolean; +} + +/** 两字符 ≈ 一 token —— 与主聊天 / 站内其它处保持一致的粗估。 */ +const CHARS_PER_TOKEN = 2; + +/** 流式期间的采样节流。 */ +const SAMPLE_THROTTLE_MS = 60; + +type Sample = { at: number; chars: number }; + +type TimingEntry = { + firstSeenAt: number; + endedAt: number | null; + samples: Sample[]; + lastChars: number; +}; + +function contentLength(dialogue: SpeedTrackableMessage): number { + return (dialogue.content ?? "").length; +} + +/** + * 返回按 dialogue id 索引的实时度量快照:流式中实时刷新,结束后冻结。 + */ +export function useDialogueSpeedMetrics( + dialogues: SpeedTrackableMessage[], +): Map { + const timingRef = useRef>(new Map()); + const lastSampleAtRef = useRef(0); + // 采样推入时强制重算 —— 与主聊天同理:timing map 是 ref,直接改不会触发渲染。 + const [, setTick] = useState(0); + + useEffect(() => { + const now = performance.now(); + + for (const dialogue of dialogues) { + const streaming = dialogue.streaming === true; + let entry = timingRef.current.get(dialogue.id); + + // 仅当气泡 **正在流式** 时才开始跟踪 —— 这是「真模型生成」的信号; + // 已完成才出现(系统播报 / 插话 / 澄清回复)的气泡不建项,也就不出胶囊。 + if (!entry) { + if (!streaming) continue; + const chars = contentLength(dialogue); + entry = { + firstSeenAt: now, + endedAt: null, + samples: [{ at: now, chars }], + lastChars: chars, + }; + timingRef.current.set(dialogue.id, entry); + continue; + } + + const chars = contentLength(dialogue); + if (streaming) { + const grew = chars !== entry.lastChars; + if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) { + entry.samples.push({ at: now, chars }); + entry.lastChars = chars; + if (entry.samples.length > 256) { + entry.samples.splice(0, entry.samples.length - 256); + } + lastSampleAtRef.current = now; + } else if (grew) { + entry.lastChars = chars; + } + } else if (entry.endedAt === null) { + // 流式结束 → 冻结时长,并以最终内容长度收口。 + entry.endedAt = now; + entry.lastChars = chars; + } + } + + setTick((v) => v + 1); + }, [dialogues]); + + // 仍有气泡在流式时慢速 tick,让实时时长 / 速率平滑刷新。 + const anyStreaming = dialogues.some((d) => d.streaming === true); + useEffect(() => { + if (!anyStreaming) return; + const interval = window.setInterval(() => setTick((v) => v + 1), 300); + return () => window.clearInterval(interval); + }, [anyStreaming]); + + const out = new Map(); + const now = performance.now(); + for (const dialogue of dialogues) { + const entry = timingRef.current.get(dialogue.id); + if (!entry) continue; + + const isActive = entry.endedAt === null && dialogue.streaming === true; + const endedAt = entry.endedAt ?? now; + const durationMs = Math.max(0, endedAt - entry.firstSeenAt); + + let tokensPerSec = 0; + if (isActive && entry.samples.length >= 2) { + // 滑动窗口:取最近 ~1.5s 的样本,反映当前速度而非全程平均。 + const samples = entry.samples; + let start = samples.findIndex((s) => s.at >= now - 1500); + if (start === -1) start = samples.length - 2; + if (start < 0) start = 0; + const head = samples[start]; + const tail = samples[samples.length - 1]; + if (head && tail) { + const elapsedMs = tail.at - head.at; + const chars = Math.max(0, tail.chars - head.chars); + if (elapsedMs > 0) { + tokensPerSec = ((chars / elapsedMs) * 1000) / CHARS_PER_TOKEN; + } + } + } else if (durationMs > 0) { + // 已完成:按全程平均。 + tokensPerSec = ((entry.lastChars / durationMs) * 1000) / CHARS_PER_TOKEN; + } + + out.set(dialogue.id, { + tokensPerSec, + durationMs, + isActive, + // 圆桌气泡无 usage_metadata,速率永远是字符估算。 + fromUsage: false, + // 圆桌每条气泡就是一位席位的一次发言,逐条都展示(无需「turn-final」收敛)。 + isTurnFinal: true, + }); + } + + return out; +} From f279613f5dce7e77c88acf7a724a324be38ee72d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 12 Jun 2026 15:48:48 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roundtable-planning/components/Step2Panel.tsx | 1 + offline-backend-20260512/backend/config.yaml | 15 --------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx index 37409cb3..7903cd73 100644 --- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx @@ -48,6 +48,7 @@ import { const SHOW_BACKGROUND_SUSPEND = true; import { useLocalSettings } from "@/core/settings"; +import { cn } from "@/lib/utils"; import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics"; import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpeedMeter"; diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 495243bf..022ddd63 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -10,20 +10,6 @@ gateway: port: 8001 models: -<<<<<<< Updated upstream - # - name: deepseek-chat - # display_name: DeepSeek Chat - # use: langchain_openai:ChatOpenAI - # model: deepseek-chat - # api_key: sk-3fee562f834a4c08bb0d471ef44cb445 - # base_url: https://api.deepseek.com - # request_timeout: 600.0 - # max_retries: 2 - # max_tokens: 4096 - # temperature: 1.0 - # supports_vision: false - # supports_thinking: true -======= - name: deepseek-chat display_name: DeepSeek Chat use: langchain_openai:ChatOpenAI @@ -36,7 +22,6 @@ models: temperature: 1.0 supports_vision: false supports_thinking: true ->>>>>>> Stashed changes # - name: zai-org/GLM-5-FP8 # display_name: zai-org/GLM-5-FP8 From dd329e7a4f877880dcf9cf88e31e70d9060518fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 12 Jun 2026 16:36:58 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E5=9C=86=E6=A1=8C=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E6=AD=A5=E6=94=AF=E6=8C=81=E6=8C=89=E5=B8=AD=E4=BD=8D=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E4=BC=9A=E5=95=86=E8=AE=B0=E5=BD=95=EF=BC=8C=E5=B7=A6?= =?UTF-8?q?=E4=BE=A7=E8=A7=92=E8=89=B2=E5=8D=A1=E4=B8=8E=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=A4=B4=E5=83=8F=E8=81=94=E5=8A=A8=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 seat-filter 归一化匹配逻辑,点击角色卡或气泡头像可只看该席位发言,并支持一键显示全部。 Co-authored-by: Cursor --- .../components/Step2Panel.tsx | 139 +++++++++++-- .../roundtable-planning/lib/seat-filter.ts | 63 ++++++ .../pages/RoundtablePlanningPage.tsx | 184 ++++++++++++++++-- 3 files changed, 350 insertions(+), 36 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/lib/seat-filter.ts diff --git a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx index 7903cd73..7978b9bf 100644 --- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx @@ -68,6 +68,12 @@ import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeat import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart"; import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics"; +import { + seatFilterForSender, + seatFilterLabel, + senderMatchesSeatFilter, + type SeatFilter, +} from "@/roundtable-planning/lib/seat-filter"; import type { AgentConfig, ParallelSeatView, @@ -100,6 +106,10 @@ export interface Step2PanelProps { agentConfigs: Record; selectedAgentId: number | null; roles: RoleEntry[]; + // ─── 按席位筛选会商记录(状态在页面层,与左侧角色卡片共用)────────────── + /** 当前筛选标识;null = 显示全部。 */ + seatFilter: SeatFilter | null; + setSeatFilter: React.Dispatch>; // ─── 并行显示(§5)──────────────────────────────────────────── parallelBatch: ParallelSeatView[]; selectedParallelSeats: string[]; @@ -161,6 +171,8 @@ export function Step2Panel(props: Step2PanelProps) { agentConfigs, selectedAgentId, roles, + seatFilter, + setSeatFilter, parallelBatch, selectedParallelSeats, isParallelRunning, @@ -201,6 +213,36 @@ export function Step2Panel(props: Step2PanelProps) { const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; const dialogueMetrics = useDialogueSpeedMetrics(step2RoundtableDialogues); + // ─── 按席位筛选会商记录 ────────────────────────────────────────────────── + // 筛选状态在页面层(与左侧「参与角色 / 人工参与」卡片共用)。点卡片或点气泡头像 + // 都把目标归一化成同一 SeatFilter 标识(见 lib/seat-filter),命中 = 标识相等。 + // 仅在单列(非并行)视图启用;并行进行时主区另有「头像条选席位」机制,互不干扰。 + const toggleSeatFilter = useCallback( + (filter: SeatFilter) => + setSeatFilter((cur) => (cur === filter ? null : filter)), + [setSeatFilter], + ); + const visibleDialogues = useMemo( + () => + seatFilter + ? step2RoundtableDialogues.filter((d) => + senderMatchesSeatFilter(d.sender, seatFilter), + ) + : step2RoundtableDialogues, + [seatFilter, step2RoundtableDialogues], + ); + // 选中的席位若已不在对话里(切换草稿 / 重置后),自动清除筛选,避免卡在空列表。 + useEffect(() => { + if ( + seatFilter && + !step2RoundtableDialogues.some((d) => + senderMatchesSeatFilter(d.sender, seatFilter), + ) + ) { + setSeatFilter(null); + } + }, [seatFilter, step2RoundtableDialogues, setSeatFilter]); + // 并行进入/退出、或切换主区显示席位时,布局在「平铺列表 ↔ 两列/单列网格」之间切换, // scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随 // (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。 @@ -485,20 +527,55 @@ export function Step2Panel(props: Step2PanelProps) { })} ) : ( - step2RoundtableDialogues.map((msg) => ( - openArtifactFromStep(msg, step)} - onFileCardClick={(path) => { - if (msg.agentThreadId) { - openArtifactPreview(msg.agentThreadId, path); - } - }} - /> - )) + <> + {/* 席位筛选提示条:点卡片 / 头像只看某席位发言时出现,可一键显示全部。 */} + {seatFilter && ( +
+ + 仅显示{" "} + + {seatFilterLabel(seatFilter)} + {" "} + 的发言 + + +
+ )} + {seatFilter && visibleDialogues.length === 0 ? ( +
+ 没有匹配「{seatFilterLabel(seatFilter)}」的消息 +
+ ) : ( + visibleDialogues.map((msg) => ( + + toggleSeatFilter(seatFilterForSender(msg.sender)) + } + resolveAgentName={resolveAgentName} + onArtifactStepClick={(step) => openArtifactFromStep(msg, step)} + onFileCardClick={(path) => { + if (msg.agentThreadId) { + openArtifactPreview(msg.agentThreadId, path); + } + }} + /> + )) + )} + )} {!step2AtBottom && } @@ -706,6 +783,13 @@ export interface Step2MessageProps { * 由调用方按全局开关 streamSpeedMeterEnabled 决定是否传入(关则传 undefined)。 */ metric?: MessageMetric; + /** + * 点击头像的回调 —— 传入则头像变为可点的「按席位筛选」开关。不传(并行两列等 + * 场景)则头像为纯展示,保持原样。 + */ + onAvatarClick?: () => void; + /** 当前筛选是否正命中本气泡的席位(命中则头像高亮)。 */ + isAvatarSelected?: boolean; /** 把派活 chip 的 agent id 解析成中文席位名。 */ resolveAgentName?: (idOrName: string) => string; onArtifactStepClick?: ( @@ -718,6 +802,8 @@ export interface Step2MessageProps { export function Step2Message({ msg, metric, + onAvatarClick, + isAvatarSelected = false, resolveAgentName, onArtifactStepClick, onFileCardClick, @@ -732,9 +818,32 @@ export function Step2Message({ const hasContent = content.length > 0; const presentedFiles = collectPresentedFiles(msg.steps); + const avatarInner = getAvatar(msg.avatarType, "h-9 w-9 shrink-0"); + // 传了 onAvatarClick → 头像变成「按席位筛选」开关:点亮当前筛选席位,带光圈高亮。 + const avatar = onAvatarClick ? ( + + ) : ( + avatarInner + ); + return ( "` —— 某 AI 席位的发言(对应该席位卡片) + */ +import { COMMANDER_SEAT_ID, type RoleEntry } from "./roundtable-constants"; + +export type SeatFilter = string; + +/** 总控协调气泡固定使用的 sender(见 useStep2Orchestration 的 appendStreamingDialogue)。 */ +export const COORDINATOR_SENDER = "总控协调"; + +export const COORDINATOR_SEAT_FILTER: SeatFilter = "coordinator"; +export const USER_SEAT_FILTER: SeatFilter = "user"; + +const AGENT_PREFIX = "agent:"; + +/** 把一条气泡的 sender 归一化成筛选标识。 */ +export function seatFilterForSender(sender: string): SeatFilter { + if (sender === COORDINATOR_SENDER || sender === "系统") { + return COORDINATOR_SEAT_FILTER; + } + if (sender.startsWith("用户")) { + return USER_SEAT_FILTER; + } + // 去掉「方案设计 (deepseek-…)」这种「模型偏好已保存」播报的模型后缀,回到席位基名, + // 让它与该席位的正常发言归到同一标识。 + const base = sender.replace(/\s*\([^)]*\)\s*$/, "").trim(); + return `${AGENT_PREFIX}${base}`; +} + +/** 把「参与角色」里的一行(席位)映射成筛选标识。 */ +export function seatFilterForRole(role: RoleEntry): SeatFilter { + // 指挥员 / 主持人(COMMANDER_SEAT_ID,human):对应总控协调 / 系统 调度播报流。 + if (role.type === "human" || role.id === COMMANDER_SEAT_ID) { + return COORDINATOR_SEAT_FILTER; + } + return `${AGENT_PREFIX}${role.name}`; +} + +/** 某 sender 是否命中当前筛选。 */ +export function senderMatchesSeatFilter( + sender: string, + filter: SeatFilter, +): boolean { + return seatFilterForSender(sender) === filter; +} + +/** 筛选提示条用的人类可读标签。 */ +export function seatFilterLabel(filter: SeatFilter): string { + if (filter === COORDINATOR_SEAT_FILTER) return "指挥员 / 主持人"; + if (filter === USER_SEAT_FILTER) return "用户"; + if (filter.startsWith(AGENT_PREFIX)) return filter.slice(AGENT_PREFIX.length); + return filter; +} diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 1ee88874..bcf6cd2b 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -86,6 +86,11 @@ import { type RoleEntry, type SelectedAgent, } from "@/roundtable-planning/lib/roundtable-constants"; +import { + seatFilterForRole, + USER_SEAT_FILTER, + type SeatFilter, +} from "@/roundtable-planning/lib/seat-filter"; import { buildChainSeatSelection, type SeatSelectionResult, @@ -215,6 +220,8 @@ export default function RoundtablePlanningPage() { const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true); const [step2Runs, setStep2Runs] = useState([]); const [activeStep2RunId, setActiveStep2RunId] = useState(null); + // 第二步「按席位筛选会商记录」—— 左侧角色卡片与中间消息头像共用的筛选标识。 + const [seatFilter, setSeatFilter] = useState(null); // Step 3「结果绘制」结果 —— 持久化进草稿 step3。绘制完成由 onReportSaved 写入; // 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。 @@ -1153,6 +1160,8 @@ export default function RoundtablePlanningPage() { step2Runs={step2Runs} activeStep2RunId={activeStep2RunId} onSelectStep2Run={handleSelectStep2Run} + seatFilter={seatFilter} + setSeatFilter={setSeatFilter} /> {/* MIDDLE VIEWPORT */} @@ -1216,6 +1225,8 @@ export default function RoundtablePlanningPage() { agentConfigs={step2.agentConfigs} selectedAgentId={step2.selectedAgentId} roles={roles} + seatFilter={seatFilter} + setSeatFilter={setSeatFilter} parallelBatch={step2.parallelBatch} selectedParallelSeats={step2.selectedParallelSeats} isParallelRunning={step2.isParallelRunning} @@ -1623,6 +1634,9 @@ interface LeftSidebarProps { step2Runs: DraftStep2RunSnapshot[]; activeStep2RunId: string | null; onSelectStep2Run: (runId: string) => void; + /** 第二步「按席位筛选会商记录」—— 点角色卡片筛选,与中间消息头像共用。 */ + seatFilter: SeatFilter | null; + setSeatFilter: React.Dispatch>; } function Step2SandboxSidebarSync({ @@ -1662,8 +1676,13 @@ function LeftSidebar({ step2Runs, activeStep2RunId, onSelectStep2Run, + seatFilter, + setSeatFilter, }: LeftSidebarProps) { const isStep2 = currentStep === 2; + // 仅第二步可按席位筛选会商记录;点中已选则取消(回到显示全部)。 + const toggleSeatFilter = (filter: SeatFilter) => + setSeatFilter((cur) => (cur === filter ? null : filter)); // Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应 // 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。 @@ -1708,13 +1727,25 @@ function LeftSidebar({

参与角色

- {roles.map((role) => ( - - ))} + {roles.map((role) => { + const filter = seatFilterForRole(role); + return ( + toggleSeatFilter(filter)} + /> + ); + })}
- + toggleSeatFilter(USER_SEAT_FILTER)} + />
- {roles.map((role) => ( - - ))} + {roles.map((role) => { + const filter = seatFilterForRole(role); + return ( + toggleSeatFilter(filter)} + /> + ); + })}
- + @@ -1897,14 +1949,50 @@ function TaskOverviewCard({ currentStep }: { currentStep: number }) { ); } -function HumanParticipationCard() { +function HumanParticipationCard({ + active = false, + onSelect, +}: { + /** 当前筛选是否命中「用户」(命中则高亮)。 */ + active?: boolean; + /** 传入则可点击,用于筛选出用户的插话 / 澄清回复。 */ + onSelect?: () => void; +} = {}) { + const clickable = Boolean(onSelect); return (

人工参与

-
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect?.(); + } + } + : undefined + } + className={cn( + "flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 transition", + clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5", + active && "ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40", + )} + >
用户 @@ -2318,6 +2406,8 @@ function RoleAvatarBadge({ agentNameToRole, size = "h-7 w-7", showSpeakingAnimation = false, + active = false, + onSelect, }: { role: RoleEntry; agentNameToRole: Record< @@ -2326,16 +2416,21 @@ function RoleAvatarBadge({ >; size?: string; showSpeakingAnimation?: boolean; + /** 当前筛选是否命中本席位(命中则头像高亮)。 */ + active?: boolean; + /** 传入则头像可点击,用于「按席位筛选会商记录」(收起态)。 */ + onSelect?: () => void; }) { const isOngoing = role.status === "研讨中"; const animate = showSpeakingAnimation && isOngoing; + const clickable = Boolean(onSelect); - return ( + const inner = (
{animate && ( @@ -2346,33 +2441,80 @@ function RoleAvatarBadge({
); + + if (!clickable) { + return
{inner}
; + } + return ( + + ); } function RoleRow({ role, agentNameToRole, + active = false, + onSelect, }: { role: RoleEntry; agentNameToRole: Record< string, { speakerId: number; avatarType: string; displayName: string } >; + /** 当前筛选是否命中本席位(命中则整卡高亮)。 */ + active?: boolean; + /** 传入则整卡可点击,用于「按席位筛选会商记录」。 */ + onSelect?: () => void; }) { const isActive = role.status === "在线" || role.status === "研讨中" || role.status === "已完成"; const isOngoing = role.status === "研讨中"; const isBootingUp = role.status === "正在上线"; + const clickable = Boolean(onSelect); return (
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect?.(); + } + } + : undefined + } + className={cn( + "flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition", isActive ? "bg-slate-50 border border-slate-100" : isBootingUp ? "bg-amber-50/60 border border-amber-100" - : "opacity-60" - }`} + : "opacity-60", + clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5", + active && + "ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40 !opacity-100", + )} >
From 369efc784b3a0585c0c10f743d8d398592cee891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 12 Jun 2026 17:50:56 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E5=BA=94=E7=94=A8=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=A1=B5=E6=A0=B7=E5=BC=8F=E5=AF=B9=E9=BD=90=E5=8F=82=E8=80=83?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=98=8E=E7=BB=86=E5=BC=B9=E5=B1=82=E8=A2=AB=E8=A3=81=E5=88=87?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../src/pages/LightAppManagementPage.tsx | 820 ++++++++++-------- 1 file changed, 477 insertions(+), 343 deletions(-) diff --git a/frontend-web/src/pages/LightAppManagementPage.tsx b/frontend-web/src/pages/LightAppManagementPage.tsx index a1f9cae1..1b54131a 100644 --- a/frontend-web/src/pages/LightAppManagementPage.tsx +++ b/frontend-web/src/pages/LightAppManagementPage.tsx @@ -1,16 +1,8 @@ -/** - * 应用管理页 —— 注册 / 编辑 / 删除轻应用(全局共享,写操作仅 admin)。 - * - * 两种类型: - * - window_open:填地址 + 路由参数,列表页以卡片展示、点击新开页跳转。 - * - iframe:填地址 + 路由参数 + 选择挂载菜单,注册后动态注入侧边栏对应父菜单下。 - * - * 路由参数构造器:参数名(key) + 类型(登录/主题)。登录参数右侧选 accessToken/yUserId/ - * userName/其他(自填值),运行时从 localStorage.userInfo 解析;主题参数填 dark-blue/red/blue。 - */ import { useMemo, useState } from "react"; import { ExternalLink, + HelpCircle, + Info, Layers, Pencil, Plus, @@ -20,25 +12,9 @@ import { } from "lucide-react"; import { toast } from "sonner"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; +import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { useTheme } from "@/contexts/ThemeContext"; import { useLightAppActions, useLightApps, @@ -47,11 +23,9 @@ import { type LightAppType, } from "@/strategy-components/api/light-apps"; import { - buildLightAppUrl, LOGIN_SOURCE_OPTIONS, MOUNT_OPTIONS, mountLabel, - THEME_EXAMPLE_OPTIONS, } from "@/strategy-components/lib/light-app"; interface FormState { @@ -100,10 +74,19 @@ function formFromApp(app: LightApp): FormState { }; } -export default function LightAppManagementPage() { - const { theme } = useTheme(); - const isDark = theme === "dark"; +function getParamSourceLabel(param: LightAppRouteParam): string { + if (param.paramType === "theme") { + return `主题参数: ${param.themeValue || ""}`; + } + const source = param.source || "accessToken"; + const option = LOGIN_SOURCE_OPTIONS.find((o) => o.value === source); + if (source === "other") { + return `登录参数: ${param.customValue || ""}`; + } + return `登录参数: ${option?.label || source}`; +} +export default function LightAppManagementPage() { const [search, setSearch] = useState(""); const { data: apps = [], isLoading } = useLightApps({ search }); const actions = useLightAppActions(); @@ -111,6 +94,7 @@ export default function LightAppManagementPage() { const [dialogOpen, setDialogOpen] = useState(false); const [editing, setEditing] = useState(null); const [form, setForm] = useState(emptyForm); + const [activeParamPopover, setActiveParamPopover] = useState(null); const saving = actions.create.isPending || actions.update.isPending; @@ -126,7 +110,8 @@ export default function LightAppManagementPage() { setDialogOpen(true); }; - const patchForm = (patch: Partial) => setForm((f) => ({ ...f, ...patch })); + const patchForm = (patch: Partial) => + setForm((f) => ({ ...f, ...patch })); const patchParam = (idx: number, patch: Partial) => setForm((f) => ({ @@ -135,26 +120,27 @@ export default function LightAppManagementPage() { })); const addParam = () => patchForm({ routeParams: [...form.routeParams, newParam()] }); + const removeParam = (idx: number) => patchForm({ routeParams: form.routeParams.filter((_, i) => i !== idx) }); - const previewUrl = useMemo(() => { - if (!form.url) return ""; - try { - return buildLightAppUrl({ url: form.url, routeParams: cleanParams(form.routeParams) }); - } catch { - return form.url; - } - }, [form.url, form.routeParams]); - const handleSave = async () => { const name = form.name.trim(); const url = form.url.trim(); - if (!name) return toast.error("请填写应用名称"); - if (!url) return toast.error("请填写应用地址"); - if (form.appType === "iframe" && !form.mountLocation) return toast.error("iframe 类型请选择挂载菜单"); + if (!name) return toast.error("请输入应用名称"); + if (!url) return toast.error("请输入应用地址"); + if (form.appType === "iframe" && !form.mountLocation) { + return toast.error("iframe 类型请选择挂载菜单"); + } + for (const p of form.routeParams) { - if (!p.key.trim()) return toast.error("路由参数的「参数名」不能为空"); + if (!p.key.trim()) return toast.error("路由参数键名不可为空"); + if (p.paramType === "login" && p.source === "other" && !p.customValue?.trim()) { + return toast.error("登录参数自定义值不可为空"); + } + if (p.paramType === "theme" && !p.themeValue?.trim()) { + return toast.error("主题参数值不可为空"); + } } const payload = { @@ -182,7 +168,7 @@ export default function LightAppManagementPage() { }; const handleDelete = async (app: LightApp) => { - if (!window.confirm(`确定删除应用「${app.name}」吗?`)) return; + if (!window.confirm(`是否确定要删除「${app.name}」?该操作不可逆。`)) return; try { await actions.remove.mutateAsync(app.appId); toast.success(`已删除「${app.name}」`); @@ -191,235 +177,487 @@ export default function LightAppManagementPage() { } }; - // ── theme classes(与配置管理页一致) ────────────────────────────────── - 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 - ? "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]"; - const mutedText = isDark ? "text-slate-500" : "text-[#A6A6A6]"; - - const typeBadge = (t: LightAppType) => - t === "iframe" - ? isDark - ? "bg-[rgba(168,85,247,0.16)] text-[#D6B4FF]" - : "bg-[#F9F0FF] text-[#722ED1]" - : isDark - ? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]" - : "bg-[#FFF1F0] text-[#CF1322]"; + const tableApps = useMemo(() => apps, [apps]); return ( -
-
-
+
+
+
-

- 应用管理 -

-

- 注册轻应用。window.open 类型在「应用列表」以卡片展示;iframe 类型挂载到所选菜单下并注入侧边栏。 +

应用管理

+

+ 注册轻应用。window.open 类型在「应用列表」以卡片展示;iframe 类型挂载到所选菜单下并注入侧栏。

- + +
- - + setSearch(e.target.value)} placeholder="搜索应用名称 / 描述" - className="pl-9" + 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 && ( + + )}
-
- +
+
- - - - - - - + + + + + + + - - {apps.length === 0 ? ( - - + {tableApps.map((app) => ( + + + + + + + + + + + + - ) : ( - apps.map((app) => ( - - + - - - - - - - )) + + + )}
名称类型地址 / 挂载参数启用操作
名称类型地址 / 挂载参数启用操作
- {isLoading ? "加载中…" : "暂无应用,点击右上角「注册应用」开始"} +
+
+ + {app.name} + + {app.description ? ( + {app.description} + ) : ( + 无描述说明 + )} +
+
+ {app.appType === "window_open" ? ( + + + 新页跳转 + + ) : ( + + + iframe 嵌套 + + )} + +
+ + {app.url} + + {app.appType === "iframe" && app.mountLocation && ( +
+ + 挂载: {mountLabel(app.mountLocation)} +
+ )} +
+
+
+ {app.routeParams.length > 0 ? ( + + ) : ( + 0个 + )} + + {activeParamPopover === app.appId && app.routeParams.length > 0 && ( + <> +
setActiveParamPopover(null)} + /> +
+
+ 路由参数明细 + + URL 拼接 + +
+
+ {app.routeParams.map((p, idx) => ( +
+ {p.key} + + ({getParamSourceLabel(p)}) + +
+ ))} +
+
+ + )} +
+
+
+ {app.enabled ? ( + <> + + + + + 已启用 + + ) : ( + <> + + 已禁用 + + )} +
+
+
+ + +
-
- {app.name} + ))} + + {tableApps.length === 0 && ( +
+
+
+
- {app.description && ( -
{app.description}
- )} -
- - {app.appType === "iframe" ? : } - {app.appType === "iframe" ? "iframe 嵌套" : "新页跳转"} - - -
{app.url}
- {app.appType === "iframe" && app.mountLocation && ( -
挂载:{mountLabel(app.mountLocation)}
- )} -
- {app.routeParams.length} 个 - - - {app.enabled ? "已启用" : "已停用"} - - -
- - +
+

+ {isLoading ? "加载中..." : "暂无匹配的组件应用"} +

+ {!isLoading && ( +

+ {search ? "请确认搜索关键词输入是否正确" : "点击右上角“注册应用”添加第一款轻应用"} +

+ )}
-
+ +
+ +
+

📌 智能多维挂载规则:

+
    +
  • + 当您注册 iframe 嵌套{" "} + 应用时,您需要在菜单一栏指定分类(如“资源管理”),组件将直接作为新导航菜单项加入该分类。 +
  • +
  • + 当路由挂载成功,点击左侧注入的子栏名称可在主区域调起{" "} + 真实 Iframe 预览器 + ,并自动将参数以加密/标准的QueryString传递过去。 +
  • +
+
+
- - - - {editing ? "编辑应用" : "注册应用"} - + {dialogOpen && ( +
+
setDialogOpen(false)} + /> -
- {/* 类型 */} - - - +
+
+

+ {editing ? "修改轻应用" : "注册应用"} +

+ +
- - patchForm({ name: e.target.value })} placeholder="如:舆情看板" /> - - - - patchForm({ url: e.target.value })} - placeholder="https://example.com/page 或 https://x#/route" - /> - - - {form.appType === "iframe" && ( - - - - )} - - -