feat(roundtable): 业务链条「发布为公共」+ 我的/公共筛选

后端:roundtable_chains 加 is_public 列;list_chains 支持 scope=mine|public(public=所有已发布,任何账号可查看/选入研讨);get_chain 放开公共可见;响应加 is_public/is_mine;update 支持 is_public(发布/取消,owner 限定,编辑/删除仍仅 owner)。迁移 20260608_03 接在合并 head 20260608_02 之后,保证单一 alembic head(多 head 会让启动自动迁移失败、静默降级 create_all → 列缺失 500)。

前端:listChains(scope) + setChainPublic;BusinessChain 加 isPublic/isMine;列表弹窗与研讨选择器加「我的/公共」筛选 tab;列表卡片发布/取消发布 + 公共徽标(别人的只读);配置页头部加发布按钮(已保存且本人)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-07 22:30:23 +08:00
1 parent 05d14bb364
commit 7affaaf7c0
10 files changed
+373 -44

No files matched your search

@@ -33,8 +33,13 @@ export interface ChainUpdatePayload {
seats?: ChainSeat[];
/** Send `null` to clear layering back to linear; omit to leave unchanged. */
stages?: string[][] | null;
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
isPublic?: boolean;
}
/** 列表筛选范围:`mine` = 我的;`public` = 所有已发布的公共链条。 */
export type ChainScope = "mine" | "public";
const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`;
async function readJson<T>(res: Response, ctx: string): Promise<T> {
@@ -55,6 +60,8 @@ interface ServerChain {
description: string | null;
seats: ChainSeat[];
stages?: string[][] | null;
is_public?: boolean;
is_mine?: boolean;
created_at: string | null;
updated_at: string | null;
}
@@ -66,6 +73,9 @@ function toChain(s: ServerChain): BusinessChain {
description: s.description ?? undefined,
seats: Array.isArray(s.seats) ? s.seats : [],
stages: Array.isArray(s.stages) ? s.stages : null,
isPublic: s.is_public ?? false,
// 后端缺省时按「我的」处理(向后兼容旧响应)。
isMine: s.is_mine ?? true,
createdAt: s.created_at ?? undefined,
updatedAt: s.updated_at ?? undefined,
};
@@ -88,18 +98,29 @@ function fromUpdate(p: ChainUpdatePayload) {
if (p.seats !== undefined) out.seats = p.seats;
// Forward stages even when explicitly null (clears layering server-side).
if (p.stages !== undefined) out.stages = p.stages;
if (p.isPublic !== undefined) out.is_public = p.isPublic;
return out;
}
// ── CRUD ────────────────────────────────────────────────────────────────────
/** List the current user's chains, newest first. */
export async function listChains(): Promise<BusinessChain[]> {
const res = await apiFetch(BASE(), { method: "GET" });
/**
* List chains, newest first. `scope`:
* - `"mine"`(默认):当前用户自己的链条;
* - `"public"`:所有已发布的公共链条(任何账号可见 / 可选入研讨)。
*/
export async function listChains(scope: ChainScope = "mine"): Promise<BusinessChain[]> {
const url = scope === "public" ? `${BASE()}?scope=public` : BASE();
const res = await apiFetch(url, { method: "GET" });
const data = await readJson<{ chains: ServerChain[] }>(res, "list chains");
return (data.chains ?? []).map(toChain);
}
/** 发布 / 取消发布一条链条(仅 owner 生效)。返回更新后的链条,404 返回 null。 */
export async function setChainPublic(id: string, isPublic: boolean): Promise<BusinessChain | null> {
return updateChain(id, { isPublic });
}
/** Fetch one chain. Returns null on 404. */
export async function getChain(id: string): Promise<BusinessChain | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
@@ -9,9 +9,10 @@
* 卡片席位名直接用落库快照(`seat.name`),无需实时校验,点「设置」进编辑页后
* 会看到实时数据。
*/
import { Fragment, useEffect, useState } from "react";
import { Fragment, useCallback, useEffect, useState } from "react";
import {
ArrowRightIcon,
GlobeIcon,
LayersIcon,
Loader2Icon,
SettingsIcon,
@@ -28,7 +29,7 @@ import {
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { listChains } from "../api/chains";
import { listChains, setChainPublic, type ChainScope } from "../api/chains";
import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
@@ -47,16 +48,32 @@ export function BusinessChainListDialog({
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);
// 每次打开都重新拉一次,保证配置页改动后回来看到的是最新列表。
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 (!open) return;
let cancelled = false;
setLoading(true);
setError(null);
void (async () => {
setLoading(true);
setError(null);
try {
const list = await listChains();
const list = await listChains(scope);
if (!cancelled) setChains(list);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
@@ -67,7 +84,22 @@ export function BusinessChainListDialog({
return () => {
cancelled = true;
};
}, [open]);
}, [open, 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -78,10 +110,29 @@ export function BusinessChainListDialog({
业务链条
</DialogTitle>
<DialogDescription>
可复用的有序智能体编排。点击卡片「设置」可进入配置页编辑该链条。
可复用的有序智能体编排。点击卡片「设置」可进入配置页编辑该链条;「发布」后所有账号都能在「公共」里查看 / 选用。
</DialogDescription>
</DialogHeader>
{/* 筛选:我的 / 公共 */}
<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">
@@ -95,9 +146,13 @@ export function BusinessChainListDialog({
) : 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">还没有业务链条</div>
<div className="text-sm font-semibold">
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
</div>
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。
{scope === "public"
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的」里发布一条试试。"
: "业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。"}
</p>
</div>
) : (
@@ -106,7 +161,9 @@ export function BusinessChainListDialog({
<ChainRow
key={chain.id}
chain={chain}
publishing={publishingId === chain.id}
onSettings={() => onConfigure(chain.id)}
onTogglePublic={() => handleTogglePublic(chain)}
/>
))}
</div>
@@ -128,28 +185,61 @@ export function BusinessChainListDialog({
function ChainRow({
chain,
publishing,
onSettings,
onTogglePublic,
}: {
chain: BusinessChain;
publishing: boolean;
onSettings: () => void;
onTogglePublic: () => void;
}) {
const isMine = chain.isMine !== false;
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>
<Button
variant="ghost"
size="sm"
onClick={onSettings}
className="ml-auto h-8 shrink-0 gap-1.5 px-3 text-sm"
>
<SettingsIcon className="size-4" />
设置
</Button>
{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>
{chain.description && (
@@ -29,7 +29,7 @@ import { cn } from "@/lib/utils";
import { listAgents } from "@/core/agents";
import { filterRecommendCandidates } from "../api/recommend";
import { listChains } from "../api/chains";
import { listChains, type ChainScope } from "../api/chains";
import {
CHAIN_MIN_SEATS,
chainTagClasses,
@@ -55,16 +55,21 @@ export function BusinessChainPicker({
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
// 筛选:我的 / 公共(已发布)。
const [scope, setScope] = useState<ChainScope>("mine");
// null = 候选池未知(agent 列表加载失败)→ 不把任何席位判成「已删除」。
const [existingIds, setExistingIds] = useState<Set<string> | null>(null);
const [nameById, setNameById] = useState<Map<string, string>>(new Map());
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
setSelectedId(null); // 切换筛选后清空选中(旧选中可能不在新列表里)。
void (async () => {
try {
const [chainList, agents] = await Promise.all([
listChains(),
listChains(scope),
listAgents().catch(() => null), // agent 列表失败不阻断链条展示
]);
if (cancelled) return;
@@ -86,7 +91,7 @@ export function BusinessChainPicker({
return () => {
cancelled = true;
};
}, []);
}, [scope]);
const isExisting = useMemo(
() => (agentId: string) => existingIds === null || existingIds.has(agentId),
@@ -136,6 +141,24 @@ export function BusinessChainPicker({
return (
<>
<section className="flex-1 min-h-0 flex flex-col overflow-hidden">
{/* 筛选:我的 / 公共 */}
<div className="mb-2 flex shrink-0 items-center gap-1 self-start rounded-lg border border-border bg-muted/40 p-0.5">
{(["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="min-h-0 flex-1 overflow-y-auto custom-scrollbar pr-1 -mr-1">
{loading ? (
<div className="flex items-center gap-2 px-1 py-8 text-sm text-muted-foreground">
@@ -149,15 +172,20 @@ export function BusinessChainPicker({
) : chains.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 py-10 text-center">
<LayersIcon className="size-10 text-primary/40" />
<div className="text-sm font-semibold">还没有业务链条</div>
<div className="text-sm font-semibold">
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
</div>
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
业务链条是可复用的有序智能体编排。先去配置页面创建第一条链条,
之后即可在这里选择使用。
{scope === "public"
? "公共链条是别人(或你自己)发布出来、所有账号可选用的链条。可在「我的」里发布一条。"
: "业务链条是可复用的有序智能体编排。先去配置页面创建第一条链条,之后即可在这里选择使用。"}
</p>
<Button variant="outline" size="sm" onClick={onGoConfig} className="gap-1.5">
<SettingsIcon className="size-4" />
去配置业务链条
</Button>
{scope === "mine" && (
<Button variant="outline" size="sm" onClick={onGoConfig} className="gap-1.5">
<SettingsIcon className="size-4" />
去配置业务链条
</Button>
)}
</div>
) : (
<div className="space-y-2.5 py-0.5">
@@ -33,6 +33,10 @@ export interface BusinessChain {
* (每席位独立一层)。有分层时,选入 Step 2 走 `dag` 编排(见 `lib/dag-types.ts`)。
*/
stages?: string[][] | null;
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
isPublic?: boolean;
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
isMine?: boolean;
createdAt?: string;
updatedAt?: string;
}
@@ -28,6 +28,7 @@ import {
ChevronDown,
ChevronUp,
GitBranch,
Globe,
GripVertical,
Layers,
Loader2,
@@ -70,6 +71,7 @@ import {
createChain,
deleteChain,
listChains,
setChainPublic,
updateChain,
} from "@/roundtable-planning/api/chains";
import {
@@ -130,6 +132,10 @@ export default function BusinessChainEditorPage() {
// ─── 当前编辑中的链条 ─────────────────────────────────────────────────
const [currentChainId, setCurrentChainId] = useState<string | null>(null);
// 当前编辑链条的发布状态 / 是否本人所有(发布按钮 + 只读保护用)。
const [currentChainPublic, setCurrentChainPublic] = useState(false);
const [currentChainMine, setCurrentChainMine] = useState(true);
const [publishing, setPublishing] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
// 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。
@@ -218,6 +224,8 @@ export default function BusinessChainEditorPage() {
// ─── 链条选择 / 新建 ──────────────────────────────────────────────────
const loadChain = useCallback((chain: BusinessChain) => {
setCurrentChainId(chain.id);
setCurrentChainPublic(chain.isPublic ?? false);
setCurrentChainMine(chain.isMine !== false);
setTitle(chain.title);
setDescription(chain.description ?? "");
setStageGroups(chainToStageGroups(chain));
@@ -225,6 +233,8 @@ export default function BusinessChainEditorPage() {
const startNewChain = useCallback(() => {
setCurrentChainId(null);
setCurrentChainPublic(false);
setCurrentChainMine(true);
setTitle("");
setDescription("");
setStageGroups([]);
@@ -462,6 +472,32 @@ export default function BusinessChainEditorPage() {
}
}, [currentChainId, title, startNewChain, refreshChains]);
// 发布 / 取消发布:把当前链条设为公共(所有账号可见 / 选用)或收回。
const handleTogglePublish = useCallback(async () => {
if (!currentChainId) {
toast.warning("请先保存链条再发布");
return;
}
const next = !currentChainPublic;
setPublishing(true);
try {
const updated = await setChainPublic(currentChainId, next);
if (!updated) {
toast.error("链条已不存在,可能被删除");
startNewChain();
await refreshChains();
return;
}
setCurrentChainPublic(updated.isPublic ?? next);
toast.success(next ? "已发布为公共,所有账号都能查看 / 选用" : "已取消发布");
await refreshChains();
} catch (err) {
toast.error(`${next ? "发布" : "取消发布"}失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
setPublishing(false);
}
}, [currentChainId, currentChainPublic, startNewChain, refreshChains]);
// ─── Render ──────────────────────────────────────────────────────────
const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…";
@@ -587,6 +623,29 @@ export default function BusinessChainEditorPage() {
{/* 右侧操作 */}
<div className="ml-auto flex items-center gap-2">
{/* 发布 / 取消发布:仅已保存且本人所有的链条。发布后所有账号可在「公共」筛选查看 / 选用。 */}
{currentChainId && currentChainMine && (
<button
type="button"
onClick={handleTogglePublish}
disabled={publishing}
title={currentChainPublic ? "已发布为公共,点击收回为私有" : "发布为公共,所有账号都能查看 / 选用"}
className={cn(
"flex items-center gap-1.5 border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60",
currentChainPublic
? "border-emerald-400/50 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/15 dark:text-emerald-400"
: "border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground",
R_CTRL,
)}
>
{publishing ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Globe className="size-4" />
)}
{currentChainPublic ? "已发布" : "发布"}
</button>
)}
<button
type="button"
onClick={handleDelete}