From 8c3d6a02fc49e7d60a79d48703973ac4eb1d0080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 25 Aug 2026 17:30:29 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E4=B8=9A=E5=8A=A1=E9=93=BE?= =?UTF-8?q?=E6=9D=A1=E5=8F=91=E5=B8=83=E5=AE=A1=E6=A0=B8=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=20+=20=E7=BC=96=E8=BE=91=E5=99=A8=E5=8E=BB=E9=99=A4=E5=B2=97?= =?UTF-8?q?=E4=BD=8D=E8=81=8C=E8=B4=A3=E8=BE=93=E5=85=A5=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 发布改为可选「确认审核 / 直接发布」:链条编辑页与圆桌「业务链条」弹窗行为一致;审核中可撤回 - 管理员在业务链条列表新增「待审核」筛选,审核发布后进入公共库 - 后端 roundtable_chains 新增 pending_review 字段(启动期自动补列)与 scope=pending 筛选;新增审核流程测试 - 编辑器席位去掉「岗位职责」输入框(保存时存量值原样保留) --- .../src/roundtable-planning/api/chains.ts | 19 +- .../components/BusinessChainListDialog.tsx | 139 +++++++++++-- .../roundtable-planning/lib/business-chain.ts | 6 + .../pages/BusinessChainEditorPage.tsx | 183 +++++++++++++----- .../pages/BusinessChainListPage.tsx | 62 ++++-- .../app/gateway/routers/roundtable_chains.py | 16 +- .../persistence/roundtable_chains/model.py | 6 + .../persistence/roundtable_chains/sql.py | 10 + .../tests/test_roundtable_chain_review.py | 102 ++++++++++ 9 files changed, 462 insertions(+), 81 deletions(-) create mode 100644 offline-backend-20260512/backend/tests/test_roundtable_chain_review.py diff --git a/frontend-web/src/roundtable-planning/api/chains.ts b/frontend-web/src/roundtable-planning/api/chains.ts index 54068509..de44ae80 100644 --- a/frontend-web/src/roundtable-planning/api/chains.ts +++ b/frontend-web/src/roundtable-planning/api/chains.ts @@ -55,15 +55,18 @@ export interface ChainUpdatePayload { isPublic?: boolean; /** 启停开关:true 启用 / false 停用;omit 不改。仅 owner / 管理员生效。 */ enabled?: boolean; + /** 「待审核」开关:true 提交审核 / false 清除(管理员发布时随 isPublic=true 一起提交);omit 不改。 */ + pendingReview?: boolean; } /** * 列表筛选范围: * - `mine` = 我的; * - `public` = 所有已发布的公共链条; + * - `pending` = 所有待审核(pendingReview=true)的链条(**仅管理员**;后端对非管理员退化为 `mine`); * - `all` = 所有账号的全部链条(**仅管理员**;后端对非管理员请求 `all` 退化为 `mine`)。 */ -export type ChainScope = "mine" | "public" | "all"; +export type ChainScope = "mine" | "public" | "pending" | "all"; const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`; @@ -91,6 +94,7 @@ interface ServerChain { gather_first?: boolean; is_public?: boolean; enabled?: boolean; + pending_review?: boolean; is_mine?: boolean; owner_name?: string | null; created_at: string | null; @@ -110,6 +114,8 @@ function toChain(s: ServerChain): BusinessChain { gatherFirst: s.gather_first ?? false, isPublic: s.is_public ?? false, enabled: s.enabled ?? true, + // 「待审核」标记:缺省 false(旧响应兼容)。 + pendingReview: s.pending_review ?? false, // 后端缺省时按「我的」处理(向后兼容旧响应)。 isMine: s.is_mine ?? true, ownerName: s.owner_name ?? undefined, @@ -146,6 +152,7 @@ function fromUpdate(p: ChainUpdatePayload) { if (p.gatherFirst !== undefined) out.gather_first = p.gatherFirst; if (p.isPublic !== undefined) out.is_public = p.isPublic; if (p.enabled !== undefined) out.enabled = p.enabled; + if (p.pendingReview !== undefined) out.pending_review = p.pendingReview; return out; } @@ -181,6 +188,16 @@ export async function setChainEnabled(id: string, enabled: boolean): Promise { + return updateChain(id, { pendingReview: true }); +} + +/** 管理员发布「待审核」链条:正式发布为公共并清除待审核标记。返回更新后的链条,404 返回 null。 */ +export async function approveChainReview(id: string): Promise { + return updateChain(id, { isPublic: true, pendingReview: false }); +} + /** 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 32824e4b..6b104f53 100644 --- a/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx +++ b/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx @@ -36,7 +36,7 @@ import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize import { getStoredAuth } from "@/core/auth"; import { listAgents } from "@/core/agents"; -import { listChains, setChainEnabled, setChainPublic, type ChainScope } from "../api/chains"; +import { listChains, setChainEnabled, setChainPublic, submitChainForReview, updateChain, type ChainScope } from "../api/chains"; import { chainTagClasses, isChainEnabled, type BusinessChain } from "../lib/business-chain"; import { getAvatarTypeFor } from "../lib/roundtable-constants"; @@ -84,6 +84,8 @@ export function BusinessChainListBody({ const [enabledFilter, setEnabledFilter] = useState("all"); const [publishingId, setPublishingId] = useState(null); const [togglingId, setTogglingId] = useState(null); + // 发布审核选择弹窗的目标链条(null = 关闭)。草稿点「发布」先选 确认审核 / 直接发布。 + const [reviewTarget, setReviewTarget] = useState(null); // 实时名称表(id 小写 → 最新 name):用于把席位的落库快照名刷新成最新(agent 改名同步)。 const [nameById, setNameById] = useState>(new Map()); @@ -140,25 +142,89 @@ export function BusinessChainListBody({ }; }, [active, scope]); + // 发布入口:已发布 → 直接取消发布(旧行为);审核中 → 撤回审核;草稿 → 弹窗选 + // 「确认审核 / 直接发布」(与链条编辑页一致)。 const handleTogglePublic = useCallback( async (chain: BusinessChain) => { - setPublishingId(chain.id); - try { - const updated = await setChainPublic(chain.id, !chain.isPublic); - if (updated) { - setChains((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); - } else { - await refresh(scope); + // 审核中 → 撤回审核,回到草稿。 + if (!chain.isPublic && chain.pendingReview) { + if (!window.confirm(`「${chain.title || "未命名链条"}」正在审核中,确认撤回审核?`)) return; + setPublishingId(chain.id); + try { + const updated = await updateChain(chain.id, { pendingReview: false }); + if (updated) { + setChains((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); + } else { + await refresh(scope); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPublishingId(null); } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setPublishingId(null); + return; } + // 已发布 → 直接取消发布。 + if (chain.isPublic) { + setPublishingId(chain.id); + try { + const updated = await setChainPublic(chain.id, false); + if (updated) { + setChains((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); + } else { + await refresh(scope); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPublishingId(null); + } + return; + } + // 草稿 → 弹窗选择。 + setReviewTarget(chain); }, [refresh, scope], ); + // 弹窗「确认审核」:提交进入待审核,等待管理员发布。 + const handleSubmitReview = useCallback(async () => { + const chain = reviewTarget; + if (!chain) return; + setPublishingId(chain.id); + try { + const updated = await submitChainForReview(chain.id); + if (updated) { + setChains((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); + } + setReviewTarget(null); + if (!updated) await refresh(scope); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPublishingId(null); + } + }, [reviewTarget, refresh, scope]); + + // 弹窗「直接发布」:立即对所有账号可见(旧行为)。 + const handleDirectPublish = useCallback(async () => { + const chain = reviewTarget; + if (!chain) return; + setPublishingId(chain.id); + try { + const updated = await setChainPublic(chain.id, true); + if (updated) { + setChains((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); + } + setReviewTarget(null); + if (!updated) await refresh(scope); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPublishingId(null); + } + }, [reviewTarget, refresh, scope]); + const handleToggleEnabled = useCallback( async (chain: BusinessChain) => { setTogglingId(chain.id); @@ -273,6 +339,39 @@ export function BusinessChainListBody({ )} + + {/* 发布审核选择弹窗:确认审核 → 待审核(管理员发布);直接发布 → 立即公共可见。 */} + !o && setReviewTarget(null)}> + + + + + 发布业务链条 + + + 是否需要审核?确认审核后「{reviewTarget?.title || "未命名链条"}」将进入「待审核」,由管理员在业务链条列表中发布;直接发布则立即对所有账号可见 / 选用。 + + + + + + + + + ); } @@ -355,6 +454,11 @@ function ChainRow({ 公共 )} + {chain.pendingReview && ( + + 待审核 + + )} {!enabled && ( 已停用 @@ -375,13 +479,20 @@ function ChainRow({ )} - {/* 发布 / 取消发布:本人的链条或管理员可操作。 */} + {/* 发布 / 取消发布 / 审核中撤回:本人的链条或管理员可操作。 */} {canManage && ( )} {/* 设置(编辑):本人的链条或管理员;普通用户看别人的链条只读浏览。 */} diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts index 03651596..c974ebd6 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -100,6 +100,12 @@ export interface BusinessChain { gatherFirst?: boolean; /** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */ isPublic?: boolean; + /** + * 「待审核」标记:true = 已在编辑器点「发布 → 确认审核」提交审核,等待管理员在 + * 业务链条列表「待审核」筛选里发布;管理员发布时清回 false 并置 isPublic=true。 + * 缺省 / false = 未在审核中(草稿或已发布)。 + */ + pendingReview?: boolean; /** * 启停状态。缺省 / undefined = 启用(存量链条无该字段时按启用处理)。 * 停用后管理弹窗仍可见,选链弹窗 / 公共入口不再选用。 diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index 20aa42fe..2527bd2d 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -66,6 +66,15 @@ import { import { CSS } from "@dnd-kit/utilities"; import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -100,6 +109,7 @@ import { listChains, optimizeChainPrompt, setChainPublic, + submitChainForReview, updateChain, } from "@/roundtable-planning/api/chains"; import { @@ -217,6 +227,10 @@ export default function BusinessChainEditorPage() { const [currentChainId, setCurrentChainId] = useState(null); // 当前编辑链条的发布状态 / 是否本人所有(发布按钮 + 只读保护用)。 const [currentChainPublic, setCurrentChainPublic] = useState(false); + // 「待审核」状态:true = 已提交审核、等待管理员发布(发布按钮变「审核中」)。 + const [currentChainPending, setCurrentChainPending] = useState(false); + // 发布审核确认弹窗(发布前选择「确认审核」或「直接发布」)。 + const [reviewDialogOpen, setReviewDialogOpen] = useState(false); const [currentChainMine, setCurrentChainMine] = useState(true); // 管理员:可编辑 / 发布 / 删除任意账号的链条(与「全部链条」筛选配套)。 const isAdmin = getStoredAuth()?.system_role === "admin"; @@ -349,6 +363,7 @@ export default function BusinessChainEditorPage() { const loadChain = useCallback((chain: BusinessChain) => { setCurrentChainId(chain.id); setCurrentChainPublic(chain.isPublic ?? false); + setCurrentChainPending(chain.pendingReview ?? false); setCurrentChainMine(chain.isMine !== false); setTitle(chain.title); setDescription(chain.description ?? ""); @@ -362,6 +377,7 @@ export default function BusinessChainEditorPage() { const startNewChain = useCallback(() => { setCurrentChainId(null); setCurrentChainPublic(false); + setCurrentChainPending(false); setCurrentChainMine(true); setTitle(""); setDescription(""); @@ -476,19 +492,6 @@ export default function BusinessChainEditorPage() { ); }, []); - const setSeatJobResponsibilitiesCb = useCallback((agentId: string, jobResponsibilities: string) => { - setStageGroups((prev) => - prev.map((group) => ({ - ...group, - seats: group.seats.map((seat) => - seat.agent_id === agentId - ? { ...seat, job_responsibilities: jobResponsibilities } - : seat, - ), - })), - ); - }, []); - // ── 提示词 AI 优化(总控提示词 / 各 Stage 阶段目标)──────────────────────── const [optimizingCoordinator, setOptimizingCoordinator] = useState(false); const [goalOptimizingIdx, setGoalOptimizingIdx] = useState(null); @@ -694,7 +697,7 @@ export default function BusinessChainEditorPage() { seat_mode: s.seat_mode ?? null, // 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。 position_id: s.position_id ?? null, - // 业务链席位岗位职责:会商智能体配置面板只读展示。 + // 业务链席位岗位职责:编辑器已不提供输入,保存时原样保留存量值(会商智能体配置面板只读展示)。 job_responsibilities: s.job_responsibilities?.trim() || null, }; }); @@ -777,31 +780,104 @@ export default function BusinessChainEditorPage() { } }, [currentChainId, title, startNewChain, refreshChains]); - // 发布 / 取消发布:把当前链条设为公共(所有账号可见 / 选用)或收回。 + // 发布入口:已发布 → 收回;审核中 → 撤回审核;未发布 → 弹窗选择「确认审核 / 直接发布」。 const handleTogglePublish = useCallback(async () => { if (!currentChainId) { toast.warning("请先保存链条再发布"); return; } - const next = !currentChainPublic; + // 已发布 → 收回为私有(旧行为不变)。 + if (currentChainPublic) { + setPublishing(true); + try { + const updated = await setChainPublic(currentChainId, false); + if (!updated) { + toast.error("链条已不存在,可能被删除"); + startNewChain(); + await refreshChains(); + return; + } + setCurrentChainPublic(updated.isPublic ?? false); + toast.success("已取消发布"); + await refreshChains(); + } catch (err) { + toast.error(`取消发布失败:${err instanceof Error ? err.message : String(err)}`); + } finally { + setPublishing(false); + } + return; + } + // 审核中 → 撤回审核,回到未发布草稿。 + if (currentChainPending) { + if (!window.confirm("该链条正在审核中,确认撤回审核?")) return; + setPublishing(true); + try { + const updated = await updateChain(currentChainId, { pendingReview: false }); + if (!updated) { + toast.error("链条已不存在,可能被删除"); + startNewChain(); + await refreshChains(); + return; + } + setCurrentChainPending(updated.pendingReview ?? false); + toast.success("已撤回审核"); + await refreshChains(); + } catch (err) { + toast.error(`撤回审核失败:${err instanceof Error ? err.message : String(err)}`); + } finally { + setPublishing(false); + } + return; + } + // 未发布 → 弹窗选择审核 / 直接发布。 + setReviewDialogOpen(true); + }, [currentChainId, currentChainPublic, currentChainPending, startNewChain, refreshChains]); + + // 弹窗「确认审核」:提交进入待审核,等待管理员在业务链条列表「待审核」筛选里发布。 + const handleSubmitReview = useCallback(async () => { + if (!currentChainId) return; setPublishing(true); try { - const updated = await setChainPublic(currentChainId, next); + const updated = await submitChainForReview(currentChainId); if (!updated) { toast.error("链条已不存在,可能被删除"); startNewChain(); await refreshChains(); return; } - setCurrentChainPublic(updated.isPublic ?? next); - toast.success(next ? "已发布为公共,所有账号都能查看 / 选用" : "已取消发布"); + setCurrentChainPending(updated.pendingReview ?? true); + setReviewDialogOpen(false); + toast.success("已提交审核,等待管理员在业务链条列表发布"); await refreshChains(); } catch (err) { - toast.error(`${next ? "发布" : "取消发布"}失败:${err instanceof Error ? err.message : String(err)}`); + toast.error(`提交审核失败:${err instanceof Error ? err.message : String(err)}`); } finally { setPublishing(false); } - }, [currentChainId, currentChainPublic, startNewChain, refreshChains]); + }, [currentChainId, startNewChain, refreshChains]); + + // 弹窗「直接发布」:保持旧发布行为,立即对所有账号可见。 + const handleDirectPublish = useCallback(async () => { + if (!currentChainId) return; + setPublishing(true); + try { + const updated = await setChainPublic(currentChainId, true); + if (!updated) { + toast.error("链条已不存在,可能被删除"); + startNewChain(); + await refreshChains(); + return; + } + setCurrentChainPublic(updated.isPublic ?? true); + setReviewDialogOpen(false); + toast.success("已发布为公共,所有账号都能查看 / 选用"); + await refreshChains(); + } catch (err) { + toast.error(`发布失败:${err instanceof Error ? err.message : String(err)}`); + } finally { + setPublishing(false); + } + }, [currentChainId, startNewChain, refreshChains]); // ─── Render ────────────────────────────────────────────────────────── const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…"; @@ -1099,18 +1175,26 @@ export default function BusinessChainEditorPage() { {/* 右侧操作 */}
- {/* 发布 / 取消发布:已保存且本人所有(或管理员)的链条。发布后所有账号可在「公共」筛选查看 / 选用。 */} + {/* 发布:未发布 → 弹窗选「确认审核 / 直接发布」;审核中 → 撤回;已发布 → 收回。 */} {currentChainId && (currentChainMine || isAdmin) && ( )} + + + + + + {/* 页脚(步骤一):分层编排说明。步骤切换走顶部步骤导航。 */} {step === 1 && (
@@ -1484,7 +1594,6 @@ function StagesColumn({ positionConfigMode, positionOptions, onSeatPositionChange, - onSeatJobResponsibilitiesChange, onStageGoalChange, goalOptimizingIdx, onOptimizeGoal, @@ -1509,7 +1618,6 @@ function StagesColumn({ /** Live directory from the administrator's position-role configuration. */ positionOptions: readonly PositionOption[]; onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void; - onSeatJobResponsibilitiesChange: (agentId: string, jobResponsibilities: string) => void; onStageGoalChange: (stageIdx: number, goal: string) => void; /** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */ goalOptimizingIdx: number | null; @@ -1619,7 +1727,6 @@ function StagesColumn({ positionConfigMode={positionConfigMode} positionOptions={positionOptions} onSeatPositionChange={onSeatPositionChange} - onSeatJobResponsibilitiesChange={onSeatJobResponsibilitiesChange} onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)} goalOptimizing={goalOptimizingIdx === stageIdx} anyGoalOptimizing={goalOptimizingIdx !== null} @@ -1655,7 +1762,6 @@ function StageSection({ positionConfigMode, positionOptions, onSeatPositionChange, - onSeatJobResponsibilitiesChange, onGoalChange, goalOptimizing, anyGoalOptimizing, @@ -1680,7 +1786,6 @@ function StageSection({ positionConfigMode: boolean; positionOptions: readonly PositionOption[]; onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void; - onSeatJobResponsibilitiesChange: (agentId: string, jobResponsibilities: string) => void; onGoalChange: (goal: string) => void; /** 本 Stage 的阶段目标正在 AI 优化中。 */ goalOptimizing: boolean; @@ -1848,9 +1953,6 @@ function StageSection({ showPositionConfig={positionConfigMode} positionOptions={positionOptions} onPositionChange={(positionId) => onSeatPositionChange(seat.agent_id, positionId)} - onJobResponsibilitiesChange={(value) => - onSeatJobResponsibilitiesChange(seat.agent_id, value) - } onDetail={() => onSeatDetail(seat.agent_id, seat.name)} /> ))} @@ -1880,7 +1982,6 @@ function SeatStrip({ showPositionConfig, positionOptions, onPositionChange, - onJobResponsibilitiesChange, onDetail, }: { seat: ChainSeat; @@ -1905,7 +2006,6 @@ function SeatStrip({ /** Current position directory, injected by the business-chain editor. */ positionOptions: readonly PositionOption[]; onPositionChange: (positionId: PositionId | null) => void; - onJobResponsibilitiesChange: (jobResponsibilities: string) => void; onDetail: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = @@ -1981,17 +2081,6 @@ function SeatStrip({ {seat.description}
)} -