diff --git a/frontend-web/src/roundtable-planning/api/chains.ts b/frontend-web/src/roundtable-planning/api/chains.ts index d3078ede..461a006e 100644 --- a/frontend-web/src/roundtable-planning/api/chains.ts +++ b/frontend-web/src/roundtable-planning/api/chains.ts @@ -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(res: Response, ctx: string): Promise { @@ -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 { - const res = await apiFetch(BASE(), { method: "GET" }); +/** + * List chains, newest first. `scope`: + * - `"mine"`(默认):当前用户自己的链条; + * - `"public"`:所有已发布的公共链条(任何账号可见 / 可选入研讨)。 + */ +export async function listChains(scope: ChainScope = "mine"): Promise { + 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 { + return updateChain(id, { isPublic }); +} + /** Fetch one chain. Returns null on 404. */ export async function getChain(id: string): Promise { const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" }); diff --git a/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx b/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx index d17e6e09..1e961c58 100644 --- a/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx +++ b/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // 筛选:我的 / 公共(已发布)。 + const [scope, setScope] = useState("mine"); + const [publishingId, setPublishingId] = 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); + } + }, []); + + // 每次打开 / 切换筛选都重新拉,保证配置页改动后回来看到的是最新列表。 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 ( @@ -78,10 +110,29 @@ export function BusinessChainListDialog({ 业务链条 - 可复用的有序智能体编排。点击卡片「设置」可进入配置页编辑该链条。 + 可复用的有序智能体编排。点击卡片「设置」可进入配置页编辑该链条;「发布」后所有账号都能在「公共」里查看 / 选用。 + {/* 筛选:我的 / 公共 */} +
+ {(["mine", "public"] as const).map((s) => ( + + ))} +
+
{loading ? (
@@ -95,9 +146,13 @@ export function BusinessChainListDialog({ ) : chains.length === 0 ? (
-
还没有业务链条
+
+ {scope === "public" ? "还没有公共链条" : "还没有业务链条"} +

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

) : ( @@ -106,7 +161,9 @@ export function BusinessChainListDialog({ onConfigure(chain.id)} + onTogglePublic={() => handleTogglePublic(chain)} /> ))}
@@ -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 (
- {/* 标题行 + 设置按钮 */} + {/* 标题行 + 公共徽标 + 发布/设置按钮 */}
{chain.title || "未命名链条"}
{chain.seats.length} 席 - + {chain.isPublic && ( + + + 公共 + + )} +
+ {/* 发布 / 取消发布:仅本人的链条可操作。 */} + {isMine && ( + + )} + {/* 设置(编辑):仅本人的链条;公共里别人的链条只读浏览。 */} + {isMine && ( + + )} +
{chain.description && ( diff --git a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx index 3bc9f72f..154c088c 100644 --- a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx +++ b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx @@ -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(null); const [selectedId, setSelectedId] = useState(null); + // 筛选:我的 / 公共(已发布)。 + const [scope, setScope] = useState("mine"); // null = 候选池未知(agent 列表加载失败)→ 不把任何席位判成「已删除」。 const [existingIds, setExistingIds] = useState | null>(null); const [nameById, setNameById] = useState>(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 ( <>
+ {/* 筛选:我的 / 公共 */} +
+ {(["mine", "public"] as const).map((s) => ( + + ))} +
{loading ? (
@@ -149,15 +172,20 @@ export function BusinessChainPicker({ ) : chains.length === 0 ? (
-
还没有业务链条
+
+ {scope === "public" ? "还没有公共链条" : "还没有业务链条"} +

- 业务链条是可复用的有序智能体编排。先去配置页面创建第一条链条, - 之后即可在这里选择使用。 + {scope === "public" + ? "公共链条是别人(或你自己)发布出来、所有账号可选用的链条。可在「我的」里发布一条。" + : "业务链条是可复用的有序智能体编排。先去配置页面创建第一条链条,之后即可在这里选择使用。"}

- + {scope === "mine" && ( + + )}
) : (
diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts index b82fe4e0..33cee3ee 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -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; } diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index 940d055b..a1d484b5 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -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(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() { {/* 右侧操作 */}
+ {/* 发布 / 取消发布:仅已保存且本人所有的链条。发布后所有账号可在「公共」筛选查看 / 选用。 */} + {currentChainId && currentChainMine && ( + + )}