Merge branch 'master-nj' into 'master'

feat: 业务链条发布审核流程 + 编辑器去除岗位职责输入框

See merge request knowledge-base-bj/deerflow-code!31
This commit is contained in:
董宏凯 committed 2026-08-25 09:35:16 +00:00
commit d87dfec2de
14 files changed
+529 -119

No files matched your search

@@ -366,10 +366,15 @@ export function ArtifactFileDetail({
!useWriteFileStreaming && !useWriteFileStreaming &&
!isSkillFile && !isSkillFile &&
!isMock; !isMock;
// 「AI 写作」md 展示开关:只要是可编辑的 markdown 就提供「AI 写作」视图(在 源码/预览 旁多一个 // 「AI 写作」md 展示开关:在 源码/预览 旁多一个切换项,支持划词「AI 改写」。
// 切换项),用户可随时切过去体验划词「AI 改写」。默认仍是「预览」,避免编辑器在内容加载期间 // 深度研究等 write-file 虚拟预览没有 artifacts PATCH,但仍应提供该视图(本地 draft);
// 挂载导致白屏 + 避免复杂 md(表格/公式/HTML)误入有损编辑。 // 流式写入中不挂载,避免编辑器在内容加载期间白屏。
const canUseAiEditor = canEditArtifact; const canUseAiEditor =
language === "markdown" &&
!isSkillFile &&
!isMock &&
!thread.isLoading &&
(canEditArtifact || useWriteFileStreaming);
const [draftContent, setDraftContent] = useState(""); const [draftContent, setDraftContent] = useState("");
const [hasLocalEdits, setHasLocalEdits] = useState(false); const [hasLocalEdits, setHasLocalEdits] = useState(false);
const [documentRewriteDialogOpen, setDocumentRewriteDialogOpen] = useState(false); const [documentRewriteDialogOpen, setDocumentRewriteDialogOpen] = useState(false);
@@ -393,7 +398,9 @@ export function ArtifactFileDetail({
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4; const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
const displayContent = documentRewriteActive const displayContent = documentRewriteActive
? documentRewrite.run.draft ? documentRewrite.run.draft
: (canEditArtifact ? draftContent : (content ?? "")); : (canEditArtifact || (canUseAiEditor && hasLocalEdits)
? draftContent
: (content ?? ""));
const artifactContentPending = const artifactContentPending =
isCodeFile && isCodeFile &&
!useWriteFileStreaming && !useWriteFileStreaming &&
@@ -443,17 +450,26 @@ export function ArtifactFileDetail({
setViewMode("code"); setViewMode("code");
}, [documentRewriteActive]); }, [documentRewriteActive]);
useEffect(() => {
if (viewMode !== "edit" || canUseAiEditor) return;
setViewMode(isSupportPreview ? "preview" : "code");
}, [canUseAiEditor, isSupportPreview, viewMode]);
useEffect(() => { useEffect(() => {
setDraftContent(content ?? ""); setDraftContent(content ?? "");
setHasLocalEdits(false); setHasLocalEdits(false);
}, [filepathFromProps]); }, [filepathFromProps]);
useEffect(() => { useEffect(() => {
if (!canEditArtifact || hasLocalEdits || documentRewriteActive) { if (
(!canEditArtifact && !canUseAiEditor) ||
hasLocalEdits ||
documentRewriteActive
) {
return; return;
} }
setDraftContent(content ?? ""); setDraftContent(content ?? "");
}, [canEditArtifact, content, documentRewriteActive, hasLocalEdits]); }, [canEditArtifact, canUseAiEditor, content, documentRewriteActive, hasLocalEdits]);
useEffect(() => { useEffect(() => {
if ( if (
@@ -1709,8 +1725,9 @@ export function ArtifactFileDetail({
)} )}
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && ( {viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
<MarkdownAiEditor <MarkdownAiEditor
// 编辑用原始内容(与源码模式一致,不脱敏);保存仍走头部「保存 Markdown」按钮。 // 编辑用原始内容(与源码模式一致,不脱敏);有 artifacts 保存能力时走头部「保存 Markdown」。
markdown={displayContent ?? ""} // write-file 虚拟预览(如深度研究)仅本地 draft,切换预览/源码仍可见本次改动。
markdown={(hasLocalEdits || canEditArtifact ? draftContent : content) ?? ""}
onChange={(value) => { onChange={(value) => {
setDraftContent(value); setDraftContent(value);
setHasLocalEdits(true); setHasLocalEdits(true);
@@ -55,15 +55,18 @@ export interface ChainUpdatePayload {
isPublic?: boolean; isPublic?: boolean;
/** 启停开关:true 启用 / false 停用;omit 不改。仅 owner / 管理员生效。 */ /** 启停开关:true 启用 / false 停用;omit 不改。仅 owner / 管理员生效。 */
enabled?: boolean; enabled?: boolean;
/** 「待审核」开关:true 提交审核 / false 清除(管理员发布时随 isPublic=true 一起提交);omit 不改。 */
pendingReview?: boolean;
} }
/** /**
* 列表筛选范围: * 列表筛选范围:
* - `mine` = 我的; * - `mine` = 我的;
* - `public` = 所有已发布的公共链条; * - `public` = 所有已发布的公共链条;
* - `pending` = 所有待审核(pendingReview=true)的链条(**仅管理员**;后端对非管理员退化为 `mine`);
* - `all` = 所有账号的全部链条(**仅管理员**;后端对非管理员请求 `all` 退化为 `mine`)。 * - `all` = 所有账号的全部链条(**仅管理员**;后端对非管理员请求 `all` 退化为 `mine`)。
*/ */
export type ChainScope = "mine" | "public" | "all"; export type ChainScope = "mine" | "public" | "pending" | "all";
const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`; const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`;
@@ -91,6 +94,7 @@ interface ServerChain {
gather_first?: boolean; gather_first?: boolean;
is_public?: boolean; is_public?: boolean;
enabled?: boolean; enabled?: boolean;
pending_review?: boolean;
is_mine?: boolean; is_mine?: boolean;
owner_name?: string | null; owner_name?: string | null;
created_at: string | null; created_at: string | null;
@@ -110,6 +114,8 @@ function toChain(s: ServerChain): BusinessChain {
gatherFirst: s.gather_first ?? false, gatherFirst: s.gather_first ?? false,
isPublic: s.is_public ?? false, isPublic: s.is_public ?? false,
enabled: s.enabled ?? true, enabled: s.enabled ?? true,
// 「待审核」标记:缺省 false(旧响应兼容)。
pendingReview: s.pending_review ?? false,
// 后端缺省时按「我的」处理(向后兼容旧响应)。 // 后端缺省时按「我的」处理(向后兼容旧响应)。
isMine: s.is_mine ?? true, isMine: s.is_mine ?? true,
ownerName: s.owner_name ?? undefined, ownerName: s.owner_name ?? undefined,
@@ -146,6 +152,7 @@ function fromUpdate(p: ChainUpdatePayload) {
if (p.gatherFirst !== undefined) out.gather_first = p.gatherFirst; if (p.gatherFirst !== undefined) out.gather_first = p.gatherFirst;
if (p.isPublic !== undefined) out.is_public = p.isPublic; if (p.isPublic !== undefined) out.is_public = p.isPublic;
if (p.enabled !== undefined) out.enabled = p.enabled; if (p.enabled !== undefined) out.enabled = p.enabled;
if (p.pendingReview !== undefined) out.pending_review = p.pendingReview;
return out; return out;
} }
@@ -181,6 +188,16 @@ export async function setChainEnabled(id: string, enabled: boolean): Promise<Bus
return updateChain(id, { enabled }); return updateChain(id, { enabled });
} }
/** 提交审核(owner 在编辑器发起):进入「待审核」,等待管理员发布。返回更新后的链条,404 返回 null。 */
export async function submitChainForReview(id: string): Promise<BusinessChain | null> {
return updateChain(id, { pendingReview: true });
}
/** 管理员发布「待审核」链条:正式发布为公共并清除待审核标记。返回更新后的链条,404 返回 null。 */
export async function approveChainReview(id: string): Promise<BusinessChain | null> {
return updateChain(id, { isPublic: true, pendingReview: false });
}
/** Fetch one chain. Returns null on 404. */ /** Fetch one chain. Returns null on 404. */
export async function getChain(id: string): Promise<BusinessChain | null> { export async function getChain(id: string): Promise<BusinessChain | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" }); const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
@@ -36,7 +36,7 @@ import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { listAgents } from "@/core/agents"; 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 { chainTagClasses, isChainEnabled, type BusinessChain } from "../lib/business-chain";
import { getAvatarTypeFor } from "../lib/roundtable-constants"; import { getAvatarTypeFor } from "../lib/roundtable-constants";
@@ -84,6 +84,8 @@ export function BusinessChainListBody({
const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>("all"); const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>("all");
const [publishingId, setPublishingId] = useState<string | null>(null); const [publishingId, setPublishingId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null); const [togglingId, setTogglingId] = useState<string | null>(null);
// 发布审核选择弹窗的目标链条(null = 关闭)。草稿点「发布」先选 确认审核 / 直接发布。
const [reviewTarget, setReviewTarget] = useState<BusinessChain | null>(null);
// 实时名称表(id 小写 → 最新 name):用于把席位的落库快照名刷新成最新(agent 改名同步)。 // 实时名称表(id 小写 → 最新 name):用于把席位的落库快照名刷新成最新(agent 改名同步)。
const [nameById, setNameById] = useState<Map<string, string>>(new Map()); const [nameById, setNameById] = useState<Map<string, string>>(new Map());
@@ -140,25 +142,89 @@ export function BusinessChainListBody({
}; };
}, [active, scope]); }, [active, scope]);
// 发布入口:已发布 → 直接取消发布(旧行为);审核中 → 撤回审核;草稿 → 弹窗选
// 「确认审核 / 直接发布」(与链条编辑页一致)。
const handleTogglePublic = useCallback( const handleTogglePublic = useCallback(
async (chain: BusinessChain) => { async (chain: BusinessChain) => {
setPublishingId(chain.id); // 审核中 → 撤回审核,回到草稿。
try { if (!chain.isPublic && chain.pendingReview) {
const updated = await setChainPublic(chain.id, !chain.isPublic); if (!window.confirm(`「${chain.title || "未命名链条"}」正在审核中,确认撤回审核?`)) return;
if (updated) { setPublishingId(chain.id);
setChains((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); try {
} else { const updated = await updateChain(chain.id, { pendingReview: false });
await refresh(scope); 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) { return;
setError(err instanceof Error ? err.message : String(err));
} finally {
setPublishingId(null);
} }
// 已发布 → 直接取消发布。
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], [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( const handleToggleEnabled = useCallback(
async (chain: BusinessChain) => { async (chain: BusinessChain) => {
setTogglingId(chain.id); setTogglingId(chain.id);
@@ -273,6 +339,39 @@ export function BusinessChainListBody({
</div> </div>
)} )}
</div> </div>
{/* 发布审核选择弹窗:确认审核 → 待审核(管理员发布);直接发布 → 立即公共可见。 */}
<Dialog open={reviewTarget !== null} onOpenChange={(o) => !o && setReviewTarget(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GlobeIcon className="size-4 text-primary" />
发布业务链条
</DialogTitle>
<DialogDescription>
是否需要审核?确认审核后「{reviewTarget?.title || "未命名链条"}」将进入「待审核」,由管理员在业务链条列表中发布;直接发布则立即对所有账号可见 / 选用。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setReviewTarget(null)}>
取消
</Button>
<Button
variant="secondary"
onClick={handleDirectPublish}
disabled={reviewTarget ? publishingId === reviewTarget.id : true}
>
直接发布
</Button>
<Button
onClick={handleSubmitReview}
disabled={reviewTarget ? publishingId === reviewTarget.id : true}
>
确认审核
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
); );
} }
@@ -355,6 +454,11 @@ function ChainRow({
公共 公共
</span> </span>
)} )}
{chain.pendingReview && (
<span className="shrink-0 rounded-full bg-amber-400/10 px-2 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-300">
待审核
</span>
)}
{!enabled && ( {!enabled && (
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground"> <span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
已停用 已停用
@@ -375,13 +479,20 @@ function ChainRow({
</span> </span>
</label> </label>
)} )}
{/* 发布 / 取消发布:本人的链条或管理员可操作。 */} {/* 发布 / 取消发布 / 审核中撤回:本人的链条或管理员可操作。 */}
{canManage && ( {canManage && (
<Button <Button
variant={chain.isPublic ? "secondary" : "outline"} variant={chain.isPublic ? "secondary" : "outline"}
size="sm" size="sm"
onClick={onTogglePublic} onClick={onTogglePublic}
disabled={publishing} disabled={publishing}
title={
chain.isPublic
? "取消发布,收回为私有"
: chain.pendingReview
? "审核中,点击撤回审核"
: "发布:可选提交审核(管理员发布)或直接发布"
}
className="h-8 gap-1.5 px-3 text-sm" className="h-8 gap-1.5 px-3 text-sm"
> >
{publishing ? ( {publishing ? (
@@ -389,7 +500,7 @@ function ChainRow({
) : ( ) : (
<GlobeIcon className="size-4" /> <GlobeIcon className="size-4" />
)} )}
{chain.isPublic ? "取消发布" : "发布"} {chain.isPublic ? "取消发布" : chain.pendingReview ? "审核中" : "发布"}
</Button> </Button>
)} )}
{/* 设置(编辑):本人的链条或管理员;普通用户看别人的链条只读浏览。 */} {/* 设置(编辑):本人的链条或管理员;普通用户看别人的链条只读浏览。 */}
@@ -100,6 +100,12 @@ export interface BusinessChain {
gatherFirst?: boolean; gatherFirst?: boolean;
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */ /** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
isPublic?: boolean; isPublic?: boolean;
/**
* 「待审核」标记:true = 已在编辑器点「发布 → 确认审核」提交审核,等待管理员在
* 业务链条列表「待审核」筛选里发布;管理员发布时清回 false 并置 isPublic=true。
* 缺省 / false = 未在审核中(草稿或已发布)。
*/
pendingReview?: boolean;
/** /**
* 启停状态。缺省 / undefined = 启用(存量链条无该字段时按启用处理)。 * 启停状态。缺省 / undefined = 启用(存量链条无该字段时按启用处理)。
* 停用后管理弹窗仍可见,选链弹窗 / 公共入口不再选用。 * 停用后管理弹窗仍可见,选链弹窗 / 公共入口不再选用。
@@ -66,6 +66,15 @@ import {
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -100,6 +109,7 @@ import {
listChains, listChains,
optimizeChainPrompt, optimizeChainPrompt,
setChainPublic, setChainPublic,
submitChainForReview,
updateChain, updateChain,
} from "@/roundtable-planning/api/chains"; } from "@/roundtable-planning/api/chains";
import { import {
@@ -217,6 +227,10 @@ export default function BusinessChainEditorPage() {
const [currentChainId, setCurrentChainId] = useState<string | null>(null); const [currentChainId, setCurrentChainId] = useState<string | null>(null);
// 当前编辑链条的发布状态 / 是否本人所有(发布按钮 + 只读保护用)。 // 当前编辑链条的发布状态 / 是否本人所有(发布按钮 + 只读保护用)。
const [currentChainPublic, setCurrentChainPublic] = useState(false); const [currentChainPublic, setCurrentChainPublic] = useState(false);
// 「待审核」状态:true = 已提交审核、等待管理员发布(发布按钮变「审核中」)。
const [currentChainPending, setCurrentChainPending] = useState(false);
// 发布审核确认弹窗(发布前选择「确认审核」或「直接发布」)。
const [reviewDialogOpen, setReviewDialogOpen] = useState(false);
const [currentChainMine, setCurrentChainMine] = useState(true); const [currentChainMine, setCurrentChainMine] = useState(true);
// 管理员:可编辑 / 发布 / 删除任意账号的链条(与「全部链条」筛选配套)。 // 管理员:可编辑 / 发布 / 删除任意账号的链条(与「全部链条」筛选配套)。
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
@@ -349,6 +363,7 @@ export default function BusinessChainEditorPage() {
const loadChain = useCallback((chain: BusinessChain) => { const loadChain = useCallback((chain: BusinessChain) => {
setCurrentChainId(chain.id); setCurrentChainId(chain.id);
setCurrentChainPublic(chain.isPublic ?? false); setCurrentChainPublic(chain.isPublic ?? false);
setCurrentChainPending(chain.pendingReview ?? false);
setCurrentChainMine(chain.isMine !== false); setCurrentChainMine(chain.isMine !== false);
setTitle(chain.title); setTitle(chain.title);
setDescription(chain.description ?? ""); setDescription(chain.description ?? "");
@@ -362,6 +377,7 @@ export default function BusinessChainEditorPage() {
const startNewChain = useCallback(() => { const startNewChain = useCallback(() => {
setCurrentChainId(null); setCurrentChainId(null);
setCurrentChainPublic(false); setCurrentChainPublic(false);
setCurrentChainPending(false);
setCurrentChainMine(true); setCurrentChainMine(true);
setTitle(""); setTitle("");
setDescription(""); 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 阶段目标)──────────────────────── // ── 提示词 AI 优化(总控提示词 / 各 Stage 阶段目标)────────────────────────
const [optimizingCoordinator, setOptimizingCoordinator] = useState(false); const [optimizingCoordinator, setOptimizingCoordinator] = useState(false);
const [goalOptimizingIdx, setGoalOptimizingIdx] = useState<number | null>(null); const [goalOptimizingIdx, setGoalOptimizingIdx] = useState<number | null>(null);
@@ -694,7 +697,7 @@ export default function BusinessChainEditorPage() {
seat_mode: s.seat_mode ?? null, seat_mode: s.seat_mode ?? null,
// 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。 // 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。
position_id: s.position_id ?? null, position_id: s.position_id ?? null,
// 业务链席位岗位职责:会商智能体配置面板只读展示。 // 业务链席位岗位职责:编辑器已不提供输入,保存时原样保留存量值(会商智能体配置面板只读展示)。
job_responsibilities: s.job_responsibilities?.trim() || null, job_responsibilities: s.job_responsibilities?.trim() || null,
}; };
}); });
@@ -777,31 +780,104 @@ export default function BusinessChainEditorPage() {
} }
}, [currentChainId, title, startNewChain, refreshChains]); }, [currentChainId, title, startNewChain, refreshChains]);
// 发布 / 取消发布:把当前链条设为公共(所有账号可见 / 选用)或收回。 // 发布入口:已发布 → 收回;审核中 → 撤回审核;未发布 → 弹窗选择「确认审核 / 直接发布」。
const handleTogglePublish = useCallback(async () => { const handleTogglePublish = useCallback(async () => {
if (!currentChainId) { if (!currentChainId) {
toast.warning("请先保存链条再发布"); toast.warning("请先保存链条再发布");
return; 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); setPublishing(true);
try { try {
const updated = await setChainPublic(currentChainId, next); const updated = await submitChainForReview(currentChainId);
if (!updated) { if (!updated) {
toast.error("链条已不存在,可能被删除"); toast.error("链条已不存在,可能被删除");
startNewChain(); startNewChain();
await refreshChains(); await refreshChains();
return; return;
} }
setCurrentChainPublic(updated.isPublic ?? next); setCurrentChainPending(updated.pendingReview ?? true);
toast.success(next ? "已发布为公共,所有账号都能查看 / 选用" : "已取消发布"); setReviewDialogOpen(false);
toast.success("已提交审核,等待管理员在业务链条列表发布");
await refreshChains(); await refreshChains();
} catch (err) { } catch (err) {
toast.error(`${next ? "发布" : "取消发布"}失败:${err instanceof Error ? err.message : String(err)}`); toast.error(`提交审核失败:${err instanceof Error ? err.message : String(err)}`);
} finally { } finally {
setPublishing(false); 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 ────────────────────────────────────────────────────────── // ─── Render ──────────────────────────────────────────────────────────
const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…"; const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…";
@@ -1099,18 +1175,26 @@ export default function BusinessChainEditorPage() {
{/* 右侧操作 */} {/* 右侧操作 */}
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{/* 发布 / 取消发布:已保存且本人所有(或管理员)的链条。发布后所有账号可在「公共」筛选查看 / 选用。 */} {/* 发布:未发布 → 弹窗选「确认审核 / 直接发布」;审核中 → 撤回;已发布 → 收回。 */}
{currentChainId && (currentChainMine || isAdmin) && ( {currentChainId && (currentChainMine || isAdmin) && (
<button <button
type="button" type="button"
onClick={handleTogglePublish} onClick={handleTogglePublish}
disabled={publishing} disabled={publishing}
title={currentChainPublic ? "已发布为公共,点击收回为私有" : "发布为公共,所有账号都能查看 / 选用"} title={
currentChainPublic
? "已发布为公共,点击收回为私有"
: currentChainPending
? "审核中,点击撤回审核"
: "发布为公共:可选提交审核(管理员发布)或直接发布"
}
className={cn( 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", "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 currentChainPublic
? "border-emerald-400/50 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/15 dark:text-emerald-400" ? "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", : currentChainPending
? "border-amber-400/50 bg-amber-400/10 text-amber-600 hover:bg-amber-400/15 dark:text-amber-300"
: "border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground",
R_CTRL, R_CTRL,
)} )}
> >
@@ -1119,7 +1203,7 @@ export default function BusinessChainEditorPage() {
) : ( ) : (
<Globe className="size-4" /> <Globe className="size-4" />
)} )}
{currentChainPublic ? "已发布" : "发布"} {currentChainPublic ? "已发布" : currentChainPending ? "审核中" : "发布"}
</button> </button>
)} )}
<button <button
@@ -1299,7 +1383,6 @@ export default function BusinessChainEditorPage() {
positionConfigMode={positionConfigMode} positionConfigMode={positionConfigMode}
positionOptions={positionOptions} positionOptions={positionOptions}
onSeatPositionChange={setSeatPositionCb} onSeatPositionChange={setSeatPositionCb}
onSeatJobResponsibilitiesChange={setSeatJobResponsibilitiesCb}
onStageGoalChange={setGoal} onStageGoalChange={setGoal}
goalOptimizingIdx={goalOptimizingIdx} goalOptimizingIdx={goalOptimizingIdx}
onOptimizeGoal={handleOptimizeStageGoal} onOptimizeGoal={handleOptimizeStageGoal}
@@ -1354,6 +1437,33 @@ export default function BusinessChainEditorPage() {
}} }}
/> />
{/* 发布审核确认弹窗:确认审核 → 进入「待审核」由管理员发布;直接发布 → 立即公共可见。 */}
<Dialog open={reviewDialogOpen} onOpenChange={setReviewDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Globe className="size-4 text-primary" />
发布业务链条
</DialogTitle>
<DialogDescription>
是否需要审核?确认审核后该链条将进入「待审核」,由管理员在业务链条列表中发布;
直接发布则立即对所有账号可见 / 选用。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setReviewDialogOpen(false)}>
取消
</Button>
<Button variant="secondary" onClick={handleDirectPublish} disabled={publishing}>
直接发布
</Button>
<Button onClick={handleSubmitReview} disabled={publishing}>
确认审核
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 页脚(步骤一):分层编排说明。步骤切换走顶部步骤导航。 */} {/* 页脚(步骤一):分层编排说明。步骤切换走顶部步骤导航。 */}
{step === 1 && ( {step === 1 && (
<footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground"> <footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground">
@@ -1484,7 +1594,6 @@ function StagesColumn({
positionConfigMode, positionConfigMode,
positionOptions, positionOptions,
onSeatPositionChange, onSeatPositionChange,
onSeatJobResponsibilitiesChange,
onStageGoalChange, onStageGoalChange,
goalOptimizingIdx, goalOptimizingIdx,
onOptimizeGoal, onOptimizeGoal,
@@ -1509,7 +1618,6 @@ function StagesColumn({
/** Live directory from the administrator's position-role configuration. */ /** Live directory from the administrator's position-role configuration. */
positionOptions: readonly PositionOption[]; positionOptions: readonly PositionOption[];
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void; onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
onSeatJobResponsibilitiesChange: (agentId: string, jobResponsibilities: string) => void;
onStageGoalChange: (stageIdx: number, goal: string) => void; onStageGoalChange: (stageIdx: number, goal: string) => void;
/** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */ /** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */
goalOptimizingIdx: number | null; goalOptimizingIdx: number | null;
@@ -1619,7 +1727,6 @@ function StagesColumn({
positionConfigMode={positionConfigMode} positionConfigMode={positionConfigMode}
positionOptions={positionOptions} positionOptions={positionOptions}
onSeatPositionChange={onSeatPositionChange} onSeatPositionChange={onSeatPositionChange}
onSeatJobResponsibilitiesChange={onSeatJobResponsibilitiesChange}
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)} onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
goalOptimizing={goalOptimizingIdx === stageIdx} goalOptimizing={goalOptimizingIdx === stageIdx}
anyGoalOptimizing={goalOptimizingIdx !== null} anyGoalOptimizing={goalOptimizingIdx !== null}
@@ -1655,7 +1762,6 @@ function StageSection({
positionConfigMode, positionConfigMode,
positionOptions, positionOptions,
onSeatPositionChange, onSeatPositionChange,
onSeatJobResponsibilitiesChange,
onGoalChange, onGoalChange,
goalOptimizing, goalOptimizing,
anyGoalOptimizing, anyGoalOptimizing,
@@ -1680,7 +1786,6 @@ function StageSection({
positionConfigMode: boolean; positionConfigMode: boolean;
positionOptions: readonly PositionOption[]; positionOptions: readonly PositionOption[];
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void; onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
onSeatJobResponsibilitiesChange: (agentId: string, jobResponsibilities: string) => void;
onGoalChange: (goal: string) => void; onGoalChange: (goal: string) => void;
/** 本 Stage 的阶段目标正在 AI 优化中。 */ /** 本 Stage 的阶段目标正在 AI 优化中。 */
goalOptimizing: boolean; goalOptimizing: boolean;
@@ -1848,9 +1953,6 @@ function StageSection({
showPositionConfig={positionConfigMode} showPositionConfig={positionConfigMode}
positionOptions={positionOptions} positionOptions={positionOptions}
onPositionChange={(positionId) => onSeatPositionChange(seat.agent_id, positionId)} onPositionChange={(positionId) => onSeatPositionChange(seat.agent_id, positionId)}
onJobResponsibilitiesChange={(value) =>
onSeatJobResponsibilitiesChange(seat.agent_id, value)
}
onDetail={() => onSeatDetail(seat.agent_id, seat.name)} onDetail={() => onSeatDetail(seat.agent_id, seat.name)}
/> />
))} ))}
@@ -1880,7 +1982,6 @@ function SeatStrip({
showPositionConfig, showPositionConfig,
positionOptions, positionOptions,
onPositionChange, onPositionChange,
onJobResponsibilitiesChange,
onDetail, onDetail,
}: { }: {
seat: ChainSeat; seat: ChainSeat;
@@ -1905,7 +2006,6 @@ function SeatStrip({
/** Current position directory, injected by the business-chain editor. */ /** Current position directory, injected by the business-chain editor. */
positionOptions: readonly PositionOption[]; positionOptions: readonly PositionOption[];
onPositionChange: (positionId: PositionId | null) => void; onPositionChange: (positionId: PositionId | null) => void;
onJobResponsibilitiesChange: (jobResponsibilities: string) => void;
onDetail: () => void; onDetail: () => void;
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
@@ -1981,17 +2081,6 @@ function SeatStrip({
{seat.description} {seat.description}
</div> </div>
)} )}
<label className="mt-1.5 flex flex-col gap-0.5">
<span className="text-[10px] font-medium text-muted-foreground">岗位职责</span>
<textarea
value={seat.job_responsibilities ?? ""}
onChange={(event) => onJobResponsibilitiesChange(event.currentTarget.value)}
onPointerDown={(event) => event.stopPropagation()}
placeholder="填写该席位在本业务链中的岗位职责"
rows={2}
className="w-full resize-none rounded border border-border bg-background px-2 py-1 text-[11px] leading-4 text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-primary/50"
/>
</label>
</div> </div>
{/* 岗位协同从专用入口进来时才展示。使用 TDesign Select,未分配不会阻塞普通圆桌使用。 */} {/* 岗位协同从专用入口进来时才展示。使用 TDesign Select,未分配不会阻塞普通圆桌使用。 */}
@@ -66,7 +66,7 @@ import { useRouteNavigate } from "@/core/route-base";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree"; import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { deleteChain, listChains, setChainPublic, type ChainScope } from "../api/chains"; import { approveChainReview, deleteChain, listChains, setChainPublic, type ChainScope } from "../api/chains";
import { chainTagClasses, type BusinessChain } from "../lib/business-chain"; import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
import { getAvatarTypeFor } from "../lib/roundtable-constants"; import { getAvatarTypeFor } from "../lib/roundtable-constants";
@@ -165,18 +165,30 @@ export default function BusinessChainListPage() {
}, []); }, []);
// ── 操作:发布 / 取消发布 ──────────────────────────────────────────────── // ── 操作:发布 / 取消发布 ────────────────────────────────────────────────
// 「待审核」筛选里 = 管理员审核发布(is_public=true 并清除待审核标记);
// 其它筛选里 = 常规发布 / 取消发布。
const handleTogglePublic = useCallback( const handleTogglePublic = useCallback(
async (chain: BusinessChain) => { async (chain: BusinessChain) => {
setPublishingId(chain.id); setPublishingId(chain.id);
try { try {
await setChainPublic(chain.id, !chain.isPublic); const updated =
await refresh(scope); scope === "pending"
await loadStats(); ? await approveChainReview(chain.id)
toast.success( : await setChainPublic(chain.id, !chain.isPublic);
chain.isPublic if (updated) {
? `已取消发布「${chain.title || "未命名链条"}」` await refresh(scope);
: `「${chain.title || "未命名链条"}」已发布到公共选用库`, await loadStats();
); toast.success(
scope === "pending"
? `「${chain.title || "未命名链条"}」审核通过并已发布到公共选用库`
: chain.isPublic
? `已取消发布「${chain.title || "未命名链条"}」`
: `「${chain.title || "未命名链条"}」已发布到公共选用库`,
);
} else {
toast.error("链条已不存在,可能被删除");
await refresh(scope);
}
} catch (err) { } catch (err) {
toast.error(`操作失败:${err instanceof Error ? err.message : String(err)}`); toast.error(`操作失败:${err instanceof Error ? err.message : String(err)}`);
} finally { } finally {
@@ -317,13 +329,18 @@ export default function BusinessChainListPage() {
{/* 筛选工具栏 */} {/* 筛选工具栏 */}
<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="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]"> <div className="flex self-start rounded-xl bg-zinc-100 p-1 dark:bg-[#0C1836]">
{( {(
[ [
["mine", "我的链条"], ["mine", "我的链条"],
["public", "公共列表"], ["public", "公共列表"],
...(isAdmin ? ([["all", "全部链条"]] as const) : []), ...(isAdmin
? ([
["pending", "待审核"],
["all", "全部链条"],
] as const)
: []),
] as [ChainScope, string][] ] as [ChainScope, string][]
).map(([key, label]) => ( ).map(([key, label]) => (
<button <button
@@ -596,14 +613,20 @@ function EmptyState({
) : ( ) : (
<> <>
<h3 className="text-base font-bold text-zinc-700 dark:text-[#C5CFE0]"> <h3 className="text-base font-bold text-zinc-700 dark:text-[#C5CFE0]">
{scope === "public" ? "还没有公共链条" : "还没有业务链条"} {scope === "public"
? "还没有公共链条"
: scope === "pending"
? "暂无待审核链条"
: "还没有业务链条"}
</h3> </h3>
<p className="mt-1 pb-4 text-xs leading-relaxed text-zinc-400 dark:text-[#6E88B0]"> <p className="mt-1 pb-4 text-xs leading-relaxed text-zinc-400 dark:text-[#6E88B0]">
{scope === "public" {scope === "public"
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的链条」里发布一条试试。" ? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的链条」里发布一条试试。"
: "业务链条是可复用的有序智能体编排。点右上角「新建链条」去创建第一条。"} : scope === "pending"
? "业务链条在编辑器里点「发布 → 确认审核」后会进入这里,由管理员审核发布。"
: "业务链条是可复用的有序智能体编排。点右上角「新建链条」去创建第一条。"}
</p> </p>
{scope !== "public" && ( {scope !== "public" && scope !== "pending" && (
<button <button
type="button" type="button"
onClick={onCreate} onClick={onCreate}
@@ -663,7 +686,11 @@ function ChainCard({
<UsersIcon className="mr-0.5 inline size-3" /> <UsersIcon className="mr-0.5 inline size-3" />
<span>{chain.seats.length} 席位</span> <span>{chain.seats.length} 席位</span>
</span> </span>
{chain.isPublic ? ( {chain.pendingReview ? (
<span className="rounded-full border border-amber-200/60 bg-amber-50 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:border-[#FAAD14]/30 dark:bg-[#FAAD14]/15 dark:text-[#FAAD14]">
待审核
</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 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>
@@ -940,6 +967,11 @@ function ChainTable({
<td className="px-6 py-4 align-top"> <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]"> <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.title || "未命名链条"}
{chain.pendingReview && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-1.5 py-0.5 text-[9px] font-medium text-amber-700 dark:bg-[#FAAD14]/15 dark:text-[#FAAD14]">
待审核
</span>
)}
{chain.isPublic && ( {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]"> <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" /> <GlobeIcon className="size-2.5" />
@@ -99,7 +99,8 @@ class NodeResponse(BaseModel):
class SessionResponse(BaseModel): class SessionResponse(BaseModel):
id: str id: str
# Older personal rows can already contain a mock task id; task_scoped is # Older personal rows can already contain a mock task id; task_scoped is
# the authoritative opt-in marker for cross-user task sharing. # the authoritative marker for task-bound rows. History is partitioned by
# user, so it only controls snapshot-id locking and list bucketing now.
external_task_id: str | None = None external_task_id: str | None = None
task_scoped: bool = False task_scoped: bool = False
task_snapshot: dict[str, Any] = Field(default_factory=dict) task_snapshot: dict[str, Any] = Field(default_factory=dict)
@@ -248,7 +249,7 @@ def _task_snapshot_for_scope(
*, *,
task_scoped: bool = True, task_scoped: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Lock the snapshot id only for an explicit shared task scope.""" """Lock the snapshot id only for an explicit task-bound scope."""
if not task_scoped or not external_task_id: if not task_scoped or not external_task_id:
return dict(task_snapshot) return dict(task_snapshot)
return {**task_snapshot, "id": external_task_id, "externalTaskId": external_task_id} return {**task_snapshot, "id": external_task_id, "externalTaskId": external_task_id}
@@ -6,7 +6,7 @@ following this order. Bound to the requesting user (not the browser), so it
follows the account across devices/sessions — same model as roundtable drafts. follows the account across devices/sessions — same model as roundtable drafts.
Routes (prefix ``/api/roundtable-chains``): Routes (prefix ``/api/roundtable-chains``):
GET "" list chains (``scope=mine``默认 / ``public`` / ``all`` 仅管理员) GET "" list chains (``scope=mine``默认 / ``public`` / ``pending`` / ``all`` 后两者仅管理员)
POST "" create a chain POST "" create a chain
GET "/{chain_id}" one chain GET "/{chain_id}" one chain
PUT "/{chain_id}" partial update (title / description / seats) PUT "/{chain_id}" partial update (title / description / seats)
@@ -96,6 +96,9 @@ class ChainResponse(BaseModel):
is_public: bool = False is_public: bool = False
# 启停状态:True = 启用(可选入研讨);False = 停用。缺省 True。 # 启停状态:True = 启用(可选入研讨);False = 停用。缺省 True。
enabled: bool = True enabled: bool = True
# 「待审核」标记:True = owner 已提交审核(等待管理员在「待审核」筛选里发布);
# 管理员发布时随 is_public=True 一起清回 False。缺省 False。
pending_review: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。 # 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
is_mine: bool = True is_mine: bool = True
# 配置者(owner)的 user_id 及解析后的可读用户名(email)。owner_name 由路由层 # 配置者(owner)的 user_id 及解析后的可读用户名(email)。owner_name 由路由层
@@ -140,6 +143,9 @@ class ChainUpdateRequest(BaseModel):
is_public: bool | None = None is_public: bool | None = None
# 启停开关(True 启用 / False 停用);仅 owner / 管理员可改。omit 不改。 # 启停开关(True 启用 / False 停用);仅 owner / 管理员可改。omit 不改。
enabled: bool | None = None enabled: bool | None = None
# 「待审核」开关(True = 提交审核,owner 在编辑器发起;False = 清除,管理员发布时
# 随 is_public=True 一起提交)。omit 不改。
pending_review: bool | None = None
# ── helpers ────────────────────────────────────────────────────────────── # ── helpers ──────────────────────────────────────────────────────────────
@@ -303,13 +309,15 @@ async def list_chains(request: Request, scope: str = "mine") -> ChainListRespons
``scope`` 查询参数(筛选): ``scope`` 查询参数(筛选):
- ``mine``(默认):仅当前用户自己的链条; - ``mine``(默认):仅当前用户自己的链条;
- ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨); - ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨);
- ``pending``:所有**待审核**(``pending_review=True``)链条(**仅管理员**;
非管理员请求 ``pending`` 退化为 ``mine``);
- ``all``:所有账号的全部链条(**仅管理员**;非管理员请求 ``all`` 退化为 ``mine``)。 - ``all``:所有账号的全部链条(**仅管理员**;非管理员请求 ``all`` 退化为 ``mine``)。
其余值按 ``mine`` 处理。 其余值按 ``mine`` 处理。
""" """
is_admin = _is_admin(request) is_admin = _is_admin(request)
normalized_scope = scope if scope in ("mine", "public", "all") else "mine" normalized_scope = scope if scope in ("mine", "public", "pending", "all") else "mine"
# 非管理员请求 all → 退化为 mine,绝不越权。 # 非管理员请求 pending / all → 退化为 mine,绝不越权。
if normalized_scope == "all" and not is_admin: if normalized_scope in ("pending", "all") and not is_admin:
normalized_scope = "mine" normalized_scope = "mine"
store = _get_store(request) store = _get_store(request)
user_id = _current_user_id(request) user_id = _current_user_id(request)
@@ -22,9 +22,9 @@ from deerflow.persistence.types import BeijingDateTime, PortableLongText
class PositionRoundtableSessionRow(Base): class PositionRoundtableSessionRow(Base):
__tablename__ = "position_roundtable_sessions" __tablename__ = "position_roundtable_sessions"
__table_args__ = ( __table_args__ = (
# History is read as "all conversations for one task, newest first". # History is read as "one user's conversations for one task, newest
# `user_id` remains audit data only; it must not partition a shared # first". `user_id` partitions visibility; a shared task workspace no
# task workspace. # longer exposes other users' collaboration records.
Index( Index(
"ix_position_roundtable_sessions_task_updated", "ix_position_roundtable_sessions_task_updated",
"external_task_id", "external_task_id",
@@ -33,8 +33,10 @@ class PositionRoundtableSessionRow(Base):
) )
id: Mapped[str] = mapped_column(String(64), primary_key=True) id: Mapped[str] = mapped_column(String(64), primary_key=True)
# Creator audit field. `task_scoped`, not merely external_task_id, opts a # Owner field: every read/write path filters on it. `task_scoped` still
# session into sharing. This keeps historical sidebar/mock rows private. # marks rows created from an explicit route taskId (locking the snapshot
# task id and keeping them out of the unscoped personal list), but it no
# longer grants cross-user access.
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
external_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) external_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
task_scoped: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0") task_scoped: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0")
@@ -135,8 +135,8 @@ class PositionRoundtableRepository:
@staticmethod @staticmethod
def _can_access(row: PositionRoundtableSessionRow, user_id: str) -> bool: def _can_access(row: PositionRoundtableSessionRow, user_id: str) -> bool:
"""Task-bound sessions are shared; unbound legacy sessions stay private.""" """History is partitioned by creator; even task-bound rows stay private."""
return bool(row.task_scoped) or row.user_id == user_id return row.user_id == user_id
@staticmethod @staticmethod
def _session_dict(row: PositionRoundtableSessionRow) -> dict[str, Any]: def _session_dict(row: PositionRoundtableSessionRow) -> dict[str, Any]:
@@ -344,11 +344,12 @@ class PositionRoundtableRepository:
async def list_sessions( async def list_sessions(
self, user_id: str, *, external_task_id: str | None = None, limit: int = 50 self, user_id: str, *, external_task_id: str | None = None, limit: int = 50
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""List shared task history or the caller's legacy personal history.""" """List the caller's own task history or legacy personal history."""
if external_task_id: if external_task_id:
stmt = select(PositionRoundtableSessionRow).where( stmt = select(PositionRoundtableSessionRow).where(
PositionRoundtableSessionRow.external_task_id == external_task_id, PositionRoundtableSessionRow.external_task_id == external_task_id,
PositionRoundtableSessionRow.task_scoped.is_(True), PositionRoundtableSessionRow.task_scoped.is_(True),
PositionRoundtableSessionRow.user_id == user_id,
) )
else: else:
stmt = select(PositionRoundtableSessionRow).where( stmt = select(PositionRoundtableSessionRow).where(
@@ -69,6 +69,12 @@ class RoundtableChainRow(Base):
enabled: Mapped[bool] = mapped_column( enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="1" Boolean, nullable=False, default=True, server_default="1"
) )
# 「待审核」标记:True = owner 已在编辑器提交审核(发布前需管理员在「待审核」筛选里确认
# 发布);管理员发布时置回 False 并同时置 ``is_public=True``。缺省 False,存量链条不受影响。
# 索引以支持管理端「待审核」列表筛选。
pending_review: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0", index=True
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC) BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
) )
@@ -64,6 +64,8 @@ class RoundtableChainRepository:
"is_public": bool(getattr(row, "is_public", False)), "is_public": bool(getattr(row, "is_public", False)),
# 启停状态:缺省 True(存量行 / 补列前视为启用)。 # 启停状态:缺省 True(存量行 / 补列前视为启用)。
"enabled": bool(getattr(row, "enabled", True)), "enabled": bool(getattr(row, "enabled", True)),
# 「待审核」标记:True = owner 已提交审核、等待管理员发布;发布时清回 False。
"pending_review": bool(getattr(row, "pending_review", False)),
"is_mine": (current_user_id is None) or (row.user_id == current_user_id), "is_mine": (current_user_id is None) or (row.user_id == current_user_id),
# 配置者(owner)的 user_id;路由层据此解析成可读用户名(owner_name)。 # 配置者(owner)的 user_id;路由层据此解析成可读用户名(owner_name)。
"owner_id": row.user_id, "owner_id": row.user_id,
@@ -79,12 +81,16 @@ class RoundtableChainRepository:
``scope``: ``scope``:
- ``"mine"``(默认):仅当前用户自己的链条; - ``"mine"``(默认):仅当前用户自己的链条;
- ``"public"``:所有**已发布**(``is_public=True``)链条,含本人发布的; - ``"public"``:所有**已发布**(``is_public=True``)链条,含本人发布的;
- ``"pending"``:所有**待审核**(``pending_review=True``)链条,**仅管理员**(由路由层
校验后传 ``is_admin=True``;非管理员误传则按 ``"mine"`` 退化,绝不越权);
- ``"all"``:所有账号的全部链条(**仅管理员**,由路由层校验后传 ``is_admin=True``; - ``"all"``:所有账号的全部链条(**仅管理员**,由路由层校验后传 ``is_admin=True``;
非管理员误传则按 ``"mine"`` 退化,绝不越权)。 非管理员误传则按 ``"mine"`` 退化,绝不越权)。
其余值按 ``"mine"`` 处理。``is_mine`` 标记每条是否本人所有,供前端判断可否编辑。 其余值按 ``"mine"`` 处理。``is_mine`` 标记每条是否本人所有,供前端判断可否编辑。
""" """
if scope == "public": if scope == "public":
cond = RoundtableChainRow.is_public.is_(True) cond = RoundtableChainRow.is_public.is_(True)
elif scope == "pending" and is_admin:
cond = RoundtableChainRow.pending_review.is_(True)
elif scope == "all" and is_admin: elif scope == "all" and is_admin:
cond = None cond = None
else: else:
@@ -125,6 +131,7 @@ class RoundtableChainRepository:
gather_first=bool(data.get("gather_first") or False), gather_first=bool(data.get("gather_first") or False),
is_public=bool(data.get("is_public") or False), is_public=bool(data.get("is_public") or False),
enabled=True if data.get("enabled") is None else bool(data.get("enabled")), enabled=True if data.get("enabled") is None else bool(data.get("enabled")),
pending_review=bool(data.get("pending_review") or False),
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
@@ -149,6 +156,7 @@ class RoundtableChainRepository:
gather_first=_UNSET, gather_first=_UNSET,
is_public=_UNSET, is_public=_UNSET,
enabled=_UNSET, enabled=_UNSET,
pending_review=_UNSET,
is_admin: bool = False, is_admin: bool = False,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
async with self._sf() as session: async with self._sf() as session:
@@ -176,6 +184,8 @@ class RoundtableChainRepository:
row.is_public = bool(is_public) row.is_public = bool(is_public)
if enabled is not _UNSET and enabled is not None: if enabled is not _UNSET and enabled is not None:
row.enabled = bool(enabled) row.enabled = bool(enabled)
if pending_review is not _UNSET and pending_review is not None:
row.pending_review = bool(pending_review)
row.updated_at = datetime.now(UTC) row.updated_at = datetime.now(UTC)
await session.commit() await session.commit()
await session.refresh(row) await session.refresh(row)
@@ -526,40 +526,48 @@ async def test_conversation_snapshots_round_trip_and_history_delete_removes_node
assert saved_node is not None assert saved_node is not None
assert saved_node["conversation_snapshot"] == node_conversation assert saved_node["conversation_snapshot"] == node_conversation
# Position roundtable history is shared by taskId, so a collaborator can # History is partitioned by creator: another user working the same taskId
# read and remove the same task conversation without being its creator. # can neither read nor remove this conversation.
assert await store.get_session(created["id"], "user-b") is not None assert await store.get_session(created["id"], "user-b") is None
assert await store.delete_session(created["id"], "user-b") assert not await store.delete_session(created["id"], "user-b")
assert await store.get_session(created["id"], "user-a") is not None
assert await store.delete_session(created["id"], "user-a")
assert await store.get_session(created["id"], "user-a") is None assert await store.get_session(created["id"], "user-a") is None
assert await store.list_nodes(created["id"], "user-a") is None assert await store.list_nodes(created["id"], "user-a") is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_task_scoped_history_is_shared_but_never_mixes_tasks(store): async def test_task_scoped_history_is_partitioned_by_user_but_never_mixes_tasks(store):
shared = await store.create_session( creator_session = await store.create_session(
"user-a", "user-a",
external_task_id="external-task-100", external_task_id="external-task-100",
task_snapshot={"id": "external-task-100", "title": "共享任务"}, task_snapshot={"id": "external-task-100", "title": "共享任务"},
) )
await store.create_session(
"user-b",
external_task_id="external-task-200",
task_snapshot={"id": "external-task-200", "title": "另一任务"},
)
visible_to_creator = await store.list_sessions(
"user-a", external_task_id="external-task-100"
)
assert [row["id"] for row in visible_to_creator] == [creator_session["id"]]
# The same taskId no longer exposes another user's rows: a collaborator
# starts from an empty list and only ever sees rows they created.
assert await store.list_sessions("user-b", external_task_id="external-task-100") == []
collaborator_session = await store.create_session(
"user-b",
external_task_id="external-task-100",
task_snapshot={"id": "external-task-100", "title": "协作者自己的会话"},
)
visible_to_collaborator = await store.list_sessions( visible_to_collaborator = await store.list_sessions(
"user-b", external_task_id="external-task-100" "user-b", external_task_id="external-task-100"
) )
assert [row["id"] for row in visible_to_collaborator] == [shared["id"]] assert [row["id"] for row in visible_to_collaborator] == [collaborator_session["id"]]
updated = await store.update_session( assert await store.update_session(
shared["id"], creator_session["id"],
"user-b", "user-b",
task_snapshot={"id": "external-task-100", "title": "协作者已更新"}, task_snapshot={"id": "external-task-100", "title": "协作者已更新"},
) ) is None
assert updated is not None assert (await store.get_session(creator_session["id"], "user-a"))["task_snapshot"]["title"] == "共享任务"
assert updated["task_snapshot"]["title"] == "协作者已更新"
assert (await store.get_session(shared["id"], "user-a"))["task_snapshot"]["title"] == "协作者已更新"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -577,7 +585,7 @@ async def test_unscoped_history_keeps_original_user_isolation(store):
# Old client records could already carry a mock task id. The new database # Old client records could already carry a mock task id. The new database
# marker keeps them personal after the migration; only a new explicit # marker keeps them personal after the migration; only a new explicit
# route taskId creates a shared row. # route taskId creates a task-bound row.
legacy_with_mock_task_id = await store.create_session( legacy_with_mock_task_id = await store.create_session(
"user-a", "user-a",
external_task_id="legacy-mock-task-id", external_task_id="legacy-mock-task-id",
@@ -0,0 +1,102 @@
"""业务链条「发布审核」流程测试。
覆盖:
- store:``pending_review`` 随 create(缺省 False)/ update 持久化;
- store:``scope="pending"`` 仅管理员返回全部待审核链条;非管理员按 ``mine`` 退化;
- 完整审核流:owner 提交审核 → 管理员在 pending 列表看到 → 发布(``is_public=True`` +
``pending_review=False``)后从 pending 列表消失、进入 public 列表。
"""
from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
async def _make_repo():
from deerflow.persistence.base import Base
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow # noqa: F401 注册表
from deerflow.persistence.roundtable_chains.sql import RoundtableChainRepository
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
return RoundtableChainRepository(sf), engine
_SEATS = [
{"agent_id": "a", "name": "情报收集", "description": ""},
{"agent_id": "b", "name": "环境评估", "description": ""},
]
@pytest.mark.asyncio
async def test_pending_review_flag_roundtrip():
repo, engine = await _make_repo()
try:
row = await repo.create_chain("u1", {"id": "c1", "title": "链", "seats": _SEATS})
# 缺省 False。
assert row["pending_review"] is False
# owner 提交审核 → True。
updated = await repo.update_chain("c1", "u1", pending_review=True)
assert updated is not None and updated["pending_review"] is True
# 撤回 / 管理员发布清回 → False。
updated = await repo.update_chain("c1", "u1", pending_review=False)
assert updated is not None and updated["pending_review"] is False
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_scope_pending_lists_all_users_pending_for_admin():
repo, engine = await _make_repo()
try:
await repo.create_chain("u1", {"id": "c1", "title": "U1 待审", "seats": _SEATS})
await repo.create_chain("u1", {"id": "c2", "title": "U1 草稿", "seats": _SEATS})
await repo.create_chain("u2", {"id": "c3", "title": "U2 待审", "seats": _SEATS})
await repo.update_chain("c1", "u1", pending_review=True)
await repo.update_chain("c3", "u2", pending_review=True)
# 管理员 pending → 只含两条待审核链条(跨账号)。
rows = await repo.list_chains("admin", scope="pending", is_admin=True)
assert {r["id"] for r in rows} == {"c1", "c3"}
# 非管理员误传 pending(is_admin=False)→ 退化为 mine:只见自己的(c1/c2),
# 绝不越权看到他人待审核链条。
rows = await repo.list_chains("u1", scope="pending", is_admin=False)
assert {r["id"] for r in rows} == {"c1", "c2"}
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_full_review_flow_owner_submit_admin_publish():
repo, engine = await _make_repo()
try:
await repo.create_chain("u1", {"id": "c1", "title": "走审核的链", "seats": _SEATS})
# ① owner 提交审核。
await repo.update_chain("c1", "u1", pending_review=True)
# ② 管理员在「待审核」列表看到。
pending = await repo.list_chains("admin", scope="pending", is_admin=True)
assert [r["id"] for r in pending] == ["c1"]
# ③ 管理员审核发布:is_public=True + 清除待审核标记。
published = await repo.update_chain(
"c1", "admin", is_public=True, pending_review=False, is_admin=True
)
assert published is not None
assert published["is_public"] is True
assert published["pending_review"] is False
# owner 不变。
assert published["owner_id"] == "u1"
# ④ 发布后:pending 列表为空,public 列表可见(任何账号)。
assert await repo.list_chains("admin", scope="pending", is_admin=True) == []
public = await repo.list_chains("u2", scope="public")
assert [r["id"] for r in public] == ["c1"]
finally:
await engine.dispose()