diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index 0d952f12..47a287d0 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -366,10 +366,15 @@ export function ArtifactFileDetail({ !useWriteFileStreaming && !isSkillFile && !isMock; - // 「AI 写作」md 展示开关:只要是可编辑的 markdown 就提供「AI 写作」视图(在 源码/预览 旁多一个 - // 切换项),用户可随时切过去体验划词「AI 改写」。默认仍是「预览」,避免编辑器在内容加载期间 - // 挂载导致白屏 + 避免复杂 md(表格/公式/HTML)误入有损编辑。 - const canUseAiEditor = canEditArtifact; + // 「AI 写作」md 展示开关:在 源码/预览 旁多一个切换项,支持划词「AI 改写」。 + // 深度研究等 write-file 虚拟预览没有 artifacts PATCH,但仍应提供该视图(本地 draft); + // 流式写入中不挂载,避免编辑器在内容加载期间白屏。 + const canUseAiEditor = + language === "markdown" && + !isSkillFile && + !isMock && + !thread.isLoading && + (canEditArtifact || useWriteFileStreaming); const [draftContent, setDraftContent] = useState(""); const [hasLocalEdits, setHasLocalEdits] = useState(false); const [documentRewriteDialogOpen, setDocumentRewriteDialogOpen] = useState(false); @@ -393,7 +398,9 @@ export function ArtifactFileDetail({ const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4; const displayContent = documentRewriteActive ? documentRewrite.run.draft - : (canEditArtifact ? draftContent : (content ?? "")); + : (canEditArtifact || (canUseAiEditor && hasLocalEdits) + ? draftContent + : (content ?? "")); const artifactContentPending = isCodeFile && !useWriteFileStreaming && @@ -443,17 +450,26 @@ export function ArtifactFileDetail({ setViewMode("code"); }, [documentRewriteActive]); + useEffect(() => { + if (viewMode !== "edit" || canUseAiEditor) return; + setViewMode(isSupportPreview ? "preview" : "code"); + }, [canUseAiEditor, isSupportPreview, viewMode]); + useEffect(() => { setDraftContent(content ?? ""); setHasLocalEdits(false); }, [filepathFromProps]); useEffect(() => { - if (!canEditArtifact || hasLocalEdits || documentRewriteActive) { + if ( + (!canEditArtifact && !canUseAiEditor) || + hasLocalEdits || + documentRewriteActive + ) { return; } setDraftContent(content ?? ""); - }, [canEditArtifact, content, documentRewriteActive, hasLocalEdits]); + }, [canEditArtifact, canUseAiEditor, content, documentRewriteActive, hasLocalEdits]); useEffect(() => { if ( @@ -1709,8 +1725,9 @@ export function ArtifactFileDetail({ )} {viewMode === "edit" && canUseAiEditor && !documentRewriteActive && ( { setDraftContent(value); setHasLocalEdits(true); 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}
)} -