Files
deerflow-code/frontend-web/src/roundtable-planning/pages/BusinessChainListPage.tsx
T
董宏凯andClaude Opus 4.8 634cb1413b feat(圆桌): 业务链条配置页按设计稿还原
统计卡 + 筛选工具栏(我的/公共/搜索/排序/网格列表) + 富链条卡(流程脉络图),
数据来自 /api/roundtable-chains 按用户隔离;品牌主色 #CF1322,深蓝模式按规范取色。

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

1033 lines
43 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.
/**
* 业务链条配置页(独立页面版 · 设计稿还原)。
*
* 综合管理 → 业务链条配置 入口进来即是本页。照「业务链条配置中心」设计稿还原:
* 顶部 4 张统计卡 → 筛选工具栏(我的 / 公共 + 搜索 + 排序 + 网格 / 列表切换)→
* 富链条卡(含可拖移的流程脉络图 + 操作按钮)。
*
* 数据全部来自后端 `/api/roundtable-chains`(`api/chains.ts`),按登录用户隔离:
* - 「设置」→ 进编辑器并切到该链条(带 chainId);「新建链条」→ 进编辑器空白新建。
* - 「发布 / 取消发布」→ `setChainPublic`;「删除」→ `deleteChain`(确认弹窗 + toast)。
* - 设计稿里后端没有的「选用人气 / 标签」已按真实数据适配(统计卡第 4 张改为「未发布
* 草稿」,卡片不再渲染人气 / 标签空壳)。
*
* 配色:
* - 品牌主操作色(按钮 / 我的·公共切换激活态)统一用 `#CF1322`(hover `#A8071A`)。
* - 深蓝(dark)模式按《深蓝色 UI 规范》取色:页面 `#0C1836`、卡片 `#122347`、
* 边框 `#1A2F5A`、正文 `#C5CFE0`、次级 `#6E88B0`、禁用 `#4A6491`、分隔 `#2D4473`。
*
* 统计卡反映**「我的链条」全局汇总**(独立拉 scope=mine),不随当前 tab 切换而变。
*
* 圆桌顶栏的「业务链条」弹窗(`BusinessChainListDialog` / `BusinessChainListBody`)
* 仍走旧实现,本页不复用它,避免互相牵连。
*/
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 { 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 = useCallback(
(chainId?: string) => {
navigate(
chainId
? `/page/workspace/roundtable/chains?chainId=${encodeURIComponent(chainId)}`
: "/page/workspace/roundtable/chains",
);
},
[navigate],
);
const [chains, setChains] = useState<BusinessChain[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 统计专用:恒为「我的链条」,与当前 tab 解耦,避免数字随筛选跳动。
const [statChains, setStatChains] = useState<BusinessChain[]>([]);
const [scope, setScope] = useState<ChainScope>("mine");
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState<SortBy>("updated");
const [layout, setLayout] = useState<"grid" | "list">("grid");
const [publishingId, setPublishingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<BusinessChain | null>(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 (
<div className="flex h-full min-h-0 w-full flex-col bg-zinc-50/60 font-sans text-zinc-900 antialiased dark:bg-[#0C1836] dark:text-[#C5CFE0]">
{/* 顶栏:标题 + 描述 + 新建链条 */}
<header className="shrink-0 border-b border-zinc-200/80 bg-white/85 px-6 py-4 backdrop-blur-md dark:border-[#1A2F5A] dark:bg-[#122347]/80">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<h1 className="flex items-center text-lg font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
业务链条配置
<span className="ml-2 rounded-md border border-[#CF1322]/30 bg-[#FFF1F0] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-[#CF1322] dark:border-[#A8071A]/40 dark:bg-[#A8071A]/20 dark:text-[#FF7875]">
Orchestrator v2
</span>
</h1>
<p className="mt-1 max-w-xl text-xs text-zinc-500 dark:text-[#6E88B0]">
可复用的有序智能体编排。点击卡片「设置」更新节点参数;「发布」后所有账号都能在「公共」里查看 / 选用。
</p>
</div>
<button
type="button"
onClick={() => goConfigure()}
className={cn(
"flex shrink-0 items-center gap-2.5 self-start rounded-xl px-4 py-2.5 text-xs font-bold shadow-sm shadow-[#CF1322]/10 transition active:scale-95 md:self-auto",
BRAND,
)}
>
<PlusIcon className="size-4" />
<span>新建链条</span>
</button>
</div>
</header>
{/* 主体(独立滚动) */}
<main className="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-7xl px-6 py-8">
{/* 统计卡 */}
<div className="mb-6 grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatCard
icon={<LayersIcon className="size-5" />}
tone="orange"
label="总编排链条"
value={stats.totalChains}
suffix="个"
/>
<StatCard
icon={<UsersIcon className="size-5" />}
tone="emerald"
label="已配置智能体席位"
value={stats.totalSeats}
suffix="席位"
/>
<StatCard
icon={<GlobeIcon className="size-5" />}
tone="indigo"
label="公共选用库"
value={stats.publicCount}
suffix="已公开"
/>
<StatCard
icon={<FilePenLineIcon className="size-5" />}
tone="amber"
label="未发布草稿"
value={stats.draftCount}
suffix="草稿"
/>
</div>
{/* 筛选工具栏 */}
<div className="mb-6 flex flex-col gap-4 rounded-2xl border border-zinc-100 bg-white p-4 shadow-sm dark:border-[#1A2F5A] dark:bg-[#122347] lg:flex-row lg:items-center lg:justify-between">
{/* 我的 / 公共 */}
<div className="flex self-start rounded-xl bg-zinc-100 p-1 dark:bg-[#0C1836]">
{(
[
["mine", "我的链条"],
["public", "公共列表"],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => setScope(key)}
className={cn(
"flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-bold transition",
scope === key
? "bg-[#CF1322] text-white shadow-sm dark:bg-[#1677FF]"
: "text-zinc-500 hover:text-zinc-800 dark:text-[#6E88B0] dark:hover:text-[#C5CFE0]",
)}
>
<span>{label}</span>
{scope === key && (
<span className="rounded-full bg-[#A8071A] px-1.5 py-0.5 text-[9px] text-[#FFCCC7] dark:bg-[#0958D9] dark:text-[#BAE0FF]">
{visibleChains.length}
</span>
)}
</button>
))}
</div>
{/* 搜索 + 排序 + 视图切换 */}
<div className="flex w-full flex-wrap items-center gap-3 lg:w-auto lg:flex-nowrap">
<div className="relative max-w-sm flex-1 sm:w-64">
<SearchIcon className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-zinc-400 dark:text-[#4A6491]" />
<input
type="text"
placeholder="搜索链条、配置人、或席位角色…"
value={search}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => setSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded p-0.5 text-zinc-400 hover:bg-zinc-100 dark:hover:bg-[#1A2F5A]"
>
<XIcon className="size-3" />
</button>
)}
</div>
<div className="flex items-center gap-1.5">
<span className="shrink-0 text-xs font-medium text-zinc-400 dark:text-[#6E88B0]">排序:</span>
<div className="relative">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortBy)}
className="cursor-pointer select-none appearance-none rounded-xl border-none bg-zinc-100 py-2 pl-3.5 pr-8 text-xs font-semibold text-zinc-700 outline-none hover:bg-zinc-200 dark:bg-[#1A2F5A] dark:text-[#C5CFE0] dark:hover:bg-[#2D4473]"
>
<option value="updated">最新更新</option>
<option value="seats">席位数量</option>
<option value="name">名称</option>
</select>
<ChevronDownIcon className="pointer-events-none absolute right-3 top-1/2 size-3 -translate-y-1/2 text-zinc-400 dark:text-[#6E88B0]" />
</div>
</div>
<div className="flex shrink-0 items-center gap-1 rounded-xl bg-zinc-100 p-1 dark:bg-[#0C1836]">
<button
type="button"
onClick={() => setLayout("grid")}
title="脉络流卡片"
className={cn(
"rounded-lg p-1.5 transition-all",
layout === "grid"
? "bg-white text-zinc-800 shadow-sm dark:bg-[#1A2F5A] dark:text-[#C5CFE0]"
: "text-zinc-400 hover:text-zinc-600 dark:text-[#4A6491] dark:hover:text-[#6E88B0]",
)}
>
<LayoutGridIcon className="size-4" />
</button>
<button
type="button"
onClick={() => setLayout("list")}
title="数据高密列表"
className={cn(
"rounded-lg p-1.5 transition-all",
layout === "list"
? "bg-white text-zinc-800 shadow-sm dark:bg-[#1A2F5A] dark:text-[#C5CFE0]"
: "text-zinc-400 hover:text-zinc-600 dark:text-[#4A6491] dark:hover:text-[#6E88B0]",
)}
>
<ListIcon className="size-4" />
</button>
</div>
</div>
</div>
{/* 结果区 */}
{loading ? (
<div className="flex items-center justify-center gap-2 py-20 text-sm text-zinc-500 dark:text-[#6E88B0]">
<Loader2Icon className="size-4 animate-spin" />
正在加载业务链条…
</div>
) : error ? (
<div className="rounded-2xl border border-red-200/60 bg-red-50/60 p-4 text-sm text-red-600 dark:border-[#A8071A]/40 dark:bg-[#A8071A]/15 dark:text-[#FF7875]">
加载业务链条失败:{error}
</div>
) : visibleChains.length === 0 ? (
<EmptyState
scope={scope}
hasSearch={search.trim().length > 0}
onClear={() => {
setSearch("");
setScope("mine");
}}
onCreate={() => goConfigure()}
/>
) : layout === "grid" ? (
<div className="grid grid-cols-1 gap-6">
<AnimatePresence mode="popLayout">
{visibleChains.map((chain) => (
<ChainCard
key={chain.id}
chain={chain}
publishing={publishingId === chain.id}
onSettings={() => goConfigure(chain.id)}
onTogglePublic={() => handleTogglePublic(chain)}
onDelete={() => setPendingDelete(chain)}
/>
))}
</AnimatePresence>
</div>
) : (
<ChainTable
chains={visibleChains}
publishingId={publishingId}
onSettings={(c) => goConfigure(c.id)}
onTogglePublic={handleTogglePublic}
onDelete={(c) => setPendingDelete(c)}
/>
)}
</div>
{/* 页脚 */}
<footer className="mt-4 border-t border-zinc-200/70 bg-white px-6 py-6 text-center text-[10px] font-medium text-zinc-400 dark:border-[#1A2F5A] dark:bg-[#122347] dark:text-[#4A6491]">
业务链条配置系统 • 智能体流式管线编排调度控制中心
</footer>
</main>
{/* 删除确认弹窗 */}
<Dialog open={!!pendingDelete} onOpenChange={(o) => !o && setPendingDelete(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>删除业务链条</DialogTitle>
<DialogDescription>
确认删除链条「{pendingDelete?.title || "未命名链条"}」吗?删除后不可恢复。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setPendingDelete(null)}
disabled={!!deletingId}
>
取消
</Button>
<Button
variant="destructive"
onClick={() => void confirmDelete()}
disabled={!!deletingId}
className="gap-1.5"
>
{deletingId ? <Loader2Icon className="size-4 animate-spin" /> : <Trash2Icon className="size-4" />}
删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ── 统计卡 ─────────────────────────────────────────────────────────────────
// 浅色保留设计稿的彩色色调;深蓝模式统一落到 `#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<keyof typeof STAT_TONES, string> = {
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 (
<div className="flex items-center gap-4 rounded-2xl border border-zinc-100 bg-white p-4 shadow-sm transition-all duration-300 hover:border-zinc-200 dark:border-[#1A2F5A] dark:bg-[#122347] dark:hover:border-[#2D4473]">
<div className={cn("rounded-xl p-3", STAT_TONES[tone])}>{icon}</div>
<div className="min-w-0">
<p className="truncate text-xs font-medium tracking-wide text-zinc-400 dark:text-[#6E88B0]">
{label}
</p>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
{value}
</span>
<span className={cn("text-xs font-medium", STAT_SUFFIX_TONE[tone])}>{suffix}</span>
</div>
</div>
</div>
);
}
// ── 空状态 ─────────────────────────────────────────────────────────────────
function EmptyState({
scope,
hasSearch,
onClear,
onCreate,
}: {
scope: ChainScope;
hasSearch: boolean;
onClear: () => void;
onCreate: () => void;
}) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
className="mx-auto mt-8 max-w-lg rounded-3xl border border-zinc-100 bg-white p-12 text-center shadow-sm dark:border-[#1A2F5A] dark:bg-[#122347]"
>
<div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-2xl border border-zinc-150 bg-zinc-50 text-zinc-400 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#6E88B0]">
{hasSearch ? <SearchIcon className="size-5" /> : <LayersIcon className="size-5" />}
</div>
{hasSearch ? (
<>
<h3 className="text-base font-bold text-zinc-700 dark:text-[#C5CFE0]">
未匹配到符合条件的链条
</h3>
<p className="mt-1 pb-4 text-xs leading-relaxed text-zinc-400 dark:text-[#6E88B0]">
可以试着缩减过滤关键词或重置筛选状态。
</p>
<button
type="button"
onClick={onClear}
className="rounded-xl bg-[#FFF1F0] px-3.5 py-2 text-xs font-semibold text-[#CF1322] transition hover:underline dark:bg-[#A8071A]/20 dark:text-[#FF7875]"
>
清除所有过滤项
</button>
</>
) : (
<>
<h3 className="text-base font-bold text-zinc-700 dark:text-[#C5CFE0]">
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
</h3>
<p className="mt-1 pb-4 text-xs leading-relaxed text-zinc-400 dark:text-[#6E88B0]">
{scope === "public"
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的链条」里发布一条试试。"
: "业务链条是可复用的有序智能体编排。点右上角「新建链条」去创建第一条。"}
</p>
{scope !== "public" && (
<button
type="button"
onClick={onCreate}
className={cn(
"inline-flex items-center gap-1.5 rounded-xl px-3.5 py-2 text-xs font-semibold transition",
BRAND,
)}
>
<PlusIcon className="size-3.5" />
新建链条
</button>
)}
</>
)}
</motion.div>
);
}
// ── 链条卡(网格视图)─────────────────────────────────────────────────────────
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 (
<motion.div
layout
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.3 }}
className="group relative flex flex-col overflow-hidden rounded-2xl border border-zinc-100 bg-white pb-4 transition hover:border-zinc-200 hover:shadow-md hover:shadow-zinc-100/50 dark:border-[#1A2F5A] dark:bg-[#122347] dark:hover:border-[#2D4473] dark:hover:shadow-none"
>
<div className="flex flex-1 flex-col p-5 pb-2">
{/* 标题行 + 操作 */}
<div className="mb-3 flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h3 className="text-lg font-semibold text-zinc-800 transition-colors group-hover:text-[#CF1322] dark:text-[#E8EDF5] dark:group-hover:text-[#FF7875]">
{chain.title || "未命名链条"}
</h3>
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-100 px-2 py-0.5 text-xs font-medium text-zinc-600 dark:bg-[#1A2F5A] dark:text-[#C5CFE0]">
<UsersIcon className="mr-0.5 inline size-3" />
<span>{chain.seats.length} 席位</span>
</span>
{chain.isPublic ? (
<span className="rounded-full border border-emerald-200/40 bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700 dark:border-[#52C41A]/30 dark:bg-[#52C41A]/15 dark:text-[#52C41A]">
已发布
</span>
) : (
<span className="rounded-full border border-zinc-200/20 bg-zinc-100 px-2 py-0.5 text-xs font-semibold text-zinc-500 dark:border-transparent dark:bg-[#1A2F5A] dark:text-[#6E88B0]">
草稿
</span>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-400 dark:text-[#6E88B0]">
{ownerName && (
<span className="flex items-center" title={chain.ownerName}>
<UserIcon className="mr-1 size-3.5" />
配置人:
<strong className="ml-1 font-medium text-zinc-600 dark:text-[#C5CFE0]">
{ownerName}
</strong>
</span>
)}
{chain.updatedAt && (
<span className="flex items-center">
<CalendarIcon className="mr-1 size-3.5" />
{fmtTime(chain.updatedAt)}
</span>
)}
</div>
</div>
{/* 操作按钮:仅本人链条可发布 / 设置 / 删除(公共里别人的链条只读浏览)。 */}
{isMine && (
<div className="flex shrink-0 items-center gap-1 opacity-80 transition-opacity group-hover:opacity-100">
<button
type="button"
onClick={onTogglePublic}
disabled={publishing}
title={chain.isPublic ? "取消发布" : "发布到公共选用库"}
className={cn(
"rounded-lg p-1.5 transition disabled:opacity-50",
chain.isPublic
? "text-emerald-600 hover:bg-emerald-50 dark:text-[#52C41A] dark:hover:bg-[#52C41A]/15"
: "text-zinc-500 hover:bg-zinc-50 hover:text-emerald-600 dark:text-[#6E88B0] dark:hover:bg-[#1A2F5A] dark:hover:text-[#52C41A]",
)}
>
{publishing ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SendIcon className="size-4" />
)}
</button>
<button
type="button"
onClick={onSettings}
title="配置修改"
className="rounded-lg p-1.5 text-zinc-600 transition hover:bg-zinc-50 hover:text-zinc-900 dark:text-[#6E88B0] dark:hover:bg-[#1A2F5A] dark:hover:text-[#C5CFE0]"
>
<SettingsIcon className="size-4" />
</button>
<button
type="button"
onClick={onDelete}
title="删除链条"
className="rounded-lg p-1.5 text-zinc-400 transition hover:bg-red-50 hover:text-red-600 dark:text-[#6E88B0] dark:hover:bg-[#A8071A]/20 dark:hover:text-[#FF7875]"
>
<Trash2Icon className="size-4" />
</button>
</div>
)}
</div>
{chain.description && (
<p className="mb-3 line-clamp-2 rounded-lg border border-zinc-100/40 bg-zinc-50/50 p-2 text-xs leading-relaxed text-zinc-500 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#6E88B0]">
{chain.description}
</p>
)}
{/* 流程脉络图 */}
<div className="mt-2 flex flex-col border-t border-dashed border-zinc-200/85 pt-2 dark:border-[#2D4473]">
<div className="mb-1 flex select-none items-center gap-1 text-[10px] font-semibold text-zinc-500 dark:text-[#6E88B0]">
<span className="inline-block size-1.5 animate-pulse rounded-full bg-[#CF1322]" />
<span>流程脉络图 (鼠标按住上下左右任意拖移)</span>
</div>
<ChainFlow chain={chain} />
</div>
</div>
</motion.div>
);
}
// ── 流程脉络图(可拖移画布)─────────────────────────────────────────────────
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<string, number>();
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<HTMLDivElement>(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 (
<div
ref={canvasRef}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={endDrag}
onMouseLeave={endDrag}
className={cn(
"custom-scrollbar overflow-auto rounded-xl border border-zinc-100/30 bg-zinc-50/20 p-2 dark:border-[#1A2F5A]/60 dark:bg-[#0C1836]",
dragging ? "cursor-grabbing" : "cursor-grab",
)}
style={{ maxHeight: 220 }}
>
<div className="flex min-w-max items-stretch gap-3 py-1">
{stages.map((stage, sIdx) => (
<Fragment key={sIdx}>
<div
className={cn(
"flex shrink-0 flex-col rounded-xl border p-2.5",
stage.parallel
? "border-amber-400 bg-amber-50/30 shadow-sm ring-2 ring-amber-100/50 dark:border-[#FAAD14]/50 dark:bg-[#FAAD14]/10 dark:ring-[#FAAD14]/15"
: "border-zinc-200/60 bg-zinc-50/60 dark:border-[#1A2F5A] dark:bg-[#122347]",
)}
>
<span
className={cn(
"mb-1.5 self-start rounded px-2 py-0.5 text-[9px] font-extrabold uppercase tracking-wider",
stage.parallel
? "bg-amber-600 text-white shadow-sm dark:bg-[#FAAD14] dark:text-[#0C1836]"
: "bg-zinc-200 text-zinc-700 dark:bg-[#1A2F5A] dark:text-[#C5CFE0]",
)}
>
{stage.parallel ? `并行 ${stage.ids.length}` : "单向串行"}
</span>
<div className="mt-0.5 flex flex-col gap-1.5">
{stage.ids.map((id) => {
const seat = seatById.get(id);
const avatarType = getAvatarTypeFor(id, orderById.get(id) ?? 0);
return (
<div
key={id}
className={cn(
"flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium",
chainTagClasses(avatarType),
)}
>
<span className={cn("size-1.5 shrink-0 rounded-full", dotClass(avatarType))} />
<span className="max-w-[130px] truncate">{seat?.name || id}</span>
</div>
);
})}
</div>
</div>
{sIdx < stages.length - 1 && (
<div className="flex shrink-0 select-none items-center justify-center px-0.5">
<ArrowRightIcon className="size-4 text-zinc-300 dark:text-[#4A6491]" />
</div>
)}
</Fragment>
))}
</div>
</div>
);
}
/** 头像配色 → 节点小圆点的实心色(与 `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 (
<div className="overflow-hidden rounded-3xl border border-zinc-100 bg-white shadow-sm dark:border-[#1A2F5A] dark:bg-[#122347]">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left">
<thead>
<tr className="border-b border-zinc-100 bg-zinc-50/50 text-xs font-semibold uppercase tracking-wider text-zinc-500 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#6E88B0]">
<th className="px-6 py-4">链条名称</th>
<th className="px-6 py-4">节点链结构</th>
<th className="px-6 py-4">席位</th>
<th className="px-6 py-4">配置人</th>
<th className="px-6 py-4">最近修改</th>
<th className="px-6 py-4 text-right">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 text-xs text-zinc-800 dark:divide-[#1A2F5A] dark:text-[#C5CFE0]">
{chains.map((chain) => {
const isMine = chain.isMine !== false;
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "—";
return (
<tr
key={chain.id}
className="group transition duration-150 hover:bg-zinc-50/60 dark:hover:bg-[#1A2F5A]/40"
>
<td className="px-6 py-4 align-top">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-900 transition-colors group-hover:text-[#CF1322] dark:text-[#E8EDF5] dark:group-hover:text-[#FF7875]">
{chain.title || "未命名链条"}
{chain.isPublic && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-1.5 py-0.5 text-[9px] font-medium text-emerald-600 dark:bg-[#52C41A]/15 dark:text-[#52C41A]">
<GlobeIcon className="size-2.5" />
公共
</span>
)}
</div>
{chain.description && (
<div className="mt-1 line-clamp-1 max-w-xs text-[11px] text-zinc-400 dark:text-[#6E88B0]">
{chain.description}
</div>
)}
</td>
<td className="px-6 py-4 align-top">
<TableFlowSummary chain={chain} />
</td>
<td className="px-6 py-4 align-top">
<span className="rounded-full bg-zinc-100 px-2 py-1 text-[10px] font-bold text-zinc-600 dark:bg-[#1A2F5A] dark:text-[#C5CFE0]">
{chain.seats.length} 席
</span>
</td>
<td
className="px-6 py-4 align-top font-medium text-zinc-600 dark:text-[#C5CFE0]"
title={chain.ownerName}
>
{ownerName}
</td>
<td className="px-6 py-4 align-top font-mono text-zinc-400 dark:text-[#6E88B0]">
{chain.updatedAt ? fmtTime(chain.updatedAt) : "—"}
</td>
<td className="px-6 py-4 text-right align-top">
{isMine ? (
<div className="inline-flex items-center gap-1">
<button
type="button"
onClick={() => onTogglePublic(chain)}
disabled={publishingId === chain.id}
title={chain.isPublic ? "取消发布" : "发布到公共选用库"}
className="rounded-lg p-1.5 text-emerald-600 transition hover:bg-emerald-50 disabled:opacity-50 dark:text-[#52C41A] dark:hover:bg-[#52C41A]/15"
>
{publishingId === chain.id ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SendIcon className="size-4" />
)}
</button>
<button
type="button"
onClick={() => onSettings(chain)}
title="设置"
className="rounded-lg p-1.5 text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-900 dark:text-[#6E88B0] dark:hover:bg-[#1A2F5A] dark:hover:text-[#C5CFE0]"
>
<SettingsIcon className="size-4" />
</button>
<button
type="button"
onClick={() => onDelete(chain)}
title="删除"
className="rounded-lg p-1.5 text-zinc-400 transition hover:bg-red-50 hover:text-red-600 dark:text-[#6E88B0] dark:hover:bg-[#A8071A]/20 dark:hover:text-[#FF7875]"
>
<Trash2Icon className="size-4" />
</button>
</div>
) : (
<span className="text-[10px] text-zinc-300 dark:text-[#4A6491]">只读</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
/** 列表视图里的紧凑流程摘要(一行 stage 链)。 */
function TableFlowSummary({ chain }: { chain: BusinessChain }) {
const { stages, seatById } = useChainStages(chain);
return (
<div className="custom-scrollbar flex max-w-[280px] select-none items-center gap-1.5 overflow-x-auto py-1">
{stages.map((stage, sIdx) => (
<Fragment key={sIdx}>
<div
className={cn(
"flex shrink-0 items-center rounded border px-2 py-1",
stage.parallel
? "border-amber-200/50 bg-amber-50/20 text-amber-700 dark:border-[#FAAD14]/40 dark:bg-[#FAAD14]/10 dark:text-[#FAAD14]"
: "border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#C5CFE0]",
)}
>
<span className="text-[9px] font-bold">
{stage.parallel
? `并行 ${stage.ids.length}`
: (seatById.get(stage.ids[0])?.name || stage.ids[0] || "").slice(0, 5)}
</span>
</div>
{sIdx < stages.length - 1 && (
<span className="shrink-0 text-zinc-300 dark:text-[#4A6491]">→</span>
)}
</Fragment>
))}
</div>
);
}
// ── 工具 ───────────────────────────────────────────────────────────────────
/** 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;
}