diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainListPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainListPage.tsx index 0929ce46..2a7e1882 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainListPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainListPage.tsx @@ -1,64 +1,1032 @@ /** - * 业务链条配置页(独立页面版)。 + * 业务链条配置页(独立页面版 · 设计稿还原)。 * - * 把原本挂在圆桌页顶栏「业务链条」按钮上的 `BusinessChainListDialog` 弹窗内容, - * 单独画成一个全屏页面:综合管理 → 业务链条配置 入口进来即是本页。 + * 综合管理 → 业务链条配置 入口进来即是本页。照「业务链条配置中心」设计稿还原: + * 顶部 4 张统计卡 → 筛选工具栏(我的 / 公共 + 搜索 + 排序 + 网格 / 列表切换)→ + * 富链条卡(含可拖移的流程脉络图 + 操作按钮)。 * - * - 列表主体复用 `BusinessChainListBody`(与弹窗同一份加载 / 发布 / 卡片逻辑)。 - * - 顶部「配置」按钮 = 进入编辑器空白新建;卡片「设置」= 进入编辑器并切到该链条。 + * 数据全部来自后端 `/api/roundtable-chains`(`api/chains.ts`),按登录用户隔离: + * - 「设置」→ 进编辑器并切到该链条(带 chainId);「新建链条」→ 进编辑器空白新建。 + * - 「发布 / 取消发布」→ `setChainPublic`;「删除」→ `deleteChain`(确认弹窗 + toast)。 + * - 设计稿里后端没有的「选用人气 / 标签」已按真实数据适配(统计卡第 4 张改为「未发布 + * 草稿」,卡片不再渲染人气 / 标签空壳)。 * - * 路由 base 感知:用 `useRouteNavigate` 跳转,strategy / workspace 两套路由树下 - * 都能落到当前 base 的编辑器(`roundtable/chains`)。 + * 配色: + * - 品牌主操作色(按钮 / 我的·公共切换激活态)统一用 `#CF1322`(hover `#A8071A`)。 + * - 深蓝(dark)模式按《深蓝色 UI 规范》取色:页面 `#0C1836`、卡片 `#122347`、 + * 边框 `#1A2F5A`、正文 `#C5CFE0`、次级 `#6E88B0`、禁用 `#4A6491`、分隔 `#2D4473`。 + * + * 统计卡反映**「我的链条」全局汇总**(独立拉 scope=mine),不随当前 tab 切换而变。 + * + * 圆桌顶栏的「业务链条」弹窗(`BusinessChainListDialog` / `BusinessChainListBody`) + * 仍走旧实现,本页不复用它,避免互相牵连。 */ -import { LayersIcon, SettingsIcon } from "lucide-react"; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + ArrowRightIcon, + CalendarIcon, + ChevronDownIcon, + FilePenLineIcon, + GlobeIcon, + LayersIcon, + LayoutGridIcon, + ListIcon, + Loader2Icon, + PlusIcon, + SearchIcon, + SendIcon, + SettingsIcon, + Trash2Icon, + UserIcon, + UsersIcon, + XIcon, +} from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { useRouteNavigate } from "@/core/route-base"; +import { cn } from "@/lib/utils"; -import { BusinessChainListBody } from "@/roundtable-planning/components/BusinessChainListDialog"; +import { deleteChain, listChains, setChainPublic, type ChainScope } from "../api/chains"; +import { chainTagClasses, type BusinessChain } from "../lib/business-chain"; +import { getAvatarTypeFor } from "../lib/roundtable-constants"; + +type SortBy = "updated" | "seats" | "name"; + +/** + * 品牌主操作色(实心按钮 / 激活态)。 + * - 浅色:深红 `#CF1322`(hover `#A8071A`)。 + * - 深蓝:按规范改用主色蓝 `#1677FF`(hover `#0958D9`,§4.2.1 主按钮)。 + */ +const BRAND = + "bg-[#CF1322] hover:bg-[#A8071A] text-white dark:bg-[#1677FF] dark:hover:bg-[#0958D9]"; export default function BusinessChainListPage() { const navigate = useRouteNavigate(); // 进入编辑器:带 chainId = 打开并切到该链条;不带 = 空白新建。 // 路径写 workspace base,`useRouteNavigate` 会按当前路由 base 改写。 - const goConfigure = (chainId?: string) => { - navigate( - chainId - ? `/page/workspace/roundtable/chains?chainId=${encodeURIComponent(chainId)}` - : "/page/workspace/roundtable/chains", - ); - }; + const goConfigure = useCallback( + (chainId?: string) => { + navigate( + chainId + ? `/page/workspace/roundtable/chains?chainId=${encodeURIComponent(chainId)}` + : "/page/workspace/roundtable/chains", + ); + }, + [navigate], + ); + + const [chains, setChains] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + // 统计专用:恒为「我的链条」,与当前 tab 解耦,避免数字随筛选跳动。 + const [statChains, setStatChains] = useState([]); + + const [scope, setScope] = useState("mine"); + const [search, setSearch] = useState(""); + const [sortBy, setSortBy] = useState("updated"); + const [layout, setLayout] = useState<"grid" | "list">("grid"); + + const [publishingId, setPublishingId] = useState(null); + const [deletingId, setDeletingId] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); + + 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); + } + }, []); + + const loadStats = useCallback(async () => { + try { + setStatChains(await listChains("mine")); + } catch { + // 统计失败不阻塞主列表,静默退化(保持上次数值)。 + } + }, []); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + void (async () => { + try { + const list = await listChains(scope); + if (!cancelled) setChains(list); + // scope=mine 时主列表即统计源,顺带刷新统计省一次请求。 + if (!cancelled && scope === "mine") setStatChains(list); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [scope]); + + // 首挂载时若直接停在 public,也要把统计源拉起来。 + useEffect(() => { + if (scope !== "mine") void loadStats(); + // 仅首挂载执行一次。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ── 操作:发布 / 取消发布 ──────────────────────────────────────────────── + const handleTogglePublic = useCallback( + async (chain: BusinessChain) => { + setPublishingId(chain.id); + try { + await setChainPublic(chain.id, !chain.isPublic); + await refresh(scope); + await loadStats(); + toast.success( + chain.isPublic + ? `已取消发布「${chain.title || "未命名链条"}」` + : `🌍「${chain.title || "未命名链条"}」已发布到公共选用库`, + ); + } catch (err) { + toast.error(`操作失败:${err instanceof Error ? err.message : String(err)}`); + } finally { + setPublishingId(null); + } + }, + [refresh, scope, loadStats], + ); + + // ── 操作:删除(确认弹窗)───────────────────────────────────────────────── + const confirmDelete = useCallback(async () => { + const chain = pendingDelete; + if (!chain) return; + const name = chain.title || "未命名链条"; + setDeletingId(chain.id); + try { + await deleteChain(chain.id); + await refresh(scope); + await loadStats(); + toast.success(`🗑️ 已删除链条「${name}」`); + setPendingDelete(null); + } catch (err) { + toast.error(`删除失败:${err instanceof Error ? err.message : String(err)}`); + } finally { + setDeletingId(null); + } + }, [pendingDelete, refresh, scope, loadStats]); + + // ── 统计(基于「我的链条」全局)────────────────────────────────────────── + const stats = useMemo(() => { + const totalChains = statChains.length; + const totalSeats = statChains.reduce((acc, c) => acc + c.seats.length, 0); + const publicCount = statChains.filter((c) => c.isPublic).length; + const draftCount = statChains.filter((c) => !c.isPublic).length; + return { totalChains, totalSeats, publicCount, draftCount }; + }, [statChains]); + + // ── 搜索 + 排序 ───────────────────────────────────────────────────────── + const visibleChains = useMemo(() => { + const q = search.trim().toLowerCase(); + const filtered = q + ? chains.filter((c) => { + if ( + (c.title && c.title.toLowerCase().includes(q)) || + (c.description && c.description.toLowerCase().includes(q)) || + (c.ownerName && c.ownerName.toLowerCase().includes(q)) + ) { + return true; + } + return c.seats.some((s) => s.name && s.name.toLowerCase().includes(q)); + }) + : chains.slice(); + + filtered.sort((a, b) => { + if (sortBy === "seats") return b.seats.length - a.seats.length; + if (sortBy === "name") return (a.title || "").localeCompare(b.title || "", "zh"); + // updated:按更新时间倒序(ISO 字符串可直接比较),缺省退化为标题。 + return (b.updatedAt || "").localeCompare(a.updatedAt || ""); + }); + return filtered; + }, [chains, search, sortBy]); return ( -
- {/* 顶栏:标题 + 描述 + 配置入口 */} -
-
+
+ {/* 顶栏:标题 + 描述 + 新建链条 */} +
+
-
- +

业务链条配置 -

-

- 可复用的有序智能体编排。点击卡片「设置」可进入编辑器修改该链条;「发布」后所有账号都能在「公共」里查看 / 选用。 + + Orchestrator v2 + + +

+ 可复用的有序智能体编排。点击卡片「设置」更新节点参数;「发布」后所有账号都能在「公共」里查看 / 选用。

- + +
- {/* 列表主体(与弹窗共用 BusinessChainListBody) */} -
- + {/* 主体(独立滚动) */} +
+
+ {/* 统计卡 */} +
+ } + tone="orange" + label="总编排链条" + value={stats.totalChains} + suffix="个" + /> + } + tone="emerald" + label="已配置智能体席位" + value={stats.totalSeats} + suffix="席位" + /> + } + tone="indigo" + label="公共选用库" + value={stats.publicCount} + suffix="已公开" + /> + } + tone="amber" + label="未发布草稿" + value={stats.draftCount} + suffix="草稿" + /> +
+ + {/* 筛选工具栏 */} +
+ {/* 我的 / 公共 */} +
+ {( + [ + ["mine", "我的链条"], + ["public", "公共列表"], + ] as const + ).map(([key, label]) => ( + + ))} +
+ + {/* 搜索 + 排序 + 视图切换 */} +
+
+ + setSearch(e.target.value)} + className="w-full rounded-xl border border-zinc-200 bg-zinc-50/25 py-2 pl-10 pr-9 text-xs text-zinc-800 placeholder-zinc-400 transition-all focus:border-[#CF1322]/60 focus:bg-white focus:outline-none dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#C5CFE0] dark:placeholder-[#4A6491] dark:focus:border-[#CF1322]/60 dark:focus:bg-[#122347]" + /> + {search && ( + + )} +
+ +
+ 排序: +
+ + +
+
+ +
+ + +
+
+
+ + {/* 结果区 */} + {loading ? ( +
+ + 正在加载业务链条… +
+ ) : error ? ( +
+ 加载业务链条失败:{error} +
+ ) : visibleChains.length === 0 ? ( + 0} + onClear={() => { + setSearch(""); + setScope("mine"); + }} + onCreate={() => goConfigure()} + /> + ) : layout === "grid" ? ( +
+ + {visibleChains.map((chain) => ( + goConfigure(chain.id)} + onTogglePublic={() => handleTogglePublic(chain)} + onDelete={() => setPendingDelete(chain)} + /> + ))} + +
+ ) : ( + goConfigure(c.id)} + onTogglePublic={handleTogglePublic} + onDelete={(c) => setPendingDelete(c)} + /> + )} +
+ + {/* 页脚 */} +
+ 业务链条配置系统 • 智能体流式管线编排调度控制中心 +
+
+ + {/* 删除确认弹窗 */} + !o && setPendingDelete(null)}> + + + 删除业务链条 + + 确认删除链条「{pendingDelete?.title || "未命名链条"}」吗?删除后不可恢复。 + + + + + + + + +
+ ); +} + +// ── 统计卡 ───────────────────────────────────────────────────────────────── +// 浅色保留设计稿的彩色色调;深蓝模式统一落到 `#1A2F5A` 容器底 + 规范功能色图标。 + +const STAT_TONES = { + orange: + "bg-orange-50 text-orange-600 dark:bg-[#1A2F5A] dark:text-[#4096FF]", + emerald: + "bg-emerald-50 text-emerald-600 dark:bg-[#1A2F5A] dark:text-[#13C2C2]", + indigo: + "bg-indigo-50 text-indigo-600 dark:bg-[#1A2F5A] dark:text-[#52C41A]", + amber: + "bg-amber-50 text-amber-600 dark:bg-[#1A2F5A] dark:text-[#FAAD14]", +} as const; + +const STAT_SUFFIX_TONE: Record = { + orange: "text-zinc-400 dark:text-[#6E88B0]", + emerald: "text-emerald-600 dark:text-[#13C2C2]", + indigo: "text-indigo-600 dark:text-[#52C41A]", + amber: "text-amber-600 dark:text-[#FAAD14]", +}; + +function StatCard({ + icon, + tone, + label, + value, + suffix, +}: { + icon: React.ReactNode; + tone: keyof typeof STAT_TONES; + label: string; + value: number; + suffix: string; +}) { + return ( +
+
{icon}
+
+

