feat: 优化8BF业务链流程图与深色配置弹窗

This commit is contained in:
董宏凯 committed 2026-07-27 21:30:57 +08:00
1 parent 1e21e559fb
commit 1555d5344b
5 files changed
+395 -13

No files matched your search

@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Check, Loader2, Save, Settings2, X } from "lucide-react";
import { Loader2, Save, Settings2, X } from "lucide-react";
import { getStoredAuth } from "@/core/auth";
import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import { listChains } from "@/roundtable-planning/api/chains";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
@@ -34,6 +35,10 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
const [selectedChainId, setSelectedChainId] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const configuredChain = useMemo(
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
[chains, mapping?.chainId],
);
const loadConfiguration = useCallback(async () => {
setIsLoading(true);
@@ -61,6 +66,10 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
}, [loadConfiguration]);
const handleSave = useCallback(async () => {
if (!isAdmin) {
setError("仅管理员可以配置 8BF 业务链条");
return;
}
const selected = chains.find((chain) => chain.id === selectedChainId);
if (!selected) {
setError("请选择一条已发布的业务链");
@@ -81,16 +90,10 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
} finally {
setIsSaving(false);
}
}, [chains, selectedChainId]);
}, [chains, isAdmin, 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"
@@ -101,7 +104,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
配置业务链
</button>
)}
{isSettingsOpen && (
{isAdmin && isSettingsOpen && (
<div className="eightbf-roundtable__settings-mask" role="presentation">
<section
className="eightbf-roundtable__settings-dialog"
@@ -137,7 +140,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
))}
</select>
</label>
{error && <p className="eightbf-roundtable__settings-error">{error}</p>}
{error && <p className="eightbf-roundtable__settings-error" role="alert">{error}</p>}
<div className="eightbf-roundtable__settings-actions">
<button type="button" onClick={() => setIsSettingsOpen(false)}>取消</button>
<button type="button" onClick={() => void handleSave()} disabled={isSaving}>
@@ -148,7 +151,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
</section>
</div>
)}
{!isLoading && mapping?.chainId && <Check className="eightbf-roundtable__configured-icon h-4 w-4" aria-label="已配置" />}
</>
);
@@ -159,6 +161,13 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
lockBusinessChain
storageTaskId={storageTaskId}
toolbarSlot={toolbarSlot}
entryRailBusinessChainSlot={
<PositionBusinessChainFlow
chain={configuredChain}
loading={isLoading}
className="eightbf-roundtable__business-chain-flow"
/>
}
rootClassName="eightbf-roundtable"
/>
);
@@ -21,4 +21,10 @@
.eightbf-roundtable .eightbf-roundtable__chain-status-value { color: #134e4a; font-weight: 600; }
.eightbf-roundtable .eightbf-roundtable__configured-icon { color: var(--eightbf-accent); }
/* 8BF 左上业务链区域:复用岗位会商的纵向节点、点阵画布与放大查看交互。 */
.eightbf-roundtable .eightbf-roundtable__business-chain-flow {
height: 500px;
min-height: 460px;
}
/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */
@@ -31,4 +31,49 @@
.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; }
/* 管理员业务链配置弹窗:深色主题下与会商工作区使用同一套深蓝表面与高对比文字。 */
.dark .eightbf-roundtable .eightbf-roundtable__settings-mask {
background: rgb(2 12 30 / 72%);
backdrop-filter: blur(3px);
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-dialog {
border: 1px solid rgb(69 195 243 / 42%);
background: #082b57;
box-shadow: 0 24px 56px rgb(0 0 0 / 45%);
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-kicker { color: #65d6c6; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-heading h2 { color: #f1f7ff; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-close { color: #a9bddb; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-close:hover { color: #fff; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-tip { color: #b9c9df; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-field { color: #e6effc; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-field select {
border-color: rgb(143 196 255 / 48%);
background: #0d3768;
color: #f1f7ff;
color-scheme: dark;
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-field select:focus {
border-color: #65d6c6;
outline: 2px solid rgb(101 214 198 / 28%);
outline-offset: 1px;
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-field option { background: #0d3768; color: #f1f7ff; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-error { color: #fca5a5; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-actions button:first-child {
color: #b9c9df;
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-actions button:first-child:hover {
background: rgb(143 196 255 / 12%);
color: #fff;
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-actions button:last-child {
background: #167d74;
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-actions button:last-child:hover {
background: #21958a;
}
/* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */
@@ -0,0 +1,305 @@
/**
* 岗位会商风格的只读业务链流程图。
*
* 页面无关:8BF 等独立会商页可以直接复用岗位会商的纵向流程图视觉与交互,
* 不会读取或写入岗位会商的会话、岗位节点状态。
*/
import { useRef, useState } from "react";
import { CheckCircle2, Circle, Search } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import { cn } from "@/lib/utils";
const NODE_COLORS = ["#4f7df3", "#a778f5", "#65b4e8", "#f0a245", "#e675a8"];
const FLOW_NODE_WIDTH = 184;
const FLOW_NODE_GAP = 12;
type FlowStage = {
goal: string | null;
seats: ChainSeat[];
};
export interface PositionBusinessChainFlowProps {
chain: BusinessChain | null;
loading?: boolean;
className?: string;
}
function getFlowStages(chain: BusinessChain): FlowStage[] {
const seatsById = new Map(chain.seats.map((seat) => [seat.agent_id, seat]));
if (chain.stages?.length) {
return chain.stages
.map((ids, index) => ({
goal: chain.stageGoals?.[index] ?? null,
seats: ids
.map((id) => seatsById.get(id))
.filter((seat): seat is ChainSeat => Boolean(seat)),
}))
.filter((stage) => stage.seats.length > 0);
}
return chain.seats.map((seat) => ({ goal: null, seats: [seat] }));
}
export function PositionBusinessChainFlow({
chain,
loading = false,
className,
}: PositionBusinessChainFlowProps) {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<section
className={cn(
"position-business-chain-flow flex h-full min-h-[460px] w-full flex-col overflow-hidden rounded-2xl border border-[#ece7e4] bg-white p-3 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/55 dark:shadow-none",
className,
)}
>
<div className="mb-3 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-3 dark:border-[#2b8ffe]/40">
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167deg,#a2e1f9_0%,#45c3f3_100%)]"
/>
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-slate-900 dark:text-white">业务链条</h2>
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
</p>
</div>
</div>
{chain && (
<button
type="button"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-[#f0c8c8] bg-[#fff7f7] text-[#cc1925] transition-colors hover:border-[#cc1925] hover:bg-[#fff0f0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cc1925]/30 dark:border-[#2b8ffe]/45 dark:bg-[#082d5f] dark:text-[#8fc4ff]"
title="查看完整流程图"
aria-label="查看完整流程图"
onClick={() => setDialogOpen(true)}
>
<Search className="size-[18px]" strokeWidth={2.5} />
</button>
)}
</div>
{!chain ? (
<div className="flex min-h-0 flex-1 items-center justify-center rounded-lg border border-dashed border-[#e8d0cc] px-5 text-center text-xs leading-6 text-slate-400 dark:border-[#2b8ffe]/30 dark:text-[#8aa0c4]">
{loading ? "正在加载业务链条…" : "管理员尚未配置 8BF 业务链条"}
</div>
) : (
<>
<BusinessChainCanvas chain={chain} className="min-h-0 flex-1" />
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="flex h-[86vh] w-[96vw] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
<DialogTitle className="text-base font-semibold">{chain.title} · 业务链条流程图</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 p-4">
<BusinessChainCanvas chain={chain} expanded className="h-full" />
</div>
</DialogContent>
</Dialog>
</>
)}
</section>
);
}
function BusinessChainCanvas({
chain,
expanded = false,
className,
}: {
chain: BusinessChain;
expanded?: boolean;
className?: string;
}) {
const [view, setView] = useState({ tx: 0, ty: 0 });
const [panning, setPanning] = useState(false);
const dragRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
const stages = getFlowStages(chain);
const nodeWidth = expanded ? 250 : FLOW_NODE_WIDTH;
const nodeGap = expanded ? 18 : FLOW_NODE_GAP;
let colorIndex = 0;
const endPan = (event: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
dragRef.current = null;
setPanning(false);
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// The browser may have already released the pointer capture.
}
};
return (
<div
className={cn(
"relative overflow-hidden rounded-lg border border-slate-100 bg-slate-50/70 touch-none dark:border-[#2b8ffe]/25 dark:bg-[#032650]/75",
panning ? "cursor-grabbing" : "cursor-grab",
className,
)}
style={{
backgroundImage: "radial-gradient(rgba(148,163,184,.55) 1px, transparent 1px)",
backgroundSize: "16px 16px",
}}
title="按住鼠标拖动画布;双击复位"
onDoubleClick={() => setView({ tx: 0, ty: 0 })}
onPointerDown={(event) => {
if (event.button !== 0) return;
dragRef.current = { x: event.clientX, y: event.clientY, tx: view.tx, ty: view.ty };
setPanning(true);
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
const drag = dragRef.current;
if (!drag) return;
setView({ tx: drag.tx + event.clientX - drag.x, ty: drag.ty + event.clientY - drag.y });
}}
onPointerUp={endPan}
onPointerCancel={endPan}
>
<div
className="flex min-h-full min-w-max flex-col items-center px-5 py-6"
style={{ transform: `translate(${view.tx}px, ${view.ty}px)` }}
>
<FlowNode
title="任务理解智能体"
description="任务理解岗"
accent={NODE_COLORS[0]}
completed
width={nodeWidth}
/>
{stages.map((stage, stageIndex) => {
const stageWidth = Math.max(
nodeWidth,
stage.seats.length * (nodeWidth + nodeGap) - nodeGap,
);
return (
<div key={`${stage.goal ?? "stage"}-${stageIndex}`} className="flex flex-col items-center">
<FlowConnector
branches={stage.seats.length}
width={stageWidth}
nodeWidth={nodeWidth}
nodeGap={nodeGap}
/>
<div className="flex items-start justify-center" style={{ gap: nodeGap }}>
{stage.seats.map((seat) => {
const accent = NODE_COLORS[(colorIndex++ + 1) % NODE_COLORS.length]!;
return (
<FlowNode
key={seat.agent_id}
title={seat.name || seat.agent_id}
description={stage.goal?.trim() || seat.description?.trim() || "业务链席位"}
accent={accent}
width={nodeWidth}
/>
);
})}
</div>
</div>
);
})}
<FlowConnector branches={1} width={nodeWidth} nodeWidth={nodeWidth} nodeGap={nodeGap} />
<FlowNode
title="方案总结智能体"
description="内置总结节点"
accent="#8b83f5"
width={nodeWidth}
/>
<FlowConnector branches={1} width={nodeWidth} nodeWidth={nodeWidth} nodeGap={nodeGap} />
<FlowNode
title="行动规划智能体"
description="内置规划节点"
accent="#78bce6"
width={nodeWidth}
/>
</div>
</div>
);
}
function FlowConnector({
branches,
width,
nodeWidth,
nodeGap,
}: {
branches: number;
width: number;
nodeWidth: number;
nodeGap: number;
}) {
const connectorStyle = { backgroundColor: "rgba(148,163,184,.88)" };
if (branches <= 1) {
return (
<span className="relative mb-1 h-8 w-0.5 rounded-full" style={connectorStyle}>
<span className="absolute bottom-1 left-1/2 size-2 -translate-x-1/2 rotate-45 border-b-2 border-r-2 border-slate-400" />
</span>
);
}
const nodeCenter = nodeWidth / 2;
return (
<div className="relative mb-1 h-9" style={{ width }}>
<span className="absolute left-1/2 top-0 h-3.5 w-0.5 -translate-x-1/2 rounded-full" style={connectorStyle} />
<span className="absolute top-3.5 h-0.5 rounded-full" style={{ left: nodeCenter, right: nodeCenter, ...connectorStyle }} />
{Array.from({ length: branches }).map((_, index) => (
<span
key={index}
className="absolute top-3.5 bottom-0 w-0.5 -translate-x-1/2 rounded-full"
style={{
left: `${((index * (nodeWidth + nodeGap) + nodeCenter) / width) * 100}%`,
...connectorStyle,
}}
>
<span className="absolute bottom-1 left-1/2 size-2 -translate-x-1/2 rotate-45 border-b-2 border-r-2 border-slate-400" />
</span>
))}
</div>
);
}
function FlowNode({
title,
description,
accent,
completed = false,
width,
}: {
title: string;
description: string;
accent: string;
completed?: boolean;
width: number;
}) {
const StatusIcon = completed ? CheckCircle2 : Circle;
return (
<article
className="relative overflow-hidden rounded-xl border border-slate-200 bg-white p-3 pr-10 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]"
style={{ width, borderLeftWidth: 5, borderLeftColor: accent }}
>
<span
className={cn(
"absolute right-2 top-2 flex size-6 items-center justify-center rounded-full border bg-white shadow-sm dark:bg-[#0d3768]",
completed
? "border-emerald-500 text-emerald-500"
: "border-slate-300 text-slate-400 dark:border-[#8aa0c4] dark:text-[#8aa0c4]",
)}
>
<StatusIcon className="size-3.5" strokeWidth={2.5} />
</span>
<p className="truncate text-sm font-extrabold leading-5 text-slate-900 dark:text-[#e8edf5]" title={title}>
{title}
</p>
<p className="mt-0.5 truncate text-xs font-semibold leading-4" style={{ color: accent }} title={description}>
{description}
</p>
</article>
);
}
@@ -296,6 +296,8 @@ export default function RoundtablePlanningPage({
storageTaskId,
/** 供独立业务页在原页头部插入自己的操作入口。 */
toolbarSlot,
/** 独立业务页在第一步左上角替换默认业务链选择区的内容。 */
entryRailBusinessChainSlot,
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
rootClassName,
}: {
@@ -304,6 +306,7 @@ export default function RoundtablePlanningPage({
lockBusinessChain?: boolean;
storageTaskId?: string | null;
toolbarSlot?: ReactNode;
entryRailBusinessChainSlot?: ReactNode;
rootClassName?: string;
}) {
const { theme } = useTheme();
@@ -2170,6 +2173,7 @@ export default function RoundtablePlanningPage({
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
lockedChainId={lockedChainId}
lockBusinessChain={lockBusinessChain}
entryRailBusinessChainSlot={entryRailBusinessChainSlot}
/>
{/* MIDDLE VIEWPORT */}
@@ -2729,6 +2733,8 @@ interface LeftSidebarProps {
lockBusinessChain?: boolean;
/** 独立页面锁定显示的业务链。 */
lockedChainId?: string | null;
/** 第一阶段替换默认业务链选择区的自定义内容。 */
entryRailBusinessChainSlot?: ReactNode;
}
function Step2SandboxSidebarSync({
@@ -2779,6 +2785,7 @@ function LeftSidebar({
entryRailBusinessCode,
lockBusinessChain,
lockedChainId,
entryRailBusinessChainSlot,
}: LeftSidebarProps) {
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
@@ -2921,6 +2928,7 @@ function LeftSidebar({
businessCode={entryRailBusinessCode ?? null}
lockBusinessChain={lockBusinessChain}
lockedChainId={lockedChainId ?? null}
businessChainSlot={entryRailBusinessChainSlot}
/>
)}
</section>
@@ -2975,6 +2983,7 @@ function RoundtableEntryRail({
businessCode,
lockedChainId,
lockBusinessChain = false,
businessChainSlot,
}: {
selectedChainId: string | null;
onSelectChain: (chain: BusinessChain) => void;
@@ -2989,6 +2998,7 @@ function RoundtableEntryRail({
/** 固定业务链时,左侧只展示这条链的流程图及放大入口。 */
lockedChainId?: string | null;
lockBusinessChain?: boolean;
businessChainSlot?: ReactNode;
}) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
@@ -3035,7 +3045,7 @@ function RoundtableEntryRail({
useEffect(() => {
// 深链接模式不展示业务链选择 → 不必拉公共链条。
if (deeplink) {
if (deeplink || lockBusinessChain) {
void refresh();
return;
}
@@ -3068,7 +3078,12 @@ function RoundtableEntryRail({
};
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div
className={cn(
"flex min-h-0 flex-1 flex-col gap-3",
lockBusinessChain && "overflow-y-auto pr-1 custom-scrollbar",
)}
>
{/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */}
{deeplink ? (
isXdfx ? (
@@ -3110,6 +3125,8 @@ function RoundtableEntryRail({
</div>
</div>
)
) : lockBusinessChain && businessChainSlot ? (
businessChainSlot
) : (
/* 业务链(公共列表,内部滚动) */
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] transition-shadow duration-300 hover:shadow-[0_4px_24px_rgba(45,37,34,0.04)]">