Files
deerflow-code/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx
T
董宏凯andClaude Opus 4.8 45049975c2 feat(roundtable): 多智能体会商 + 业务链条页脱敏
- 区域级脱敏 Hook useDesensitizeSubtree:对左右侧面板、顶部标题、
  业务链条选择器/列表弹窗/列表页/配置页/映射页整片脱敏(文本节点,
  跳过 input/textarea 等可编辑控件)
- 消息列表正文保持原文,仅智能体名称脱敏(senderName / 席位名 / 流程图节点)
- Step3 总结/报告/大屏、推荐弹窗等结果面板脱敏
- 沙箱产物预览不脱敏:ArtifactFileDetail 加 disableDesensitize,会商沙箱传入
- desensitize.ts 新增 desensitizeDeep 递归脱敏结构化数据

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:43:29 +08:00

398 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 业务链条列表弹窗(圆桌页顶栏「业务链条」按钮入口)。
*
* 点击「业务链条」按钮后打开本弹窗:展示当前用户已配置的业务链条卡片列表。
* - 每张卡片右侧「设置」按钮 → 进入配置页并**切换到该链条**(带 `chainId`)。
* - footer「配置」按钮 → 进入配置页(空白新建,等价于旧按钮的直接跳转)。
*
* 纯导航 / 浏览用途,不参与研讨席位选择(席位选择走 `BusinessChainPicker`)。
* 卡片席位名优先用实时 agent 数据(`listAgents()`,与 `BusinessChainPicker` 一致),
* agent 改名后此弹窗也能同步显示;解析不到才回退到落库快照(`seat.name`)。
*/
import { Fragment, useCallback, useEffect, useState } from "react";
import {
ArrowRightIcon,
GlobeIcon,
LayersIcon,
Loader2Icon,
SettingsIcon,
UserIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { listAgents } from "@/core/agents";
import { listChains, setChainPublic, type ChainScope } from "../api/chains";
import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
export interface BusinessChainListDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 进入配置页。带 chainId = 打开并切到该链条;不带 = 空白新建。 */
onConfigure: (chainId?: string) => void;
}
export interface BusinessChainListBodyProps {
/**
* 是否激活(控制何时拉取列表):弹窗里传 `open`(打开才拉),
* 独立页面里恒为 `true`。
*/
active: boolean;
/** 进入配置页。带 chainId = 打开并切到该链条;不带 = 空白新建。 */
onConfigure: (chainId?: string) => void;
/** 列表为空时的「去创建」引导文案差异(弹窗说「点下方配置」,页面说「点右上配置」)。 */
emptyHint?: string;
}
/**
* 业务链条列表主体(筛选 tab + 滚动卡片列表)。
*
* 从 `BusinessChainListDialog` 抽出,供弹窗与独立配置页(`BusinessChainListPage`)
* 复用,保证两处的加载 / 发布 / 卡片渲染逻辑完全一致。外层负责标题栏与「配置」入口、
* 以及撑出 `flex flex-col` 高度(本组件用 `flex-1` 占满剩余空间)。
*/
export function BusinessChainListBody({
active,
onConfigure,
emptyHint,
}: BusinessChainListBodyProps) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 筛选:我的 / 公共(已发布)。
const [scope, setScope] = useState<ChainScope>("mine");
const [publishingId, setPublishingId] = useState<string | null>(null);
// 实时名称表(id 小写 → 最新 name):用于把席位的落库快照名刷新成最新(agent 改名同步)。
const [nameById, setNameById] = useState<Map<string, string>>(new Map());
useEffect(() => {
if (!active) return;
let cancelled = false;
void (async () => {
try {
const agents = await listAgents();
if (cancelled) return;
const m = new Map<string, string>();
for (const a of agents) if (a?.id) m.set(a.id.toLowerCase(), a.name ?? a.id);
setNameById(m);
} catch {
// 静默失败:解析不到就回退到席位快照名。
}
})();
return () => {
cancelled = true;
};
}, [active]);
const refresh = useCallback(async (s: ChainScope) => {
setLoading(true);
setError(null);
try {
const list = await listChains(s);
setChains(list);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
// 每次激活 / 切换筛选都重新拉,保证配置页改动后回来看到的是最新列表。
useEffect(() => {
if (!active) return;
let cancelled = false;
void (async () => {
setLoading(true);
setError(null);
try {
const list = await listChains(scope);
if (!cancelled) setChains(list);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [active, scope]);
const handleTogglePublic = useCallback(
async (chain: BusinessChain) => {
setPublishingId(chain.id);
try {
await setChainPublic(chain.id, !chain.isPublic);
await refresh(scope);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPublishingId(null);
}
},
[refresh, scope],
);
return (
<>
{/* 筛选:我的 / 公共 */}
<div className="shrink-0 flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
{(["mine", "public"] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition",
scope === s
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{s === "mine" ? "我的" : "公共"}
</button>
))}
</div>
<div className="custom-scrollbar -mr-1 min-h-0 flex-1 overflow-y-auto py-3 pr-1">
{loading ? (
<div className="flex items-center gap-2 px-1 py-10 text-sm text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
正在加载业务链条…
</div>
) : error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
加载业务链条失败:{error}
</div>
) : chains.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<LayersIcon className="size-10 text-primary/40" />
<div className="text-sm font-semibold">
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
</div>
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
{scope === "public"
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的」里发布一条试试。"
: emptyHint ??
"业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。"}
</p>
</div>
) : (
<div className="space-y-3.5">
{chains.map((chain) => (
<ChainRow
key={chain.id}
chain={chain}
nameById={nameById}
publishing={publishingId === chain.id}
onSettings={() => onConfigure(chain.id)}
onTogglePublic={() => handleTogglePublic(chain)}
/>
))}
</div>
)}
</div>
</>
);
}
export function BusinessChainListDialog({
open,
onOpenChange,
onConfigure,
}: BusinessChainListDialogProps) {
// 整片脱敏:弹窗内链条列表(链条名/描述/席位名/标题)。
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent ref={dzRef} className="flex max-h-[85vh] flex-col gap-0 sm:max-w-2xl">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<LayersIcon className="size-5 text-primary" />
业务链条
</DialogTitle>
<DialogDescription>
可复用的有序智能体编排。点击卡片「设置」可进入配置页编辑该链条;「发布」后所有账号都能在「公共」里查看 / 选用。
</DialogDescription>
</DialogHeader>
<BusinessChainListBody active={open} onConfigure={onConfigure} />
<DialogFooter className="shrink-0 border-t pt-3 sm:justify-end">
<Button onClick={() => onConfigure()} className="gap-1.5">
<SettingsIcon className="size-4" />
配置
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── 单张链条行卡片 ─────────────────────────────────────────────────────────
function ChainRow({
chain,
nameById,
publishing,
onSettings,
onTogglePublic,
}: {
chain: BusinessChain;
nameById: Map<string, string>;
publishing: boolean;
onSettings: () => void;
onTogglePublic: () => void;
}) {
const isMine = chain.isMine !== false;
// 配置者用户名:后端返回 email,展示时取 @ 前的本地部分更像「用户名」。
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "";
return (
<div className="rounded-2xl border border-border bg-card p-5 transition-colors hover:border-primary/40">
{/* 标题行 + 公共徽标 + 发布/设置按钮 */}
<div className="flex items-center gap-2.5">
<div className="truncate text-base font-semibold">{chain.title || "未命名链条"}</div>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
{chain.seats.length} 席
</span>
{chain.isPublic && (
<span className="shrink-0 inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<GlobeIcon className="size-3" />
公共
</span>
)}
<div className="ml-auto flex shrink-0 items-center gap-1.5">
{/* 发布 / 取消发布:仅本人的链条可操作。 */}
{isMine && (
<Button
variant={chain.isPublic ? "secondary" : "outline"}
size="sm"
onClick={onTogglePublic}
disabled={publishing}
className="h-8 gap-1.5 px-3 text-sm"
>
{publishing ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<GlobeIcon className="size-4" />
)}
{chain.isPublic ? "取消发布" : "发布"}
</Button>
)}
{/* 设置(编辑):仅本人的链条;公共里别人的链条只读浏览。 */}
{isMine && (
<Button
variant="ghost"
size="sm"
onClick={onSettings}
className="h-8 gap-1.5 px-3 text-sm"
>
<SettingsIcon className="size-4" />
设置
</Button>
)}
</div>
</div>
{ownerName && (
<div
className="mt-1.5 flex items-center gap-1 text-xs text-muted-foreground"
title={chain.ownerName}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">配置人:{ownerName}</span>
</div>
)}
{chain.description && (
<div className="mt-1.5 line-clamp-2 text-sm text-muted-foreground">{chain.description}</div>
)}
{/* 席位编排:按 stage 分组——并行阶段竖向并列在虚线框里,阶段之间用 → 串联。 */}
<ChainSeatsFlow chain={chain} nameById={nameById} />
</div>
);
}
// ── 链条席位编排流(体现「同层并行 · 层间串行」)─────────────────────────────
function ChainSeatsFlow({
chain,
nameById,
}: {
chain: BusinessChain;
nameById: Map<string, string>;
}) {
// 名称回退表:用落库快照(seats)解析 stage 里的 agent_id。
const seatById = new Map(chain.seats.map((s) => [s.agent_id, s] as const));
// 分层来源:带 stages(分层编排)就按 stages 渲染(过滤空阶段);否则每个席位各成
// 一段、顺序串行(线性链,与旧版展示一致)。
const stages =
chain.stages && chain.stages.length > 0
? chain.stages.filter((s) => s.length > 0)
: chain.seats.map((s) => [s.agent_id]);
// 跨阶段累加的全局序号 —— 决定头像配色,与编辑器 / 展平 seats 顺序对齐。
const orderById = new Map<string, number>();
let counter = 0;
for (const stageIds of stages) {
for (const id of stageIds) {
orderById.set(id, counter);
counter += 1;
}
}
const seatTag = (agentId: string) => {
const seat = seatById.get(agentId);
// 优先实时名称(agent 改名同步)→ 落库快照 → agent_id。
const label = nameById.get(agentId.toLowerCase()) ?? seat?.name ?? agentId;
return (
<span
key={agentId}
className={cn(
"rounded-md border px-2 py-1 text-xs font-medium",
chainTagClasses(getAvatarTypeFor(agentId, orderById.get(agentId) ?? 0)),
)}
>
{label}
</span>
);
};
return (
<div className="mt-3.5 flex flex-wrap items-center gap-1.5">
{stages.map((stageIds, stageIdx) => (
<Fragment key={stageIdx}>
{stageIdx > 0 && (
<ArrowRightIcon className="size-3.5 shrink-0 text-muted-foreground/50" />
)}
{stageIds.length > 1 ? (
// 并行阶段:席位竖向并列在虚线框里,左上角标「并行 N」。
<div className="relative flex flex-col gap-1 rounded-lg border border-dashed border-primary/40 bg-primary/[0.04] px-1.5 pb-1.5 pt-2.5">
<span className="pointer-events-none absolute -top-2 left-1.5 rounded-full bg-card px-1.5 text-[10px] font-semibold leading-tight text-primary">
并行 {stageIds.length}
</span>
{stageIds.map((id) => seatTag(id))}
</div>
) : (
seatTag(stageIds[0])
)}
</Fragment>
))}
</div>
);
}