Merge branch 'dhk-1'
This commit is contained in:
commit
6e3896fed3
8 files changed
+197
-75
No files matched your search
@@ -54,8 +54,13 @@ export interface ChainUpdatePayload {
|
|||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 列表筛选范围:`mine` = 我的;`public` = 所有已发布的公共链条。 */
|
/**
|
||||||
export type ChainScope = "mine" | "public";
|
* 列表筛选范围:
|
||||||
|
* - `mine` = 我的;
|
||||||
|
* - `public` = 所有已发布的公共链条;
|
||||||
|
* - `all` = 所有账号的全部链条(**仅管理员**;后端对非管理员请求 `all` 退化为 `mine`)。
|
||||||
|
*/
|
||||||
|
export type ChainScope = "mine" | "public" | "all";
|
||||||
|
|
||||||
const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`;
|
const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`;
|
||||||
|
|
||||||
@@ -146,7 +151,7 @@ function fromUpdate(p: ChainUpdatePayload) {
|
|||||||
* - `"public"`:所有已发布的公共链条(任何账号可见 / 可选入研讨)。
|
* - `"public"`:所有已发布的公共链条(任何账号可见 / 可选入研讨)。
|
||||||
*/
|
*/
|
||||||
export async function listChains(scope: ChainScope = "mine"): Promise<BusinessChain[]> {
|
export async function listChains(scope: ChainScope = "mine"): Promise<BusinessChain[]> {
|
||||||
const url = scope === "public" ? `${BASE()}?scope=public` : BASE();
|
const url = scope === "mine" ? BASE() : `${BASE()}?scope=${scope}`;
|
||||||
const res = await apiFetch(url, { method: "GET" });
|
const res = await apiFetch(url, { method: "GET" });
|
||||||
const data = await readJson<{ chains: ServerChain[] }>(res, "list chains");
|
const data = await readJson<{ chains: ServerChain[] }>(res, "list chains");
|
||||||
return (data.chains ?? []).map(toChain);
|
return (data.chains ?? []).map(toChain);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
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 { getStoredAuth } from "@/core/auth";
|
||||||
import { listAgents } from "@/core/agents";
|
import { listAgents } from "@/core/agents";
|
||||||
|
|
||||||
import { listChains, setChainPublic, type ChainScope } from "../api/chains";
|
import { listChains, setChainPublic, type ChainScope } from "../api/chains";
|
||||||
@@ -71,7 +72,9 @@ export function BusinessChainListBody({
|
|||||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// 筛选:我的 / 公共(已发布)。
|
// 管理员:额外多一个「全部」筛选,且可编辑 / 发布任意账号的链条。
|
||||||
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
|
// 筛选:我的 / 公共(已发布)/(管理员)全部。
|
||||||
const [scope, setScope] = useState<ChainScope>("mine");
|
const [scope, setScope] = useState<ChainScope>("mine");
|
||||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||||
// 实时名称表(id 小写 → 最新 name):用于把席位的落库快照名刷新成最新(agent 改名同步)。
|
// 实时名称表(id 小写 → 最新 name):用于把席位的落库快照名刷新成最新(agent 改名同步)。
|
||||||
@@ -147,9 +150,9 @@ export function BusinessChainListBody({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 筛选:我的 / 公共 */}
|
{/* 筛选:我的 / 公共 /(管理员)全部 */}
|
||||||
<div className="shrink-0 flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
|
<div className="shrink-0 flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
|
||||||
{(["mine", "public"] as const).map((s) => (
|
{((isAdmin ? ["mine", "public", "all"] : ["mine", "public"]) as ChainScope[]).map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -161,7 +164,7 @@ export function BusinessChainListBody({
|
|||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{s === "mine" ? "我的" : "公共"}
|
{s === "mine" ? "我的" : s === "public" ? "公共" : "全部"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -195,6 +198,7 @@ export function BusinessChainListBody({
|
|||||||
<ChainRow
|
<ChainRow
|
||||||
key={chain.id}
|
key={chain.id}
|
||||||
chain={chain}
|
chain={chain}
|
||||||
|
canManage={isAdmin || chain.isMine !== false}
|
||||||
nameById={nameById}
|
nameById={nameById}
|
||||||
publishing={publishingId === chain.id}
|
publishing={publishingId === chain.id}
|
||||||
onSettings={() => onConfigure(chain.id)}
|
onSettings={() => onConfigure(chain.id)}
|
||||||
@@ -245,18 +249,20 @@ export function BusinessChainListDialog({
|
|||||||
|
|
||||||
function ChainRow({
|
function ChainRow({
|
||||||
chain,
|
chain,
|
||||||
|
canManage,
|
||||||
nameById,
|
nameById,
|
||||||
publishing,
|
publishing,
|
||||||
onSettings,
|
onSettings,
|
||||||
onTogglePublic,
|
onTogglePublic,
|
||||||
}: {
|
}: {
|
||||||
chain: BusinessChain;
|
chain: BusinessChain;
|
||||||
|
/** 能否编辑 / 发布(本人链条 或 管理员)。 */
|
||||||
|
canManage: boolean;
|
||||||
nameById: Map<string, string>;
|
nameById: Map<string, string>;
|
||||||
publishing: boolean;
|
publishing: boolean;
|
||||||
onSettings: () => void;
|
onSettings: () => void;
|
||||||
onTogglePublic: () => void;
|
onTogglePublic: () => void;
|
||||||
}) {
|
}) {
|
||||||
const isMine = chain.isMine !== false;
|
|
||||||
// 配置者用户名:后端返回 email,展示时取 @ 前的本地部分更像「用户名」。
|
// 配置者用户名:后端返回 email,展示时取 @ 前的本地部分更像「用户名」。
|
||||||
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "";
|
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "";
|
||||||
return (
|
return (
|
||||||
@@ -274,8 +280,8 @@ function ChainRow({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||||
{/* 发布 / 取消发布:仅本人的链条可操作。 */}
|
{/* 发布 / 取消发布:本人的链条或管理员可操作。 */}
|
||||||
{isMine && (
|
{canManage && (
|
||||||
<Button
|
<Button
|
||||||
variant={chain.isPublic ? "secondary" : "outline"}
|
variant={chain.isPublic ? "secondary" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -291,8 +297,8 @@ function ChainRow({
|
|||||||
{chain.isPublic ? "取消发布" : "发布"}
|
{chain.isPublic ? "取消发布" : "发布"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* 设置(编辑):仅本人的链条;公共里别人的链条只读浏览。 */}
|
{/* 设置(编辑):本人的链条或管理员;普通用户看别人的链条只读浏览。 */}
|
||||||
{isMine && (
|
{canManage && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -1205,24 +1205,6 @@ export function useStep2Orchestration(
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* 模型自动容错通知(前台):后端切到下一个模型重跑本轮时调用。弹 toast + 在时间线插一条
|
|
||||||
* 「系统提示」气泡,让用户看到「『X』模型异常 → 已切换『Y』继续」。
|
|
||||||
*/
|
|
||||||
const notifyModelSwitch = useCallback(
|
|
||||||
(info: ModelSwitchInfo) => {
|
|
||||||
const who = info.agentName || "智能体";
|
|
||||||
triggerToast(`⚠️「${info.failedModel}」模型${info.reason},已自动切换「${info.nextModel}」继续`);
|
|
||||||
appendDialogue({
|
|
||||||
sender: "系统提示",
|
|
||||||
avatarType: "amber",
|
|
||||||
content: `⚠️ 模型容错:「${who}」所用模型「${info.failedModel}」${info.reason},已自动切换到「${info.nextModel}」继续。`,
|
|
||||||
confidence: "模型容错",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[appendDialogue, triggerToast],
|
|
||||||
);
|
|
||||||
|
|
||||||
const phaseLabel = useCallback(
|
const phaseLabel = useCallback(
|
||||||
(
|
(
|
||||||
phase: StreamPhase,
|
phase: StreamPhase,
|
||||||
@@ -1339,17 +1321,38 @@ export function useStep2Orchestration(
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成某气泡的 onModelSwitch 处理器:弹提示 + 把该气泡里「失败尝试已累计的半截内容」清空
|
* 生成某气泡的 onModelSwitch 处理器:弹提示 + 在该失败气泡**正前方**插入一条「系统提示」
|
||||||
* (随后新模型的流式增量会重新填进同一气泡),并把状态标为「模型切换中…」。
|
* 气泡(「『X』模型异常 → 已切换『Y』继续」),同时把失败气泡里「已累计的半截内容」清空
|
||||||
|
* (随后新模型的流式增量会重新填进同一气泡)并标为「模型切换中…」。
|
||||||
|
*
|
||||||
|
* 顺序很关键:系统提示插在失败气泡之前 → 用户**先**看到「已切换其他模型」、**再**看到新
|
||||||
|
* 模型重答的过程(旧实现把提示气泡追加到列表末尾,反而排在新模型答案之后,顺序颠倒)。
|
||||||
|
* 插「之前」而非「移到末尾」也保住并行批次里各席位气泡的相对次序。
|
||||||
*/
|
*/
|
||||||
const makeModelSwitchHandler = useCallback(
|
const makeModelSwitchHandler = useCallback(
|
||||||
(bubbleId: number, isParallel: boolean) => (info: ModelSwitchInfo) => {
|
(bubbleId: number, isParallel: boolean) => (info: ModelSwitchInfo) => {
|
||||||
notifyModelSwitch(info);
|
const who = info.agentName || "智能体";
|
||||||
|
triggerToast(`⚠️「${info.failedModel}」模型${info.reason},已自动切换「${info.nextModel}」继续`);
|
||||||
if (isParallel) parallelStreamRef.current.delete(bubbleId);
|
if (isParallel) parallelStreamRef.current.delete(bubbleId);
|
||||||
setDialogueContent(bubbleId, "");
|
const systemBubble: Step2Dialogue = {
|
||||||
setDialogueConfidence(bubbleId, "模型切换中…");
|
id: Date.now() + Math.random(),
|
||||||
|
sender: "系统提示",
|
||||||
|
avatarType: "amber",
|
||||||
|
content: `⚠️ 模型容错:「${who}」所用模型「${info.failedModel}」${info.reason},已自动切换到「${info.nextModel}」继续。`,
|
||||||
|
time: nowTime(),
|
||||||
|
confidence: "模型容错",
|
||||||
|
};
|
||||||
|
setStep2RoundtableDialogues((prev) => {
|
||||||
|
const idx = prev.findIndex((d) => d.id === bubbleId);
|
||||||
|
// 失败气泡找不到(异常兜底):仍按旧行为在末尾追加系统提示。
|
||||||
|
if (idx < 0) return [...prev, systemBubble];
|
||||||
|
const cleared = { ...prev[idx], content: "", confidence: "模型切换中…" };
|
||||||
|
const next = [...prev];
|
||||||
|
next.splice(idx, 1, systemBubble, cleared);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[notifyModelSwitch, setDialogueContent, setDialogueConfidence],
|
[triggerToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── §6 并行流式:全接收 / 惰性渲染 / 节流 ───────────────────────────────
|
// ─── §6 并行流式:全接收 / 惰性渲染 / 节流 ───────────────────────────────
|
||||||
@@ -3093,7 +3096,22 @@ export function useStep2Orchestration(
|
|||||||
// 需要 ref 立刻可读到作业的真实线程,否则读到旧值导致接管失败。
|
// 需要 ref 立刻可读到作业的真实线程,否则读到旧值导致接管失败。
|
||||||
threadIdsRef.current = snapshot.threadIds;
|
threadIdsRef.current = snapshot.threadIds;
|
||||||
coordinatorNameRef.current = snapshot.coordinatorName;
|
coordinatorNameRef.current = snapshot.coordinatorName;
|
||||||
setStep2RoundtableDialogues(snapshot.step2RoundtableDialogues);
|
// 历史快照是**静止记录**:任何残留「流式/加载中」的气泡(用户中途点了「停止」,
|
||||||
|
// 或草稿在流式期间被自动保存)都强制收尾为静止态。否则用户点聊天记录回看时,这些
|
||||||
|
// 气泡仍显示「调度中 / 等待响应 / 正在输出…」+ 加载光标,让人误以为还在进行(其实早已结束)。
|
||||||
|
// streaming===true 与「加载中」文案一一对应(finalizeDialogue 收尾时必置 false),故据此即可精确识别。
|
||||||
|
setStep2RoundtableDialogues(
|
||||||
|
snapshot.step2RoundtableDialogues.map((d) => {
|
||||||
|
if (!d.streaming) return d;
|
||||||
|
const hasText = (d.content ?? "").trim().length > 0;
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
streaming: false,
|
||||||
|
content: hasText ? d.content : "(已结束)",
|
||||||
|
confidence: "已结束",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
setLastLeaderContent(snapshot.lastLeaderContent);
|
setLastLeaderContent(snapshot.lastLeaderContent);
|
||||||
setHasConsensus(snapshot.hasConsensus);
|
setHasConsensus(snapshot.hasConsensus);
|
||||||
setConsensusPercentage(snapshot.consensusPercentage);
|
setConsensusPercentage(snapshot.consensusPercentage);
|
||||||
|
|||||||
@@ -509,6 +509,14 @@ export function useStep3Summary(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ingestStatusRef.current = ingestStatus;
|
ingestStatusRef.current = ingestStatus;
|
||||||
}, [ingestStatus]);
|
}, [ingestStatus]);
|
||||||
|
// 重新分析(重跑总结 / 重新或修改流程图)前把入库状态整体复位为 idle —— 旧报告/旧流程图的
|
||||||
|
// 「是否入库」询问卡、入库协助卡、手动入库按钮全部初始化隐藏,避免「都重新分析了还问是否入库」
|
||||||
|
// 的歧义。流程图类重分析完成后 runFlowExtract 会在 idle 时重新发起入库询问。
|
||||||
|
const resetIngestState = useCallback(() => {
|
||||||
|
setIngestStatus("idle");
|
||||||
|
setPendingIngestClarification(null);
|
||||||
|
setPendingFlowForIngest(false);
|
||||||
|
}, []);
|
||||||
// 入库功能「有效可用」= 技能可用 **且** 处于深链(taskId)模式。
|
// 入库功能「有效可用」= 技能可用 **且** 处于深链(taskId)模式。
|
||||||
const ingestEnabled = !!ingestAvailable && !!ingestTaskId;
|
const ingestEnabled = !!ingestAvailable && !!ingestTaskId;
|
||||||
const ingestAvailableRef = useRef<boolean>(ingestEnabled);
|
const ingestAvailableRef = useRef<boolean>(ingestEnabled);
|
||||||
@@ -1237,10 +1245,12 @@ export function useStep3Summary(
|
|||||||
const regenerateFlow = useCallback(() => {
|
const regenerateFlow = useCallback(() => {
|
||||||
if (!currentMdRef.current.trim() && seatDeliveries.length === 0) return;
|
if (!currentMdRef.current.trim() && seatDeliveries.length === 0) return;
|
||||||
aborterRef.current?.abort();
|
aborterRef.current?.abort();
|
||||||
|
// 重画流程图 = 重新分析 → 先复位入库状态(隐藏旧图的入库询问/按钮),新图生成完会重新询问。
|
||||||
|
resetIngestState();
|
||||||
const aborter = new AbortController();
|
const aborter = new AbortController();
|
||||||
aborterRef.current = aborter;
|
aborterRef.current = aborter;
|
||||||
void runFlowExtract(aborter);
|
void runFlowExtract(aborter);
|
||||||
}, [runFlowExtract, seatDeliveries]);
|
}, [runFlowExtract, seatDeliveries, resetIngestState]);
|
||||||
|
|
||||||
/** Phase 3:画布手动改/删节点后回写图 + 持久化(序列化进草稿快照)。 */
|
/** Phase 3:画布手动改/删节点后回写图 + 持久化(序列化进草稿快照)。 */
|
||||||
const updateFlowGraph = useCallback(
|
const updateFlowGraph = useCallback(
|
||||||
@@ -1273,6 +1283,9 @@ export function useStep3Summary(
|
|||||||
setSeatStubMessages({});
|
setSeatStubMessages({});
|
||||||
setFlowGraphState(null);
|
setFlowGraphState(null);
|
||||||
setFlowPhase("idle");
|
setFlowPhase("idle");
|
||||||
|
// 整体重跑(重试 / 重新生成)= 重新分析 → 复位入库状态,旧产物的入库询问/按钮先隐藏;
|
||||||
|
// 重跑出新流程图后 runFlowExtract 会重新询问是否入库。
|
||||||
|
resetIngestState();
|
||||||
|
|
||||||
const aborter = new AbortController();
|
const aborter = new AbortController();
|
||||||
aborterRef.current = aborter;
|
aborterRef.current = aborter;
|
||||||
@@ -1330,6 +1343,7 @@ export function useStep3Summary(
|
|||||||
finalizeDialogue,
|
finalizeDialogue,
|
||||||
streamOnce,
|
streamOnce,
|
||||||
runFlowExtract,
|
runFlowExtract,
|
||||||
|
resetIngestState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1545,6 +1559,8 @@ export function useStep3Summary(
|
|||||||
// 带附件时跳过分类(附件多用于报告 Q&A/改报告);澄清后用户点选则用 forcedIntent 跳过分类。
|
// 带附件时跳过分类(附件多用于报告 Q&A/改报告);澄清后用户点选则用 forcedIntent 跳过分类。
|
||||||
// reuseBubbleId:意图分类期间先建的「调度中」气泡,定了意图后由对应分支复用(不等接口返回、像第一步)。
|
// reuseBubbleId:意图分类期间先建的「调度中」气泡,定了意图后由对应分支复用(不等接口返回、像第一步)。
|
||||||
let reuseBubbleId: number | null = null;
|
let reuseBubbleId: number | null = null;
|
||||||
|
// 「重新生成报告」复位入库状态后,待报告重写完成、若已有流程图则重新询问是否入库。
|
||||||
|
let reaskIngestAfter = false;
|
||||||
if (text && !hasFiles) {
|
if (text && !hasFiles) {
|
||||||
let intent: FlowIntent | undefined = opts?.forcedIntent;
|
let intent: FlowIntent | undefined = opts?.forcedIntent;
|
||||||
let dispatchBubbleId: number | null = null;
|
let dispatchBubbleId: number | null = null;
|
||||||
@@ -1580,6 +1596,9 @@ export function useStep3Summary(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (intent === "regenerate_flow" || intent === "modify_flow") {
|
if (intent === "regenerate_flow" || intent === "modify_flow") {
|
||||||
|
// 重新/修改流程图 = 重新分析 → 先复位入库状态(隐藏旧图入库询问/按钮),
|
||||||
|
// runFlowExtract 出新图后会在 idle 时重新询问是否入库。
|
||||||
|
resetIngestState();
|
||||||
const flowAborter = new AbortController();
|
const flowAborter = new AbortController();
|
||||||
aborterRef.current = flowAborter;
|
aborterRef.current = flowAborter;
|
||||||
const cur = flowGraphRef.current;
|
const cur = flowGraphRef.current;
|
||||||
@@ -1593,6 +1612,12 @@ export function useStep3Summary(
|
|||||||
}
|
}
|
||||||
// regenerate_report | qa → 复用调度气泡走下方总结线程路径。
|
// regenerate_report | qa → 复用调度气泡走下方总结线程路径。
|
||||||
reuseBubbleId = dispatchBubbleId;
|
reuseBubbleId = dispatchBubbleId;
|
||||||
|
// 重新生成报告 = 重新分析 → 复位入库状态先隐藏旧的入库询问/按钮(qa 仅提问不复位);
|
||||||
|
// 报告重写完后若已有流程图,再重新询问是否入库(见 streamOnce 收尾后的 reaskIngestAfter)。
|
||||||
|
if (intent === "regenerate_report") {
|
||||||
|
resetIngestState();
|
||||||
|
reaskIngestAfter = true;
|
||||||
|
}
|
||||||
// intent === "regenerate_report" | "qa" → 落到下方总结线程续问路径:
|
// intent === "regenerate_report" | "qa" → 落到下方总结线程续问路径:
|
||||||
// **追加**用户气泡 + 在原线程上让智能体重写报告 / 增量改 / 回答(**不清空历史**)。
|
// **追加**用户气泡 + 在原线程上让智能体重写报告 / 增量改 / 回答(**不清空历史**)。
|
||||||
// regenerate_report 不再走 run()(run 会 setDialogues([]) 清空,导致"覆盖聊天记录、
|
// regenerate_report 不再走 run()(run 会 setDialogues([]) 清空,导致"覆盖聊天记录、
|
||||||
@@ -1688,6 +1713,11 @@ export function useStep3Summary(
|
|||||||
flowRound,
|
flowRound,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (aborter.signal.aborted) return;
|
||||||
|
// 报告重写完成、且入库可用 + 已有流程图 → 重新询问是否入库(复位后再问,针对新报告)。
|
||||||
|
if (reaskIngestAfter && ingestAvailableRef.current && flowGraphRef.current) {
|
||||||
|
setIngestStatus("asking");
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (aborter.signal.aborted) return;
|
if (aborter.signal.aborted) return;
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -1713,6 +1743,7 @@ export function useStep3Summary(
|
|||||||
streamOnce,
|
streamOnce,
|
||||||
runFlowExtract,
|
runFlowExtract,
|
||||||
runIngest,
|
runIngest,
|
||||||
|
resetIngestState,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize
|
|||||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||||
import { listAgents } from "@/core/agents";
|
import { listAgents } from "@/core/agents";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
filterRecommendCandidates,
|
filterRecommendCandidates,
|
||||||
@@ -86,6 +87,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
createChain,
|
createChain,
|
||||||
deleteChain,
|
deleteChain,
|
||||||
|
getChain,
|
||||||
listChains,
|
listChains,
|
||||||
optimizeChainPrompt,
|
optimizeChainPrompt,
|
||||||
setChainPublic,
|
setChainPublic,
|
||||||
@@ -171,6 +173,8 @@ export default function BusinessChainEditorPage() {
|
|||||||
// 当前编辑链条的发布状态 / 是否本人所有(发布按钮 + 只读保护用)。
|
// 当前编辑链条的发布状态 / 是否本人所有(发布按钮 + 只读保护用)。
|
||||||
const [currentChainPublic, setCurrentChainPublic] = useState(false);
|
const [currentChainPublic, setCurrentChainPublic] = useState(false);
|
||||||
const [currentChainMine, setCurrentChainMine] = useState(true);
|
const [currentChainMine, setCurrentChainMine] = useState(true);
|
||||||
|
// 管理员:可编辑 / 发布 / 删除任意账号的链条(与「全部链条」筛选配套)。
|
||||||
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
@@ -323,17 +327,35 @@ export default function BusinessChainEditorPage() {
|
|||||||
setStep(1);
|
setStep(1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ─── URL 参数 chainId:从「业务链条」弹窗点「设置」进来时自动切到该链条 ──────
|
// ─── URL 参数 chainId:从「业务链条」弹窗 / 列表点「设置」进来时自动切到该链条 ──────
|
||||||
// 只在链条列表加载完后应用一次(用 ref 闸门避免覆盖用户后续的手动切换)。
|
// 按「已应用的 chainId」做闸门(而非「只应用一次」):当 URL 的 chainId 变化时重新加载。
|
||||||
const appliedChainParamRef = useRef(false);
|
// 关键修复:此编辑器实例在多次导航间可能不卸载(从弹窗进来时尤甚),用「只应用一次」的
|
||||||
|
// 旧闸门会导致第二次打开另一条链条时根本不加载,currentChainId 停在上一条 → 保存打到错误
|
||||||
|
// 的 id → 后端 404「已删除」,必须重新登录(整页刷新重建组件)才恢复。改为追踪已应用的 id
|
||||||
|
// 后,chainId 变了就重载;同一 chainId 不重复加载,也不覆盖用户在编辑器内的手动切换。
|
||||||
|
const appliedChainIdRef = useRef<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (appliedChainParamRef.current) return;
|
|
||||||
const chainId = searchParams.get("chainId");
|
const chainId = searchParams.get("chainId");
|
||||||
if (!chainId) return;
|
if (!chainId) return;
|
||||||
if (chains.length === 0) return; // 等 refreshChains 拉回列表
|
if (appliedChainIdRef.current === chainId) return; // 这条已加载过,别覆盖用户后续操作
|
||||||
const chain = chains.find((c) => c.id === chainId);
|
appliedChainIdRef.current = chainId;
|
||||||
if (chain) loadChain(chain);
|
// 优先用「我的链条」列表里已拉到的对象(省一次请求);不在列表里(管理员从「全部链条」
|
||||||
appliedChainParamRef.current = true; // 命中或链条已不存在,都不再重试
|
// 点设置 / 公共链条 / 自己没有任何链条)则直接按 id 拉取。后端对管理员放开任意链条、
|
||||||
|
// 对所有人放开已发布公共链条;拉不到给出提示并保持空白新建态。
|
||||||
|
const local = chains.find((c) => c.id === chainId);
|
||||||
|
if (local) {
|
||||||
|
loadChain(local);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const chain = await getChain(chainId);
|
||||||
|
if (chain) loadChain(chain);
|
||||||
|
else toast.error("该业务链条不存在或无权访问");
|
||||||
|
} catch {
|
||||||
|
// 静默:取不到就保持当前(空白)状态。
|
||||||
|
}
|
||||||
|
})();
|
||||||
}, [chains, searchParams, loadChain]);
|
}, [chains, searchParams, loadChain]);
|
||||||
|
|
||||||
// ─── 席位增删改(全部委托给 lib/chain-stages 纯函数)────────────────────
|
// ─── 席位增删改(全部委托给 lib/chain-stages 纯函数)────────────────────
|
||||||
@@ -987,8 +1009,8 @@ export default function BusinessChainEditorPage() {
|
|||||||
|
|
||||||
{/* 右侧操作 */}
|
{/* 右侧操作 */}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{/* 发布 / 取消发布:仅已保存且本人所有的链条。发布后所有账号可在「公共」筛选查看 / 选用。 */}
|
{/* 发布 / 取消发布:已保存且本人所有(或管理员)的链条。发布后所有账号可在「公共」筛选查看 / 选用。 */}
|
||||||
{currentChainId && currentChainMine && (
|
{currentChainId && (currentChainMine || isAdmin) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleTogglePublish}
|
onClick={handleTogglePublish}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useRouteNavigate } from "@/core/route-base";
|
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";
|
||||||
@@ -83,6 +84,8 @@ export default function BusinessChainListPage() {
|
|||||||
const navigate = useRouteNavigate();
|
const navigate = useRouteNavigate();
|
||||||
// 整片脱敏:链条列表页展示文本(链条名/描述/席位名/标题)。
|
// 整片脱敏:链条列表页展示文本(链条名/描述/席位名/标题)。
|
||||||
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
|
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||||
|
// 管理员:额外多一个「全部链条」筛选,且可编辑 / 发布 / 删除任意账号的链条。
|
||||||
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
|
|
||||||
// 进入编辑器:带 chainId = 打开并切到该链条;不带 = 空白新建。
|
// 进入编辑器:带 chainId = 打开并切到该链条;不带 = 空白新建。
|
||||||
// 路径写 workspace base,`useRouteNavigate` 会按当前路由 base 改写。
|
// 路径写 workspace base,`useRouteNavigate` 会按当前路由 base 改写。
|
||||||
@@ -314,13 +317,14 @@ 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", "公共列表"],
|
||||||
] as const
|
...(isAdmin ? ([["all", "全部链条"]] as const) : []),
|
||||||
|
] as [ChainScope, string][]
|
||||||
).map(([key, label]) => (
|
).map(([key, label]) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
@@ -439,6 +443,7 @@ export default function BusinessChainListPage() {
|
|||||||
<ChainCard
|
<ChainCard
|
||||||
key={chain.id}
|
key={chain.id}
|
||||||
chain={chain}
|
chain={chain}
|
||||||
|
canManage={isAdmin || chain.isMine !== false}
|
||||||
publishing={publishingId === chain.id}
|
publishing={publishingId === chain.id}
|
||||||
onSettings={() => goConfigure(chain.id)}
|
onSettings={() => goConfigure(chain.id)}
|
||||||
onTogglePublic={() => handleTogglePublic(chain)}
|
onTogglePublic={() => handleTogglePublic(chain)}
|
||||||
@@ -450,6 +455,7 @@ export default function BusinessChainListPage() {
|
|||||||
) : (
|
) : (
|
||||||
<ChainTable
|
<ChainTable
|
||||||
chains={visibleChains}
|
chains={visibleChains}
|
||||||
|
canManage={isAdmin}
|
||||||
publishingId={publishingId}
|
publishingId={publishingId}
|
||||||
onSettings={(c) => goConfigure(c.id)}
|
onSettings={(c) => goConfigure(c.id)}
|
||||||
onTogglePublic={handleTogglePublic}
|
onTogglePublic={handleTogglePublic}
|
||||||
@@ -620,18 +626,20 @@ function EmptyState({
|
|||||||
|
|
||||||
function ChainCard({
|
function ChainCard({
|
||||||
chain,
|
chain,
|
||||||
|
canManage,
|
||||||
publishing,
|
publishing,
|
||||||
onSettings,
|
onSettings,
|
||||||
onTogglePublic,
|
onTogglePublic,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
chain: BusinessChain;
|
chain: BusinessChain;
|
||||||
|
/** 能否编辑 / 发布 / 删除(本人链条 或 管理员)。 */
|
||||||
|
canManage: boolean;
|
||||||
publishing: boolean;
|
publishing: boolean;
|
||||||
onSettings: () => void;
|
onSettings: () => void;
|
||||||
onTogglePublic: () => void;
|
onTogglePublic: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
const isMine = chain.isMine !== false;
|
|
||||||
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "";
|
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -685,8 +693,8 @@ function ChainCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮:仅本人链条可发布 / 设置 / 删除(公共里别人的链条只读浏览)。 */}
|
{/* 操作按钮:本人链条或管理员可发布 / 设置 / 删除(普通用户看别人的链条只读浏览)。 */}
|
||||||
{isMine && (
|
{canManage && (
|
||||||
<div className="flex shrink-0 items-center gap-1 opacity-80 transition-opacity group-hover:opacity-100">
|
<div className="flex shrink-0 items-center gap-1 opacity-80 transition-opacity group-hover:opacity-100">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -892,12 +900,15 @@ function dotClass(avatarType: string): string {
|
|||||||
|
|
||||||
function ChainTable({
|
function ChainTable({
|
||||||
chains,
|
chains,
|
||||||
|
canManage,
|
||||||
publishingId,
|
publishingId,
|
||||||
onSettings,
|
onSettings,
|
||||||
onTogglePublic,
|
onTogglePublic,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
chains: BusinessChain[];
|
chains: BusinessChain[];
|
||||||
|
/** 管理员:可管理任意链条(即使非本人)。 */
|
||||||
|
canManage: boolean;
|
||||||
publishingId: string | null;
|
publishingId: string | null;
|
||||||
onSettings: (c: BusinessChain) => void;
|
onSettings: (c: BusinessChain) => void;
|
||||||
onTogglePublic: (c: BusinessChain) => void;
|
onTogglePublic: (c: BusinessChain) => void;
|
||||||
@@ -919,7 +930,7 @@ function ChainTable({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-100 text-xs text-zinc-800 dark:divide-[#1A2F5A] dark:text-[#C5CFE0]">
|
<tbody className="divide-y divide-zinc-100 text-xs text-zinc-800 dark:divide-[#1A2F5A] dark:text-[#C5CFE0]">
|
||||||
{chains.map((chain) => {
|
{chains.map((chain) => {
|
||||||
const isMine = chain.isMine !== false;
|
const isMine = canManage || chain.isMine !== false;
|
||||||
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "—";
|
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "—";
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ 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 current user's chains (full records, incl. seats)
|
GET "" list chains (``scope=mine``默认 / ``public`` / ``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)
|
||||||
DELETE "/{chain_id}" delete
|
DELETE "/{chain_id}" delete
|
||||||
|
|
||||||
|
管理员权限:``system_role == "admin"`` 的用户(``_is_admin``)可 list ``scope=all``
|
||||||
|
(所有账号的全部链条),并可 get / 编辑 / 发布 / 删除**任意** owner 的链条;非管理员
|
||||||
|
仍仅限本人链条(``scope=all`` 退化为 ``mine``)。编辑/删除不转移链条归属(``user_id`` 不变)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -131,6 +135,18 @@ def _current_user_id(request: Request) -> str:
|
|||||||
return get_effective_user_id()
|
return get_effective_user_id()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_admin(request: Request) -> bool:
|
||||||
|
"""当前请求是否为管理员。
|
||||||
|
|
||||||
|
认证关闭时 ``request.state.user`` 为 None(单一 default 用户,mine 即 all),
|
||||||
|
按管理员处理(与 menu_overrides 等放行约定一致,无越权风险)。
|
||||||
|
"""
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
if user is None:
|
||||||
|
return True
|
||||||
|
return getattr(user, "system_role", None) == "admin"
|
||||||
|
|
||||||
|
|
||||||
async def _enrich_owner_names(rows: list[dict]) -> None:
|
async def _enrich_owner_names(rows: list[dict]) -> None:
|
||||||
"""把每行的 ``owner_id`` 解析成可读用户名(email),写入 ``owner_name``。
|
"""把每行的 ``owner_id`` 解析成可读用户名(email),写入 ``owner_name``。
|
||||||
|
|
||||||
@@ -269,13 +285,18 @@ async def list_chains(request: Request, scope: str = "mine") -> ChainListRespons
|
|||||||
|
|
||||||
``scope`` 查询参数(筛选):
|
``scope`` 查询参数(筛选):
|
||||||
- ``mine``(默认):仅当前用户自己的链条;
|
- ``mine``(默认):仅当前用户自己的链条;
|
||||||
- ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨)。
|
- ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨);
|
||||||
|
- ``all``:所有账号的全部链条(**仅管理员**;非管理员请求 ``all`` 退化为 ``mine``)。
|
||||||
其余值按 ``mine`` 处理。
|
其余值按 ``mine`` 处理。
|
||||||
"""
|
"""
|
||||||
normalized_scope = scope if scope in ("mine", "public") else "mine"
|
is_admin = _is_admin(request)
|
||||||
|
normalized_scope = scope if scope in ("mine", "public", "all") else "mine"
|
||||||
|
# 非管理员请求 all → 退化为 mine,绝不越权。
|
||||||
|
if normalized_scope == "all" and not is_admin:
|
||||||
|
normalized_scope = "mine"
|
||||||
store = _get_store(request)
|
store = _get_store(request)
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
rows = await store.list_chains(user_id, scope=normalized_scope)
|
rows = await store.list_chains(user_id, scope=normalized_scope, is_admin=is_admin)
|
||||||
await _enrich_owner_names(rows)
|
await _enrich_owner_names(rows)
|
||||||
return ChainListResponse(chains=[ChainResponse(**r) for r in rows])
|
return ChainListResponse(chains=[ChainResponse(**r) for r in rows])
|
||||||
|
|
||||||
@@ -310,7 +331,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
|
|||||||
async def get_chain(request: Request, chain_id: str) -> ChainResponse:
|
async def get_chain(request: Request, chain_id: str) -> ChainResponse:
|
||||||
store = _get_store(request)
|
store = _get_store(request)
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
row = await store.get_chain(chain_id, user_id)
|
row = await store.get_chain(chain_id, user_id, is_admin=_is_admin(request))
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="Chain not found")
|
raise HTTPException(status_code=404, detail="Chain not found")
|
||||||
await _enrich_owner_names([row])
|
await _enrich_owner_names([row])
|
||||||
@@ -334,7 +355,7 @@ async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest
|
|||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
# Distinguish "omitted" from "explicitly sent"; only forward provided fields.
|
# Distinguish "omitted" from "explicitly sent"; only forward provided fields.
|
||||||
fields = body.model_dump(exclude_unset=True)
|
fields = body.model_dump(exclude_unset=True)
|
||||||
row = await store.update_chain(chain_id, user_id, **fields)
|
row = await store.update_chain(chain_id, user_id, is_admin=_is_admin(request), **fields)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="Chain not found")
|
raise HTTPException(status_code=404, detail="Chain not found")
|
||||||
await _enrich_owner_names([row])
|
await _enrich_owner_names([row])
|
||||||
@@ -345,7 +366,7 @@ async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest
|
|||||||
async def delete_chain(request: Request, chain_id: str) -> None:
|
async def delete_chain(request: Request, chain_id: str) -> None:
|
||||||
store = _get_store(request)
|
store = _get_store(request)
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
deleted = await store.delete_chain(chain_id, user_id)
|
deleted = await store.delete_chain(chain_id, user_id, is_admin=_is_admin(request))
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail="Chain not found")
|
raise HTTPException(status_code=404, detail="Chain not found")
|
||||||
|
|
||||||
|
|||||||
+22
-14
@@ -70,36 +70,41 @@ class RoundtableChainRepository:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def list_chains(
|
async def list_chains(
|
||||||
self, user_id: str, *, scope: str = "mine", limit: int = 200
|
self, user_id: str, *, scope: str = "mine", limit: int = 200, is_admin: bool = False
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""列出链条。
|
"""列出链条。
|
||||||
|
|
||||||
``scope``:
|
``scope``:
|
||||||
- ``"mine"``(默认):仅当前用户自己的链条;
|
- ``"mine"``(默认):仅当前用户自己的链条;
|
||||||
- ``"public"``:所有**已发布**(``is_public=True``)链条,含本人发布的。
|
- ``"public"``:所有**已发布**(``is_public=True``)链条,含本人发布的;
|
||||||
|
- ``"all"``:所有账号的全部链条(**仅管理员**,由路由层校验后传 ``is_admin=True``;
|
||||||
|
非管理员误传则按 ``"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 == "all" and is_admin:
|
||||||
|
cond = None
|
||||||
else:
|
else:
|
||||||
cond = RoundtableChainRow.user_id == user_id
|
cond = RoundtableChainRow.user_id == user_id
|
||||||
stmt = (
|
stmt = select(RoundtableChainRow)
|
||||||
select(RoundtableChainRow)
|
if cond is not None:
|
||||||
.where(cond)
|
stmt = stmt.where(cond)
|
||||||
.order_by(RoundtableChainRow.updated_at.desc())
|
stmt = stmt.order_by(RoundtableChainRow.updated_at.desc()).limit(limit)
|
||||||
.limit(limit)
|
|
||||||
)
|
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return [self._to_dict(row, user_id) for row in result.scalars()]
|
return [self._to_dict(row, user_id) for row in result.scalars()]
|
||||||
|
|
||||||
async def get_chain(self, chain_id: str, user_id: str) -> dict[str, Any] | None:
|
async def get_chain(
|
||||||
"""取一条链条:owner 可见,**已发布的公共链条任何人可见**(供选入研讨)。"""
|
self, chain_id: str, user_id: str, *, is_admin: bool = False
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""取一条链条:owner 可见,**已发布的公共链条任何人可见**(供选入研讨);
|
||||||
|
**管理员可见任意链条**(``is_admin=True``,供后台管理配置)。"""
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
row = await session.get(RoundtableChainRow, chain_id)
|
row = await session.get(RoundtableChainRow, chain_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
if row.user_id != user_id and not bool(getattr(row, "is_public", False)):
|
if not is_admin and row.user_id != user_id and not bool(getattr(row, "is_public", False)):
|
||||||
return None
|
return None
|
||||||
return self._to_dict(row, user_id)
|
return self._to_dict(row, user_id)
|
||||||
|
|
||||||
@@ -140,10 +145,12 @@ class RoundtableChainRepository:
|
|||||||
seat_mode=_UNSET,
|
seat_mode=_UNSET,
|
||||||
gather_first=_UNSET,
|
gather_first=_UNSET,
|
||||||
is_public=_UNSET,
|
is_public=_UNSET,
|
||||||
|
is_admin: bool = False,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
row = await session.get(RoundtableChainRow, chain_id)
|
row = await session.get(RoundtableChainRow, chain_id)
|
||||||
if row is None or row.user_id != user_id:
|
# 管理员可改任意链条;否则仅 owner 本人。不变更 user_id(不转移归属)。
|
||||||
|
if row is None or (not is_admin and row.user_id != user_id):
|
||||||
return None
|
return None
|
||||||
if title is not _UNSET and title is not None:
|
if title is not _UNSET and title is not None:
|
||||||
row.title = str(title).strip()
|
row.title = str(title).strip()
|
||||||
@@ -168,10 +175,11 @@ class RoundtableChainRepository:
|
|||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return self._to_dict(row, user_id)
|
return self._to_dict(row, user_id)
|
||||||
|
|
||||||
async def delete_chain(self, chain_id: str, user_id: str) -> bool:
|
async def delete_chain(self, chain_id: str, user_id: str, *, is_admin: bool = False) -> bool:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
row = await session.get(RoundtableChainRow, chain_id)
|
row = await session.get(RoundtableChainRow, chain_id)
|
||||||
if row is None or row.user_id != user_id:
|
# 管理员可删任意链条;否则仅 owner 本人。
|
||||||
|
if row is None or (not is_admin and row.user_id != user_id):
|
||||||
return False
|
return False
|
||||||
await session.delete(row)
|
await session.delete(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
Reference in new issue
Block a user