From e70bb4aa6fcddb47d35bed8c66b51dba07882bf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Mon, 27 Jul 2026 18:59:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E8BF=E5=A4=9A=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E4=BD=93=E4=BC=9A=E5=95=86=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/src/core/auth/route-key-login.ts | 33 ++++ .../pages/EightBfRoundtablePage.tsx | 165 ++++++++++++++++++ .../src/eightbf-roundtable/styles/step1.css | 24 +++ .../src/eightbf-roundtable/styles/step2.css | 17 ++ .../src/eightbf-roundtable/styles/step3.css | 34 ++++ frontend-web/src/pages/StrategyRoutes.tsx | 23 ++- frontend-web/src/pages/WorkspaceRoutes.tsx | 11 ++ .../pages/RoundtablePlanningPage.tsx | 105 +++++++++-- .../app/gateway/routers/business_mapping.py | 53 ++++++ .../backend/tests/test_business_mapping.py | 100 +++++++++++ 10 files changed, 552 insertions(+), 13 deletions(-) create mode 100644 frontend-web/src/core/auth/route-key-login.ts create mode 100644 frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx create mode 100644 frontend-web/src/eightbf-roundtable/styles/step1.css create mode 100644 frontend-web/src/eightbf-roundtable/styles/step2.css create mode 100644 frontend-web/src/eightbf-roundtable/styles/step3.css create mode 100644 offline-backend-20260512/backend/tests/test_business_mapping.py diff --git a/frontend-web/src/core/auth/route-key-login.ts b/frontend-web/src/core/auth/route-key-login.ts new file mode 100644 index 00000000..7c7601ff --- /dev/null +++ b/frontend-web/src/core/auth/route-key-login.ts @@ -0,0 +1,33 @@ +import { readRuntimeEnv } from "@/env"; + +/** + * Short-link route map maintained by the frontend. + * + * Add a new entry here when an external system needs a short ``routeKey``. + * Targets must remain internal SPA routes; LoginPage validates them again + * before navigating. + */ +export const ROUTE_KEY_MAPPINGS: Readonly> = { + a: "/page/canvas/ai-writing", + "8bf": "/page/strategy/qa/8bf-roundtable", +}; + +/** Login URL parameter that selects a route from ``ROUTE_KEY_MAPPINGS``. */ +export const ROUTE_KEY_PARAM = "routeKey"; + +/** + * Optional frontend-configured username-login password. + * + * This is intentionally an environment/runtime value rather than a literal in + * source code. It is used only with a mapped ``routeKey`` link. When it is + * empty, LoginPage falls back to the URL's existing ``password`` parameter. + */ +export function getRouteKeyLoginPassword(): string { + return readRuntimeEnv("VITE_ROUTE_LOGIN_PASSWORD") ?? ""; +} + +export function resolveRouteKey(routeKey: string | null | undefined): string { + const key = routeKey?.trim(); + if (!key) return ""; + return ROUTE_KEY_MAPPINGS[key] ?? ""; +} diff --git a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx new file mode 100644 index 00000000..a1e2a1b3 --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Check, Loader2, Save, Settings2, X } from "lucide-react"; + +import { getStoredAuth } from "@/core/auth"; +import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage"; +import { listChains } from "@/roundtable-planning/api/chains"; +import type { BusinessChain } from "@/roundtable-planning/lib/business-chain"; +import { + listBusinessMappings, + updateBusinessMapping, + type BusinessMapping, +} from "@/strategy-components/api/business-mapping"; + +import "../styles/step1.css"; +import "../styles/step2.css"; +import "../styles/step3.css"; + +const BUSINESS_CODE = "8BF"; + +/** + * 8BF 独立多智能体会商页。 + * + * 页面复用圆桌会商的三步能力,但业务链选择、草稿存储键和 CSS 根节点完全独立; + * 不引用岗位会商的页面、会话或接口。 + */ +export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) { + const auth = getStoredAuth(); + const isAdmin = auth?.system_role === "admin"; + const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]); + const [mapping, setMapping] = useState(null); + const [chains, setChains] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [selectedChainId, setSelectedChainId] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const loadConfiguration = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const [mappings, availableChains] = await Promise.all([ + listBusinessMappings(), + listChains(isAdmin ? "all" : "public"), + ]); + const nextMapping = mappings.find((item) => item.businessCode === BUSINESS_CODE) ?? null; + // 8BF 只允许绑定已发布业务链;后端同样会进行二次校验。 + const nextChains = availableChains.filter((chain) => chain.isPublic); + setMapping(nextMapping); + setChains(nextChains); + setSelectedChainId(nextMapping?.chainId ?? ""); + } catch { + setError("业务链配置加载失败,请刷新后重试"); + } finally { + setIsLoading(false); + } + }, [isAdmin]); + + useEffect(() => { + void loadConfiguration(); + }, [loadConfiguration]); + + const handleSave = useCallback(async () => { + const selected = chains.find((chain) => chain.id === selectedChainId); + if (!selected) { + setError("请选择一条已发布的业务链"); + return; + } + setIsSaving(true); + setError(null); + try { + const next = await updateBusinessMapping(BUSINESS_CODE, { + chainId: selected.id, + chainTitle: selected.title, + }); + if (!next) throw new Error("8BF 映射不存在"); + setMapping(next); + setIsSettingsOpen(false); + } catch (err) { + setError(err instanceof Error ? err.message : "保存业务链失败"); + } finally { + setIsSaving(false); + } + }, [chains, selectedChainId]); + + const toolbarSlot = ( + <> +
+ 8BF 业务链 + + {isLoading ? "加载中…" : mapping?.chainTitle || "待管理员配置"} + +
+ {isAdmin && ( + + )} + {isSettingsOpen && ( +
+
+
+
+

管理员配置

+

设置 8BF 公共业务链

+
+ +
+

+ 保存后,所有 8BF 用户都会使用这条已发布业务链;会商记录仍按用户独立保存。 +

+ + {error &&

{error}

} +
+ + +
+
+
+ )} + {!isLoading && mapping?.chainId && } + + ); + + return ( + + ); +} diff --git a/frontend-web/src/eightbf-roundtable/styles/step1.css b/frontend-web/src/eightbf-roundtable/styles/step1.css new file mode 100644 index 00000000..7804ce37 --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/styles/step1.css @@ -0,0 +1,24 @@ +/* 8BF 第 1 步(任务理解)独立样式入口。所有规则限定在 .eightbf-roundtable 内,不影响原多智能体会商。 */ +.eightbf-roundtable { + --eightbf-accent: #0f766e; + --eightbf-accent-soft: #ecfdf5; + --eightbf-border: #b7ded8; +} + +.eightbf-roundtable .eightbf-roundtable__chain-status { + display: inline-flex; + min-width: 184px; + align-items: center; + gap: 8px; + border: 1px solid var(--eightbf-border); + border-radius: 10px; + background: var(--eightbf-accent-soft); + padding: 6px 10px; + font-size: 12px; +} + +.eightbf-roundtable .eightbf-roundtable__chain-status-label { color: #0f5f56; font-weight: 700; } +.eightbf-roundtable .eightbf-roundtable__chain-status-value { color: #134e4a; font-weight: 600; } +.eightbf-roundtable .eightbf-roundtable__configured-icon { color: var(--eightbf-accent); } + +/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */ diff --git a/frontend-web/src/eightbf-roundtable/styles/step2.css b/frontend-web/src/eightbf-roundtable/styles/step2.css new file mode 100644 index 00000000..0abc75de --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/styles/step2.css @@ -0,0 +1,17 @@ +/* 8BF 第 2 步(多智能体会商)独立样式入口。 */ +.eightbf-roundtable .eightbf-roundtable__chain-settings-button { + display: inline-flex; + align-items: center; + gap: 5px; + border-radius: 9px; + background: #0f766e; + color: #fff; + padding: 7px 10px; + font-size: 12px; + font-weight: 700; + transition: background-color .15s ease; +} + +.eightbf-roundtable .eightbf-roundtable__chain-settings-button:hover { background: #115e59; } + +/* 可在此继续覆盖会商消息、席位卡片、流程节点及展开按钮的样式。 */ diff --git a/frontend-web/src/eightbf-roundtable/styles/step3.css b/frontend-web/src/eightbf-roundtable/styles/step3.css new file mode 100644 index 00000000..c442cdff --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/styles/step3.css @@ -0,0 +1,34 @@ +/* 8BF 第 3 步(结果绘制)独立样式入口。 */ +.eightbf-roundtable .eightbf-roundtable__settings-mask { + position: fixed; + inset: 0; + z-index: 90; + display: grid; + place-items: center; + background: rgb(15 23 42 / 42%); + padding: 20px; +} + +.eightbf-roundtable .eightbf-roundtable__settings-dialog { + width: min(460px, 100%); + border-radius: 16px; + background: #fff; + padding: 20px; + box-shadow: 0 24px 56px rgb(15 23 42 / 22%); +} + +.eightbf-roundtable .eightbf-roundtable__settings-heading, +.eightbf-roundtable .eightbf-roundtable__settings-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.eightbf-roundtable .eightbf-roundtable__settings-kicker { color: #0f766e; font-size: 12px; font-weight: 700; } +.eightbf-roundtable .eightbf-roundtable__settings-heading h2 { margin-top: 2px; color: #0f172a; font-size: 16px; font-weight: 700; } +.eightbf-roundtable .eightbf-roundtable__settings-close { color: #64748b; } +.eightbf-roundtable .eightbf-roundtable__settings-tip { margin-top: 14px; color: #64748b; font-size: 13px; line-height: 1.6; } +.eightbf-roundtable .eightbf-roundtable__settings-field { display: grid; gap: 7px; margin-top: 16px; color: #334155; font-size: 13px; font-weight: 600; } +.eightbf-roundtable .eightbf-roundtable__settings-field select { width: 100%; border: 1px solid #cbd5e1; border-radius: 8px; padding: 9px 10px; font-weight: 500; } +.eightbf-roundtable .eightbf-roundtable__settings-error { margin-top: 10px; color: #b91c1c; font-size: 12px; } +.eightbf-roundtable .eightbf-roundtable__settings-actions { margin-top: 20px; justify-content: flex-end; } +.eightbf-roundtable .eightbf-roundtable__settings-actions button { border-radius: 8px; padding: 8px 12px; font-size: 13px; font-weight: 700; } +.eightbf-roundtable .eightbf-roundtable__settings-actions button:last-child { display: inline-flex; align-items: center; gap: 6px; background: #0f766e; color: #fff; } +.eightbf-roundtable .eightbf-roundtable__settings-actions button:disabled { cursor: not-allowed; opacity: .65; } + +/* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */ diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 31ee9214..90d37b3c 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -22,6 +22,7 @@ import KnowledgeBaseResourceManagement from "@/strategy-components/components/re import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement"; import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage"; import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage"; +import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage"; import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage"; import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage"; import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage"; @@ -67,6 +68,9 @@ import AdminUserActivityPage from "./AdminUserActivityPage"; import { ChatRuntime } from "./WorkspaceRoutes"; export default function StrategyRoutes() { + const storedAuth = getStoredAuth(); + const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin"; + return ( @@ -92,6 +96,14 @@ export default function StrategyRoutes() { } /> + + + + } + /> } /> } /> } /> - } /> + + ) : ( + + ) + } + /> } /> } /> diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 78b48b25..0373fadb 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -56,6 +56,7 @@ import StudioNotebooksPage from './StudioNotebooksPage'; import StudioNotebookDetailPage from './StudioNotebookDetailPage'; import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage'; import PositionRoundtablePage from '@/position-roundtable/pages/PositionRoundtablePage'; +import EightBfRoundtablePage from '@/eightbf-roundtable/pages/EightBfRoundtablePage'; import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage'; import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage'; import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage'; @@ -493,6 +494,16 @@ export default function WorkspaceRoutes({ } /> + + + + + + } + /> (null); /** 无界直传 chainId 时,链条解析完成前禁止弹选链窗(避免 modal 布局抖动)。 */ @@ -317,6 +334,31 @@ export default function RoundtablePlanningPage({ inWujieEmbed && !!(prefillRef.current?.chainId || prefillRef.current?.businessCode), ); + + // 独立业务页使用管理员配置的固定业务链。它只负责预选,不触发深链的自动开跑逻辑。 + useEffect(() => { + if (!lockedChainId) { + setAutoChainResult(null); + return; + } + let cancelled = false; + setAutoChainLoading(true); + void Promise.all([getChain(lockedChainId), listAgents().catch(() => [])]) + .then(([chain, agents]) => { + if (cancelled || !chain) return; + const result = buildChainSeatSelection(chain, filterRecommendCandidates(agents)); + if (!cancelled) setAutoChainResult(result); + }) + .catch(() => { + if (!cancelled) setAutoChainResult(null); + }) + .finally(() => { + if (!cancelled) setAutoChainLoading(false); + }); + return () => { + cancelled = true; + }; + }, [lockedChainId]); const autoStartedRef = useRef(false); const prefillClearedRef = useRef(false); // 共识达成后只向父页面(无界)回传一次交付结果。 @@ -756,7 +798,7 @@ export default function RoundtablePlanningPage({ setCurrentStep, triggerToast, // 聊天记录存储键:xdfx-A 按行动id 隔离,其它业务按真实 taskId(见 recordTaskIdOf)。 - taskId: recordTaskIdOf(prefillRef.current), + taskId: draftStorageTaskId, intentSending: step1.isIntentSending, step2FinalizedCount, step3SavedTick, @@ -1042,6 +1084,10 @@ export default function RoundtablePlanningPage({ // 再次点击同一条链 = 取消勾选。 const handleSelectChainFromSidebar = useCallback( async (chain: BusinessChain) => { + if (lockBusinessChain) { + triggerToast("当前页面的业务链由管理员统一配置"); + return; + } if (autoChainResult?.chain?.id === chain.id) { setAutoChainResult(null); triggerToast(`已取消业务链「${chain.title}」`); @@ -1061,7 +1107,7 @@ export default function RoundtablePlanningPage({ triggerToast("选择业务链失败,请重试"); } }, - [autoChainResult, triggerToast], + [autoChainResult, lockBusinessChain, triggerToast], ); // ─── 会商自动化串联(模块 C) ────────────────────────────────── @@ -1524,7 +1570,7 @@ export default function RoundtablePlanningPage({ step2.stopOrchestration(); // 1. 取消后端作业:避免前后端双跑/抢同一线程(409),线程随后由前端接管续跑。 // executor.cancel 会 task.cancel() 中断在跑的 run,DB 同步置 cancelled。 - await cancelJob(jobId, recordTaskIdOf(prefillRef.current)).catch(() => null); + await cancelJob(jobId, draftStorageTaskId).catch(() => null); // 2. 加载作业最近写回草稿的快照:恢复 threadIds(作业真实线程)+ 协调名 + 完整对话。 // hydrateFromDraft 已同步写好 threadIds/coordinatorName 的 ref,供下一步接管即时读取。 await drafts.handleLoadDraft(draftId); @@ -1629,7 +1675,7 @@ export default function RoundtablePlanningPage({ // 后台挂起发起前**立刻强制存一次**当前聊天(保住发起时刻的本地研讨记录,防丢)。 const draftId = await drafts.saveDraftNow(2); // 后台作业按聊天记录存储键归属(xdfx-A=行动id):报告写回对应 task-draft 存储、去重一致。 - const tid = recordTaskIdOf(prefillRef.current); + const tid = draftStorageTaskId; // 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。 const existing = draftId ? await getJobByDraft(draftId, tid).catch(() => null) : null; const alreadyRunning = @@ -1701,7 +1747,7 @@ export default function RoundtablePlanningPage({ const planSnap = mode === "dag" ? orchestrationPlan : null; const draftId = drafts.currentDraftId; // 后台作业按聊天记录存储键归属(xdfx-A=行动id),与草稿存储一致。 - const tid = recordTaskIdOf(prefillRef.current); + const tid = draftStorageTaskId; // 立刻:标记「挂起中」+ 跳回第一步 + 提示(不等接口)。 if (draftId) markPendingSuspend(draftId); @@ -1796,7 +1842,7 @@ export default function RoundtablePlanningPage({ if (agents.length === 0) return; startJobKeepalive({ draftId: drafts.currentDraftId ?? undefined, - taskId: recordTaskIdOf(prefillRef.current) ?? undefined, + taskId: draftStorageTaskId ?? undefined, intent: buildInitialIntent(), agents, model: selectedModel || undefined, @@ -1866,6 +1912,7 @@ export default function RoundtablePlanningPage({ className={cn( "roundtable-planning theme-transition relative flex h-full min-h-0 flex-col bg-[#fef6f6] font-sans text-slate-800 selection:bg-red-200", isDark && "roundtable-planning--dark", + rootClassName, )} > {/* 启动加载遮罩:深链/预填自动会话从进入页面到问答真正开始(建意图线程 / 回显历史草稿) @@ -1936,7 +1983,7 @@ export default function RoundtablePlanningPage({ {/* 派活链路流程图弹窗(订阅进行中作业的真实 SSE 进度) */} { if (!o) setChainModalJobId(null); @@ -1973,6 +2020,7 @@ export default function RoundtablePlanningPage({ />
+ {toolbarSlot} {/* 深链 taskId 模式:查看线上当前策划进度(目的树)。与单智能体闸门同一条接口。 */} {prefillRef.current?.taskId && (