+ {label} +

+
+ + {value} + + {suffix} +
); } + +// ── 空状态 ───────────────────────────────────────────────────────────────── + +function EmptyState({ + scope, + hasSearch, + onClear, + onCreate, +}: { + scope: ChainScope; + hasSearch: boolean; + onClear: () => void; + onCreate: () => void; +}) { + return ( + +
+ {hasSearch ? : } +
+ {hasSearch ? ( + <> +

+ 未匹配到符合条件的链条 +

+

+ 可以试着缩减过滤关键词或重置筛选状态。 +

+ + + ) : ( + <> +

+ {scope === "public" ? "还没有公共链条" : "还没有业务链条"} +

+

+ {scope === "public" + ? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的链条」里发布一条试试。" + : "业务链条是可复用的有序智能体编排。点右上角「新建链条」去创建第一条。"} +

+ {scope !== "public" && ( + + )} + + )} +
+ ); +} + +// ── 链条卡(网格视图)───────────────────────────────────────────────────────── + +function ChainCard({ + chain, + publishing, + onSettings, + onTogglePublic, + onDelete, +}: { + chain: BusinessChain; + publishing: boolean; + onSettings: () => void; + onTogglePublic: () => void; + onDelete: () => void; +}) { + const isMine = chain.isMine !== false; + const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : ""; + + return ( + +
+ {/* 标题行 + 操作 */} +
+
+
+

