feat: 新增8BF多智能体会商页面
This commit is contained in:
1 parent
239b798c5d
commit
1ab3329f6d
10 files changed
+552
-13
No files matched your search
@@ -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<Record<string, string>> = {
|
||||||
|
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] ?? "";
|
||||||
|
}
|
||||||
@@ -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<BusinessMapping | null>(null);
|
||||||
|
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
const [selectedChainId, setSelectedChainId] = useState("");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 = (
|
||||||
|
<>
|
||||||
|
<div className="eightbf-roundtable__chain-status" aria-live="polite">
|
||||||
|
<span className="eightbf-roundtable__chain-status-label">8BF 业务链</span>
|
||||||
|
<span className="eightbf-roundtable__chain-status-value">
|
||||||
|
{isLoading ? "加载中…" : mapping?.chainTitle || "待管理员配置"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="eightbf-roundtable__chain-settings-button"
|
||||||
|
onClick={() => setIsSettingsOpen(true)}
|
||||||
|
>
|
||||||
|
<Settings2 className="h-3.5 w-3.5" />
|
||||||
|
配置业务链
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isSettingsOpen && (
|
||||||
|
<div className="eightbf-roundtable__settings-mask" role="presentation">
|
||||||
|
<section
|
||||||
|
className="eightbf-roundtable__settings-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="eightbf-chain-settings-title"
|
||||||
|
>
|
||||||
|
<div className="eightbf-roundtable__settings-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eightbf-roundtable__settings-kicker">管理员配置</p>
|
||||||
|
<h2 id="eightbf-chain-settings-title">设置 8BF 公共业务链</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="eightbf-roundtable__settings-close"
|
||||||
|
onClick={() => setIsSettingsOpen(false)}
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="eightbf-roundtable__settings-tip">
|
||||||
|
保存后,所有 8BF 用户都会使用这条已发布业务链;会商记录仍按用户独立保存。
|
||||||
|
</p>
|
||||||
|
<label className="eightbf-roundtable__settings-field">
|
||||||
|
<span>已发布业务链</span>
|
||||||
|
<select value={selectedChainId} onChange={(event) => setSelectedChainId(event.target.value)}>
|
||||||
|
<option value="">请选择业务链</option>
|
||||||
|
{chains.map((chain) => (
|
||||||
|
<option key={chain.id} value={chain.id}>
|
||||||
|
{chain.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{error && <p className="eightbf-roundtable__settings-error">{error}</p>}
|
||||||
|
<div className="eightbf-roundtable__settings-actions">
|
||||||
|
<button type="button" onClick={() => setIsSettingsOpen(false)}>取消</button>
|
||||||
|
<button type="button" onClick={() => void handleSave()} disabled={isSaving}>
|
||||||
|
{isSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||||
|
保存并应用
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && mapping?.chainId && <Check className="eightbf-roundtable__configured-icon h-4 w-4" aria-label="已配置" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RoundtablePlanningPage
|
||||||
|
compactChrome={compactChrome}
|
||||||
|
lockedChainId={mapping?.chainId ?? null}
|
||||||
|
lockBusinessChain
|
||||||
|
storageTaskId={storageTaskId}
|
||||||
|
toolbarSlot={toolbarSlot}
|
||||||
|
rootClassName="eightbf-roundtable"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 步的任务理解面板与业务链流程图样式。 */
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
/* 可在此继续覆盖会商消息、席位卡片、流程节点及展开按钮的样式。 */
|
||||||
@@ -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 步报告、流程图与放大弹窗的样式。 */
|
||||||
@@ -22,6 +22,7 @@ import KnowledgeBaseResourceManagement from "@/strategy-components/components/re
|
|||||||
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
|
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
|
||||||
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
||||||
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
||||||
|
import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage";
|
||||||
import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage";
|
import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage";
|
||||||
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
|
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
|
||||||
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
|
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
|
||||||
@@ -67,6 +68,9 @@ import AdminUserActivityPage from "./AdminUserActivityPage";
|
|||||||
import { ChatRuntime } from "./WorkspaceRoutes";
|
import { ChatRuntime } from "./WorkspaceRoutes";
|
||||||
|
|
||||||
export default function StrategyRoutes() {
|
export default function StrategyRoutes() {
|
||||||
|
const storedAuth = getStoredAuth();
|
||||||
|
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RouteBaseProvider value="/page/strategy">
|
<RouteBaseProvider value="/page/strategy">
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
@@ -92,6 +96,14 @@ export default function StrategyRoutes() {
|
|||||||
</ChatRuntime>
|
</ChatRuntime>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="qa/8bf-roundtable"
|
||||||
|
element={
|
||||||
|
<ChatRuntime>
|
||||||
|
<EightBfRoundtablePage />
|
||||||
|
</ChatRuntime>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="qa/position-roundtable"
|
path="qa/position-roundtable"
|
||||||
element={
|
element={
|
||||||
@@ -312,7 +324,16 @@ export default function StrategyRoutes() {
|
|||||||
<Route path="skills" element={<SkillsPage />} />
|
<Route path="skills" element={<SkillsPage />} />
|
||||||
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
|
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
|
||||||
<Route path="memory" element={<MemoryPage />} />
|
<Route path="memory" element={<MemoryPage />} />
|
||||||
<Route path="business-chain-mapping" element={<BusinessChainMappingPage />} />
|
<Route
|
||||||
|
path="business-chain-mapping"
|
||||||
|
element={
|
||||||
|
canManageBusinessMappings ? (
|
||||||
|
<BusinessChainMappingPage />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/page/strategy/qa/general" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="business-chains" element={<BusinessChainListPage />} />
|
<Route path="business-chains" element={<BusinessChainListPage />} />
|
||||||
<Route path="roundtable/chains" element={<BusinessChainEditorPage />} />
|
<Route path="roundtable/chains" element={<BusinessChainEditorPage />} />
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import StudioNotebooksPage from './StudioNotebooksPage';
|
|||||||
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
|
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
|
||||||
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
|
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
|
||||||
import PositionRoundtablePage from '@/position-roundtable/pages/PositionRoundtablePage';
|
import PositionRoundtablePage from '@/position-roundtable/pages/PositionRoundtablePage';
|
||||||
|
import EightBfRoundtablePage from '@/eightbf-roundtable/pages/EightBfRoundtablePage';
|
||||||
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
|
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
|
||||||
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
|
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
|
||||||
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
|
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
|
||||||
@@ -493,6 +494,16 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="roundtable/8bf"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<ChatRuntime>
|
||||||
|
<EightBfRoundtablePage compactChrome={headerHidden && !compactActionsHidden} />
|
||||||
|
</ChatRuntime>
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="roundtable/position"
|
path="roundtable/position"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
* 5) Persona 抽屉改的 model 不会立即重启编排,下一轮才生效
|
* 5) Persona 抽屉改的 model 不会立即重启编排,下一轮才生效
|
||||||
* 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级
|
* 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { setRoundtableErrorContext } from "@/roundtable-planning/api/diagnostics-report";
|
import { setRoundtableErrorContext } from "@/roundtable-planning/api/diagnostics-report";
|
||||||
@@ -288,8 +288,23 @@ function readEmbedHashQueryPrefill(): RoundtablePrefill | null {
|
|||||||
|
|
||||||
export default function RoundtablePlanningPage({
|
export default function RoundtablePlanningPage({
|
||||||
compactChrome = false,
|
compactChrome = false,
|
||||||
|
/** 独立业务页锁定的公共业务链;不影响原多智能体会商入口。 */
|
||||||
|
lockedChainId = null,
|
||||||
|
/** 即使尚未配置业务链,也禁止用户改为推荐/手选链路。 */
|
||||||
|
lockBusinessChain = false,
|
||||||
|
/** 独立业务页的草稿/后台作业存储键。 */
|
||||||
|
storageTaskId,
|
||||||
|
/** 供独立业务页在原页头部插入自己的操作入口。 */
|
||||||
|
toolbarSlot,
|
||||||
|
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
|
||||||
|
rootClassName,
|
||||||
}: {
|
}: {
|
||||||
compactChrome?: boolean;
|
compactChrome?: boolean;
|
||||||
|
lockedChainId?: string | null;
|
||||||
|
lockBusinessChain?: boolean;
|
||||||
|
storageTaskId?: string | null;
|
||||||
|
toolbarSlot?: ReactNode;
|
||||||
|
rootClassName?: string;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
|
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
|
||||||
@@ -309,6 +324,8 @@ export default function RoundtablePlanningPage({
|
|||||||
readWujiePrefill() ??
|
readWujiePrefill() ??
|
||||||
readPersistedDeeplinkPrefill(location.pathname),
|
readPersistedDeeplinkPrefill(location.pathname),
|
||||||
);
|
);
|
||||||
|
// 8BF 等独立页面可提供专属存储键;原会商仍沿用深链 taskId / 个人草稿语义。
|
||||||
|
const draftStorageTaskId = storageTaskId ?? recordTaskIdOf(prefillRef.current);
|
||||||
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
|
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
|
||||||
const [autoChainResult, setAutoChainResult] = useState<SeatSelectionResult | null>(null);
|
const [autoChainResult, setAutoChainResult] = useState<SeatSelectionResult | null>(null);
|
||||||
/** 无界直传 chainId 时,链条解析完成前禁止弹选链窗(避免 modal 布局抖动)。 */
|
/** 无界直传 chainId 时,链条解析完成前禁止弹选链窗(避免 modal 布局抖动)。 */
|
||||||
@@ -317,6 +334,31 @@ export default function RoundtablePlanningPage({
|
|||||||
inWujieEmbed &&
|
inWujieEmbed &&
|
||||||
!!(prefillRef.current?.chainId || prefillRef.current?.businessCode),
|
!!(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 autoStartedRef = useRef(false);
|
||||||
const prefillClearedRef = useRef(false);
|
const prefillClearedRef = useRef(false);
|
||||||
// 共识达成后只向父页面(无界)回传一次交付结果。
|
// 共识达成后只向父页面(无界)回传一次交付结果。
|
||||||
@@ -756,7 +798,7 @@ export default function RoundtablePlanningPage({
|
|||||||
setCurrentStep,
|
setCurrentStep,
|
||||||
triggerToast,
|
triggerToast,
|
||||||
// 聊天记录存储键:xdfx-A 按行动id 隔离,其它业务按真实 taskId(见 recordTaskIdOf)。
|
// 聊天记录存储键:xdfx-A 按行动id 隔离,其它业务按真实 taskId(见 recordTaskIdOf)。
|
||||||
taskId: recordTaskIdOf(prefillRef.current),
|
taskId: draftStorageTaskId,
|
||||||
intentSending: step1.isIntentSending,
|
intentSending: step1.isIntentSending,
|
||||||
step2FinalizedCount,
|
step2FinalizedCount,
|
||||||
step3SavedTick,
|
step3SavedTick,
|
||||||
@@ -1042,6 +1084,10 @@ export default function RoundtablePlanningPage({
|
|||||||
// 再次点击同一条链 = 取消勾选。
|
// 再次点击同一条链 = 取消勾选。
|
||||||
const handleSelectChainFromSidebar = useCallback(
|
const handleSelectChainFromSidebar = useCallback(
|
||||||
async (chain: BusinessChain) => {
|
async (chain: BusinessChain) => {
|
||||||
|
if (lockBusinessChain) {
|
||||||
|
triggerToast("当前页面的业务链由管理员统一配置");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (autoChainResult?.chain?.id === chain.id) {
|
if (autoChainResult?.chain?.id === chain.id) {
|
||||||
setAutoChainResult(null);
|
setAutoChainResult(null);
|
||||||
triggerToast(`已取消业务链「${chain.title}」`);
|
triggerToast(`已取消业务链「${chain.title}」`);
|
||||||
@@ -1061,7 +1107,7 @@ export default function RoundtablePlanningPage({
|
|||||||
triggerToast("选择业务链失败,请重试");
|
triggerToast("选择业务链失败,请重试");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[autoChainResult, triggerToast],
|
[autoChainResult, lockBusinessChain, triggerToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── 会商自动化串联(模块 C) ──────────────────────────────────
|
// ─── 会商自动化串联(模块 C) ──────────────────────────────────
|
||||||
@@ -1524,7 +1570,7 @@ export default function RoundtablePlanningPage({
|
|||||||
step2.stopOrchestration();
|
step2.stopOrchestration();
|
||||||
// 1. 取消后端作业:避免前后端双跑/抢同一线程(409),线程随后由前端接管续跑。
|
// 1. 取消后端作业:避免前后端双跑/抢同一线程(409),线程随后由前端接管续跑。
|
||||||
// executor.cancel 会 task.cancel() 中断在跑的 run,DB 同步置 cancelled。
|
// executor.cancel 会 task.cancel() 中断在跑的 run,DB 同步置 cancelled。
|
||||||
await cancelJob(jobId, recordTaskIdOf(prefillRef.current)).catch(() => null);
|
await cancelJob(jobId, draftStorageTaskId).catch(() => null);
|
||||||
// 2. 加载作业最近写回草稿的快照:恢复 threadIds(作业真实线程)+ 协调名 + 完整对话。
|
// 2. 加载作业最近写回草稿的快照:恢复 threadIds(作业真实线程)+ 协调名 + 完整对话。
|
||||||
// hydrateFromDraft 已同步写好 threadIds/coordinatorName 的 ref,供下一步接管即时读取。
|
// hydrateFromDraft 已同步写好 threadIds/coordinatorName 的 ref,供下一步接管即时读取。
|
||||||
await drafts.handleLoadDraft(draftId);
|
await drafts.handleLoadDraft(draftId);
|
||||||
@@ -1629,7 +1675,7 @@ export default function RoundtablePlanningPage({
|
|||||||
// 后台挂起发起前**立刻强制存一次**当前聊天(保住发起时刻的本地研讨记录,防丢)。
|
// 后台挂起发起前**立刻强制存一次**当前聊天(保住发起时刻的本地研讨记录,防丢)。
|
||||||
const draftId = await drafts.saveDraftNow(2);
|
const draftId = await drafts.saveDraftNow(2);
|
||||||
// 后台作业按聊天记录存储键归属(xdfx-A=行动id):报告写回对应 task-draft 存储、去重一致。
|
// 后台作业按聊天记录存储键归属(xdfx-A=行动id):报告写回对应 task-draft 存储、去重一致。
|
||||||
const tid = recordTaskIdOf(prefillRef.current);
|
const tid = draftStorageTaskId;
|
||||||
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
|
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
|
||||||
const existing = draftId ? await getJobByDraft(draftId, tid).catch(() => null) : null;
|
const existing = draftId ? await getJobByDraft(draftId, tid).catch(() => null) : null;
|
||||||
const alreadyRunning =
|
const alreadyRunning =
|
||||||
@@ -1701,7 +1747,7 @@ export default function RoundtablePlanningPage({
|
|||||||
const planSnap = mode === "dag" ? orchestrationPlan : null;
|
const planSnap = mode === "dag" ? orchestrationPlan : null;
|
||||||
const draftId = drafts.currentDraftId;
|
const draftId = drafts.currentDraftId;
|
||||||
// 后台作业按聊天记录存储键归属(xdfx-A=行动id),与草稿存储一致。
|
// 后台作业按聊天记录存储键归属(xdfx-A=行动id),与草稿存储一致。
|
||||||
const tid = recordTaskIdOf(prefillRef.current);
|
const tid = draftStorageTaskId;
|
||||||
|
|
||||||
// 立刻:标记「挂起中」+ 跳回第一步 + 提示(不等接口)。
|
// 立刻:标记「挂起中」+ 跳回第一步 + 提示(不等接口)。
|
||||||
if (draftId) markPendingSuspend(draftId);
|
if (draftId) markPendingSuspend(draftId);
|
||||||
@@ -1796,7 +1842,7 @@ export default function RoundtablePlanningPage({
|
|||||||
if (agents.length === 0) return;
|
if (agents.length === 0) return;
|
||||||
startJobKeepalive({
|
startJobKeepalive({
|
||||||
draftId: drafts.currentDraftId ?? undefined,
|
draftId: drafts.currentDraftId ?? undefined,
|
||||||
taskId: recordTaskIdOf(prefillRef.current) ?? undefined,
|
taskId: draftStorageTaskId ?? undefined,
|
||||||
intent: buildInitialIntent(),
|
intent: buildInitialIntent(),
|
||||||
agents,
|
agents,
|
||||||
model: selectedModel || undefined,
|
model: selectedModel || undefined,
|
||||||
@@ -1866,6 +1912,7 @@ export default function RoundtablePlanningPage({
|
|||||||
className={cn(
|
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",
|
"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",
|
isDark && "roundtable-planning--dark",
|
||||||
|
rootClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* 启动加载遮罩:深链/预填自动会话从进入页面到问答真正开始(建意图线程 / 回显历史草稿)
|
{/* 启动加载遮罩:深链/预填自动会话从进入页面到问答真正开始(建意图线程 / 回显历史草稿)
|
||||||
@@ -1936,7 +1983,7 @@ export default function RoundtablePlanningPage({
|
|||||||
{/* 派活链路流程图弹窗(订阅进行中作业的真实 SSE 进度) */}
|
{/* 派活链路流程图弹窗(订阅进行中作业的真实 SSE 进度) */}
|
||||||
<DispatchChainJobModal
|
<DispatchChainJobModal
|
||||||
jobId={chainModalJobId}
|
jobId={chainModalJobId}
|
||||||
taskId={prefillRef.current?.taskId ?? null}
|
taskId={draftStorageTaskId ?? null}
|
||||||
open={chainModalJobId !== null}
|
open={chainModalJobId !== null}
|
||||||
onOpenChange={(o) => {
|
onOpenChange={(o) => {
|
||||||
if (!o) setChainModalJobId(null);
|
if (!o) setChainModalJobId(null);
|
||||||
@@ -1973,6 +2020,7 @@ export default function RoundtablePlanningPage({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{toolbarSlot}
|
||||||
{/* 深链 taskId 模式:查看线上当前策划进度(目的树)。与单智能体闸门同一条接口。 */}
|
{/* 深链 taskId 模式:查看线上当前策划进度(目的树)。与单智能体闸门同一条接口。 */}
|
||||||
{prefillRef.current?.taskId && (
|
{prefillRef.current?.taskId && (
|
||||||
<button
|
<button
|
||||||
@@ -1987,7 +2035,7 @@ export default function RoundtablePlanningPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 深链接(taskId)模式:业务链由 businessCode 自动决定,隐藏手动「业务链条」入口。 */}
|
{/* 深链接(taskId)模式:业务链由 businessCode 自动决定,隐藏手动「业务链条」入口。 */}
|
||||||
{!inWujieEmbed && !prefillRef.current?.taskId && (
|
{!lockBusinessChain && !inWujieEmbed && !prefillRef.current?.taskId && (
|
||||||
<button
|
<button
|
||||||
id="business-chain-btn"
|
id="business-chain-btn"
|
||||||
onClick={() => setIsChainListOpen(true)}
|
onClick={() => setIsChainListOpen(true)}
|
||||||
@@ -2118,7 +2166,10 @@ export default function RoundtablePlanningPage({
|
|||||||
onOpenDraft={drafts.handleLoadDraft}
|
onOpenDraft={drafts.handleLoadDraft}
|
||||||
activeDraftId={drafts.currentDraftId}
|
activeDraftId={drafts.currentDraftId}
|
||||||
entryRailTaskId={recordTaskIdOf(prefillRef.current)}
|
entryRailTaskId={recordTaskIdOf(prefillRef.current)}
|
||||||
|
historyTaskId={draftStorageTaskId}
|
||||||
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
|
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
|
||||||
|
lockedChainId={lockedChainId}
|
||||||
|
lockBusinessChain={lockBusinessChain}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* MIDDLE VIEWPORT */}
|
{/* MIDDLE VIEWPORT */}
|
||||||
@@ -2163,6 +2214,14 @@ export default function RoundtablePlanningPage({
|
|||||||
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
|
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (lockBusinessChain) {
|
||||||
|
triggerToast(
|
||||||
|
autoChainLoading
|
||||||
|
? "正在加载管理员配置的业务链,请稍候…"
|
||||||
|
: "管理员尚未配置可用的业务链,请联系管理员处理",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
inWujieEmbed &&
|
inWujieEmbed &&
|
||||||
autoChainLoading &&
|
autoChainLoading &&
|
||||||
@@ -2662,8 +2721,14 @@ interface LeftSidebarProps {
|
|||||||
activeDraftId: string | null;
|
activeDraftId: string | null;
|
||||||
/** 深链 taskId 模式:Step1 落地左栏「分析记录」只列该任务的草稿。 */
|
/** 深链 taskId 模式:Step1 落地左栏「分析记录」只列该任务的草稿。 */
|
||||||
entryRailTaskId?: string | null;
|
entryRailTaskId?: string | null;
|
||||||
|
/** 独立页面的历史草稿存储键。 */
|
||||||
|
historyTaskId?: string | null;
|
||||||
/** 深链业务码:左上角卡片据此决定显示「当前任务」(rwfx/3qfx) 还是「当前行动」(xdfx)。 */
|
/** 深链业务码:左上角卡片据此决定显示「当前任务」(rwfx/3qfx) 还是「当前行动」(xdfx)。 */
|
||||||
entryRailBusinessCode?: string | null;
|
entryRailBusinessCode?: string | null;
|
||||||
|
/** 独立页面禁止在左栏改选业务链。 */
|
||||||
|
lockBusinessChain?: boolean;
|
||||||
|
/** 独立页面锁定显示的业务链。 */
|
||||||
|
lockedChainId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Step2SandboxSidebarSync({
|
function Step2SandboxSidebarSync({
|
||||||
@@ -2710,7 +2775,10 @@ function LeftSidebar({
|
|||||||
onOpenDraft,
|
onOpenDraft,
|
||||||
activeDraftId,
|
activeDraftId,
|
||||||
entryRailTaskId,
|
entryRailTaskId,
|
||||||
|
historyTaskId,
|
||||||
entryRailBusinessCode,
|
entryRailBusinessCode,
|
||||||
|
lockBusinessChain,
|
||||||
|
lockedChainId,
|
||||||
}: LeftSidebarProps) {
|
}: LeftSidebarProps) {
|
||||||
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
|
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
|
||||||
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
|
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
|
||||||
@@ -2849,7 +2917,10 @@ function LeftSidebar({
|
|||||||
onOpenDraft={onOpenDraft}
|
onOpenDraft={onOpenDraft}
|
||||||
activeDraftId={activeDraftId}
|
activeDraftId={activeDraftId}
|
||||||
taskId={entryRailTaskId ?? null}
|
taskId={entryRailTaskId ?? null}
|
||||||
|
historyTaskId={historyTaskId ?? null}
|
||||||
businessCode={entryRailBusinessCode ?? null}
|
businessCode={entryRailBusinessCode ?? null}
|
||||||
|
lockBusinessChain={lockBusinessChain}
|
||||||
|
lockedChainId={lockedChainId ?? null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -2900,7 +2971,10 @@ function RoundtableEntryRail({
|
|||||||
onOpenDraft,
|
onOpenDraft,
|
||||||
activeDraftId,
|
activeDraftId,
|
||||||
taskId,
|
taskId,
|
||||||
|
historyTaskId,
|
||||||
businessCode,
|
businessCode,
|
||||||
|
lockedChainId,
|
||||||
|
lockBusinessChain = false,
|
||||||
}: {
|
}: {
|
||||||
selectedChainId: string | null;
|
selectedChainId: string | null;
|
||||||
onSelectChain: (chain: BusinessChain) => void;
|
onSelectChain: (chain: BusinessChain) => void;
|
||||||
@@ -2908,13 +2982,18 @@ function RoundtableEntryRail({
|
|||||||
activeDraftId: string | null;
|
activeDraftId: string | null;
|
||||||
/** 深链 taskId 模式:分析记录只列该任务的草稿。 */
|
/** 深链 taskId 模式:分析记录只列该任务的草稿。 */
|
||||||
taskId?: string | null;
|
taskId?: string | null;
|
||||||
|
/** 独立页面的草稿存储键;不改变当前任务卡片/业务链区域的展示语义。 */
|
||||||
|
historyTaskId?: string | null;
|
||||||
/** 深链业务码:xdfx(7BF) 左上角显示「当前行动」,其余显示「当前任务」。 */
|
/** 深链业务码:xdfx(7BF) 左上角显示「当前行动」,其余显示「当前任务」。 */
|
||||||
businessCode?: string | null;
|
businessCode?: string | null;
|
||||||
|
/** 固定业务链时,左侧只展示这条链的流程图及放大入口。 */
|
||||||
|
lockedChainId?: string | null;
|
||||||
|
lockBusinessChain?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||||
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
|
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
|
||||||
const [expandedChainId, setExpandedChainId] = useState<string | null>(null);
|
const [expandedChainId, setExpandedChainId] = useState<string | null>(null);
|
||||||
const { drafts: records, refresh } = useRoundtableDrafts(taskId);
|
const { drafts: records, refresh } = useRoundtableDrafts(historyTaskId ?? taskId);
|
||||||
// 分析记录「按业务链筛选」—— 选项从已加载草稿的 chainTitle 去重得到,
|
// 分析记录「按业务链筛选」—— 选项从已加载草稿的 chainTitle 去重得到,
|
||||||
// 无业务链的草稿(自由 / recommend 模式)归入「自由模式」。
|
// 无业务链的草稿(自由 / recommend 模式)归入「自由模式」。
|
||||||
const [chainFilter, setChainFilter] = useState<string>(RECORD_FILTER_ALL);
|
const [chainFilter, setChainFilter] = useState<string>(RECORD_FILTER_ALL);
|
||||||
@@ -2963,7 +3042,9 @@ function RoundtableEntryRail({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void listChains("public")
|
void listChains("public")
|
||||||
.then((all) => {
|
.then((all) => {
|
||||||
if (!cancelled) setChains(all);
|
if (!cancelled) {
|
||||||
|
setChains(lockBusinessChain ? all.filter((chain) => chain.id === lockedChainId) : all);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* 业务链非关键,静默忽略 */
|
/* 业务链非关键,静默忽略 */
|
||||||
@@ -2972,7 +3053,7 @@ function RoundtableEntryRail({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [refresh, deeplink]);
|
}, [refresh, deeplink, lockedChainId, lockBusinessChain]);
|
||||||
|
|
||||||
const formatTime = (value: string) => {
|
const formatTime = (value: string) => {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ from datetime import datetime
|
|||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/business-mapping", tags=["business-mapping"])
|
router = APIRouter(prefix="/api/business-mapping", tags=["business-mapping"])
|
||||||
|
|
||||||
@@ -62,6 +64,54 @@ def _get_store(request: Request):
|
|||||||
return store
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
def _is_admin(request: Request) -> bool:
|
||||||
|
"""Treat no-auth deployments as the single administrative workspace."""
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
return user is None or getattr(user, "system_role", None) == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def _current_user_id(request: Request) -> str:
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
if user is not None:
|
||||||
|
return str(user.id)
|
||||||
|
return get_effective_user_id()
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_public_chain_mapping(
|
||||||
|
request: Request,
|
||||||
|
fields: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
"""Require globally mapped chains to remain readable by every account."""
|
||||||
|
if "chain_id" not in fields:
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_chain_id = fields["chain_id"]
|
||||||
|
chain_id = raw_chain_id.strip() if isinstance(raw_chain_id, str) else ""
|
||||||
|
if not chain_id:
|
||||||
|
fields["chain_id"] = None
|
||||||
|
fields["chain_title"] = None
|
||||||
|
return
|
||||||
|
|
||||||
|
chain_store = getattr(request.app.state, "roundtable_chain_store", None)
|
||||||
|
if chain_store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Business chain store not available")
|
||||||
|
|
||||||
|
chain = await chain_store.get_chain(
|
||||||
|
chain_id,
|
||||||
|
_current_user_id(request),
|
||||||
|
is_admin=True,
|
||||||
|
)
|
||||||
|
if chain is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Business chain not found")
|
||||||
|
if not chain.get("is_public"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Global business mappings require a public business chain",
|
||||||
|
)
|
||||||
|
fields["chain_id"] = chain_id
|
||||||
|
fields["chain_title"] = str(chain.get("title") or "") or None
|
||||||
|
|
||||||
|
|
||||||
# ── routes ───────────────────────────────────────────────────────────────
|
# ── routes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -78,9 +128,12 @@ async def list_mappings(request: Request) -> BusinessMappingListResponse:
|
|||||||
async def update_mapping(
|
async def update_mapping(
|
||||||
request: Request, business_code: str, body: BusinessMappingUpdateRequest
|
request: Request, business_code: str, body: BusinessMappingUpdateRequest
|
||||||
) -> BusinessMappingResponse:
|
) -> BusinessMappingResponse:
|
||||||
|
if not _is_admin(request):
|
||||||
|
raise HTTPException(status_code=403, detail="Only administrators can update business mappings")
|
||||||
store = _get_store(request)
|
store = _get_store(request)
|
||||||
# Only forward fields the caller explicitly sent (distinguish omitted vs null).
|
# Only forward fields the caller explicitly sent (distinguish omitted vs null).
|
||||||
fields = body.model_dump(exclude_unset=True)
|
fields = body.model_dump(exclude_unset=True)
|
||||||
|
await _validate_public_chain_mapping(request, fields)
|
||||||
row = await store.update_mapping(business_code, **fields)
|
row = await store.update_mapping(business_code, **fields)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="Business mapping not found")
|
raise HTTPException(status_code=404, detail="Business mapping not found")
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Authorization and shared-chain invariants for global business mappings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.gateway.routers import business_mapping as business_mapping_router
|
||||||
|
|
||||||
|
|
||||||
|
class _MappingStore:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, dict[str, object]]] = []
|
||||||
|
|
||||||
|
async def update_mapping(self, business_code: str, **fields: object) -> dict[str, object]:
|
||||||
|
self.calls.append((business_code, fields))
|
||||||
|
return {
|
||||||
|
"business_code": business_code,
|
||||||
|
"label": "岗位多智能体会商",
|
||||||
|
"agent_id": None,
|
||||||
|
"agent_name": None,
|
||||||
|
"chain_id": fields.get("chain_id"),
|
||||||
|
"chain_title": fields.get("chain_title"),
|
||||||
|
"sort_order": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _ChainStore:
|
||||||
|
def __init__(self, chain: dict[str, object] | None) -> None:
|
||||||
|
self.chain = chain
|
||||||
|
|
||||||
|
async def get_chain(self, chain_id: str, user_id: str, *, is_admin: bool) -> dict[str, object] | None:
|
||||||
|
assert chain_id == "chain-8bf"
|
||||||
|
assert user_id == "admin-id"
|
||||||
|
assert is_admin is True
|
||||||
|
return self.chain
|
||||||
|
|
||||||
|
|
||||||
|
def _request(role: str, chain: dict[str, object] | None = None):
|
||||||
|
return SimpleNamespace(
|
||||||
|
app=SimpleNamespace(
|
||||||
|
state=SimpleNamespace(
|
||||||
|
business_mapping_store=_MappingStore(),
|
||||||
|
roundtable_chain_store=_ChainStore(chain),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
state=SimpleNamespace(user=SimpleNamespace(id="admin-id", system_role=role)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_only_administrators_can_update_global_business_mappings() -> None:
|
||||||
|
request = _request("user")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
await business_mapping_router.update_mapping(
|
||||||
|
request,
|
||||||
|
"8BF",
|
||||||
|
business_mapping_router.BusinessMappingUpdateRequest(chain_id="chain-8bf"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert error.value.status_code == 403
|
||||||
|
assert request.app.state.business_mapping_store.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_global_mapping_requires_a_public_chain_and_canonicalizes_its_title() -> None:
|
||||||
|
request = _request("admin", {"id": "chain-8bf", "title": "岗位协同链", "is_public": True})
|
||||||
|
|
||||||
|
response = await business_mapping_router.update_mapping(
|
||||||
|
request,
|
||||||
|
"8BF",
|
||||||
|
business_mapping_router.BusinessMappingUpdateRequest(
|
||||||
|
chain_id="chain-8bf",
|
||||||
|
chain_title="客户端伪造的标题",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.chain_id == "chain-8bf"
|
||||||
|
assert response.chain_title == "岗位协同链"
|
||||||
|
assert request.app.state.business_mapping_store.calls == [
|
||||||
|
("8BF", {"chain_id": "chain-8bf", "chain_title": "岗位协同链"})
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_global_mapping_rejects_a_private_chain() -> None:
|
||||||
|
request = _request("admin", {"id": "chain-8bf", "title": "私有链", "is_public": False})
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
await business_mapping_router.update_mapping(
|
||||||
|
request,
|
||||||
|
"8BF",
|
||||||
|
business_mapping_router.BusinessMappingUpdateRequest(chain_id="chain-8bf"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert error.value.status_code == 400
|
||||||
|
assert request.app.state.business_mapping_store.calls == []
|
||||||
Reference in new issue
Block a user