统计卡 + 筛选工具栏(我的/公共/搜索/排序/网格列表) + 富链条卡(流程脉络图), 数据来自 /api/roundtable-chains 按用户隔离;品牌主色 #CF1322,深蓝模式按规范取色。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1033 lines
43 KiB
TypeScript
1033 lines
43 KiB
TypeScript
/**
|
||
* 业务链条配置页(独立页面版 · 设计稿还原)。
|
||
*
|
||
* 综合管理 → 业务链条配置 入口进来即是本页。照「业务链条配置中心」设计稿还原:
|
||
* 顶部 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;
|
||
}
|