+ {chain.title || "未命名链条"} +

+ + + {chain.seats.length} 席位 + + {chain.isPublic ? ( + + 已发布 + + ) : ( + + 草稿 + + )} +
+ +
+ {ownerName && ( + + + 配置人: + + {ownerName} + + + )} + {chain.updatedAt && ( + + + {fmtTime(chain.updatedAt)} + + )} +
+
+ + {/* 操作按钮:仅本人链条可发布 / 设置 / 删除(公共里别人的链条只读浏览)。 */} + {isMine && ( +
+ + + +
+ )} +
+ + {chain.description && ( +

+ {chain.description} +

+ )} + + {/* 流程脉络图 */} +
+
+ + 流程脉络图 (鼠标按住上下左右任意拖移) +
+ +
+
+
+ ); +} + +// ── 流程脉络图(可拖移画布)───────────────────────────────────────────────── + +interface FlowStage { + ids: string[]; + parallel: boolean; +} + +/** 从链条解析分层 stage + 名称 / 配色映射。 */ +function useChainStages(chain: BusinessChain) { + return useMemo(() => { + const seatById = new Map(chain.seats.map((s) => [s.agent_id, s] as const)); + const rawStages = + chain.stages && chain.stages.length > 0 + ? chain.stages.filter((s) => s.length > 0) + : chain.seats.map((s) => [s.agent_id]); + + const orderById = new Map(); + let counter = 0; + for (const ids of rawStages) { + for (const id of ids) { + orderById.set(id, counter); + counter += 1; + } + } + const stages: FlowStage[] = rawStages.map((ids) => ({ ids, parallel: ids.length > 1 })); + return { stages, seatById, orderById }; + }, [chain]); +} + +function ChainFlow({ chain }: { chain: BusinessChain }) { + const { stages, seatById, orderById } = useChainStages(chain); + const canvasRef = useRef(null); + const drag = useRef({ active: false, startX: 0, startY: 0, left: 0, top: 0 }); + const [dragging, setDragging] = useState(false); + + const onMouseDown = (e: React.MouseEvent) => { + const el = canvasRef.current; + if (!el) return; + drag.current = { + active: true, + startX: e.pageX, + startY: e.pageY, + left: el.scrollLeft, + top: el.scrollTop, + }; + setDragging(true); + }; + const onMouseMove = (e: React.MouseEvent) => { + const el = canvasRef.current; + if (!drag.current.active || !el) return; + e.preventDefault(); + el.scrollLeft = drag.current.left - (e.pageX - drag.current.startX) * 1.5; + el.scrollTop = drag.current.top - (e.pageY - drag.current.startY) * 1.5; + }; + const endDrag = () => { + drag.current.active = false; + setDragging(false); + }; + + return ( +
+
+ {stages.map((stage, sIdx) => ( + +
+ + {stage.parallel ? `并行 ${stage.ids.length}` : "单向串行"} + +
+ {stage.ids.map((id) => { + const seat = seatById.get(id); + const avatarType = getAvatarTypeFor(id, orderById.get(id) ?? 0); + return ( +
+ + {seat?.name || id} +
+ ); + })} +
+
+ {sIdx < stages.length - 1 && ( +
+ +
+ )} +
+ ))} +
+
+ ); +} + +/** 头像配色 → 节点小圆点的实心色(与 `chainTagClasses` 的色系对齐)。 */ +function dotClass(avatarType: string): string { + switch (avatarType) { + case "purple": + return "bg-purple-500"; + case "emerald": + return "bg-emerald-500"; + case "amber": + return "bg-amber-500"; + case "pink": + return "bg-pink-500"; + case "human": + return "bg-slate-500"; + case "blue": + default: + return "bg-blue-500"; + } +} + +// ── 链条表格(高密列表视图)─────────────────────────────────────────────────── + +function ChainTable({ + chains, + publishingId, + onSettings, + onTogglePublic, + onDelete, +}: { + chains: BusinessChain[]; + publishingId: string | null; + onSettings: (c: BusinessChain) => void; + onTogglePublic: (c: BusinessChain) => void; + onDelete: (c: BusinessChain) => void; +}) { + return ( +
+
+ + + + + + + + + + + + + {chains.map((chain) => { + const isMine = chain.isMine !== false; + const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "—"; + return ( + + + + + + + + + ); + })} + +
链条名称节点链结构席位配置人最近修改操作
+
+ {chain.title || "未命名链条"} + {chain.isPublic && ( + + + 公共 + + )} +
+ {chain.description && ( +
+ {chain.description} +
+ )} +
+ + + + {chain.seats.length} 席 + + + {ownerName} + + {chain.updatedAt ? fmtTime(chain.updatedAt) : "—"} + + {isMine ? ( +
+ + + +
+ ) : ( + 只读 + )} +
+
+
+ ); +} + +/** 列表视图里的紧凑流程摘要(一行 stage 链)。 */ +function TableFlowSummary({ chain }: { chain: BusinessChain }) { + const { stages, seatById } = useChainStages(chain); + return ( +
+ {stages.map((stage, sIdx) => ( + +
+ + {stage.parallel + ? `并行 ${stage.ids.length}` + : (seatById.get(stage.ids[0])?.name || stage.ids[0] || "").slice(0, 5)} + +
+ {sIdx < stages.length - 1 && ( + → + )} +
+ ))} +
+ ); +} + +// ── 工具 ─────────────────────────────────────────────────────────────────── + +/** ISO 时间 → `YYYY-MM-DD HH:mm`(与设计稿一致)。 */ +function fmtTime(iso: string): string { + // 后端返回形如 "2026-06-12T18:30:00" 或带时区;取前 16 位并把 T 换空格。 + const s = iso.replace("T", " "); + return s.length >= 16 ? s.slice(0, 16) : s; +}