Merge branch 'master-nj' into 'master'
feat: 业务链条发布审核流程 + 编辑器去除岗位职责输入框 See merge request knowledge-base-bj/deerflow-code!31
This commit is contained in:
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)
|
||||||
|
|||||||
+7
-5
@@ -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")
|
||||||
|
|||||||
+4
-3
@@ -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(
|
||||||
|
|||||||
+6
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
+10
@@ -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()
|
||||||
Reference in new issue
Block a user