taskId 深链会商存储独立化: - 新增独立表 roundtable_task_drafts + 路由 /api/roundtable-task-drafts,只按 task_id 归属、完全不按 user 分权(created_by 仅审计),与个人 roundtable_drafts 物理分离; 个人草稿表恢复严格按用户隔离(去掉原 task 放权)。 - 后台研讨作业按 taskId 共享:roundtable_jobs 加 task_id,get/get_by_draft/stream/ resume/cancel 支持 ?task_id= 跨用户读取与控制,作业报告写回 task-draft 存储。 - 前端:新增 api/task-drafts.ts 客户端,useDraftPersistence / useRoundtableDrafts / roundtable-jobs / DispatchChainJobModal / RoundtablePlanningPage 按 taskId 路由读写。 - 迁移 20260618_01 + column_additions_mysql.sql(jobs.task_id) + 数据搬迁脚本 scripts/migrate_roundtable_task_drafts.py(把历史 task_id 行迁出个人表)。 第三步聊天记录像第二步一样存全: - 总结报告 / 经典看板 / 大屏 三条流的生成过程对话(中间文字 + 步骤条 + stub)落库, 加载草稿整段回显,而非只剩最终 md / 流程图 / HTML。DraftStep3Snapshot 扩展 dialogues/seatStubMessages 槽位;存稿走页面 render 期 ref,恢复走各 hook hydrateSaved (旧草稿无字段则回退单条「历史记录」气泡)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
626 lines
25 KiB
TypeScript
626 lines
25 KiB
TypeScript
/**
|
||
* 圆桌规划草稿持久化 hook(后端版)。
|
||
*
|
||
* 2026-06 改造:草稿从浏览器 localStorage 迁移到后端 MySQL(`/api/roundtable-drafts`),
|
||
* 按登录用户隔离、跨设备/会话保留。本 hook 仍只负责编排「什么时机存 / 加载到哪里」,
|
||
* Step 1 / Step 2 的状态由各自的 hook 提供快照函数与 hydrate。
|
||
*
|
||
* 对外返回的接口(`drafts` 列表项含 `name` 等字段、各 handler 签名)与旧版保持一致,
|
||
* 主页面无需改动。
|
||
*
|
||
* 同时修复了旧 localStorage 版的几个存储 bug:
|
||
* 1. **自动保存会覆盖手动重命名** —— 现在 create 时写一次派生标题,之后所有 update
|
||
* 一律不带 title;标题只由显式「重命名」改写。
|
||
* 2. **快速连续前进(1→2→3)产生重复草稿** —— 所有保存经一条串行队列,create 完成
|
||
* 即写入 `currentDraftIdRef`,后续保存读到 id 走 update 而非再 create。
|
||
* 3. **加载读到 state 里的旧列表快照** —— load 走 `getDraft(id)` 实时拉服务端最新全量。
|
||
* 4. localStorage 配额导致的静默写失败 / 数据丢失 —— 改后端后不复存在。
|
||
*/
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
|
||
import type { Step1Snapshot } from "@/roundtable-planning/hooks/useStep1Intent";
|
||
import {
|
||
createDraft,
|
||
deleteDraft,
|
||
deriveDraftName,
|
||
getDraft,
|
||
listDrafts,
|
||
updateDraft,
|
||
type DraftCreatePayload,
|
||
type DraftRecord,
|
||
type DraftStep2Snapshot,
|
||
type DraftStep3Snapshot,
|
||
type DraftUpdatePayload,
|
||
} from "@/roundtable-planning/api/drafts";
|
||
import {
|
||
createTaskDraft,
|
||
deleteTaskDraft,
|
||
getLatestTaskDraftByTask,
|
||
getTaskDraft,
|
||
listTaskDraftsByTask,
|
||
updateTaskDraft,
|
||
} from "@/roundtable-planning/api/task-drafts";
|
||
import type { JobStatus } from "@/roundtable-planning/lib/job-types";
|
||
|
||
/** 历史下拉渲染用的轻量列表项(`name` 映射自后端 `title`)。 */
|
||
export interface DraftListItem {
|
||
id: string;
|
||
name: string;
|
||
furthestStep: 1 | 2 | 3;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
/** 最近一次后台作业状态(Phase 6 历史下拉「进行中」loading)。 */
|
||
status?: JobStatus | null;
|
||
/** 最近一次后台作业 id(点击进行中项打开流程图弹窗用)。 */
|
||
jobId?: string | null;
|
||
}
|
||
|
||
export interface UseDraftPersistenceOptions {
|
||
currentStep: number;
|
||
setCurrentStep: React.Dispatch<React.SetStateAction<number>>;
|
||
triggerToast: (msg: string) => void;
|
||
/**
|
||
* 外部任务 id(无界嵌入抽屉传入)。非空时:①新建草稿一律绑定该 task_id(按任务共享、
|
||
* 不按 user 分权);②可用 `loadLatestByTask` 按任务回显最新聊天记录。
|
||
*/
|
||
taskId?: string | null;
|
||
/** 读 Step 1 当前快照(由 useStep1Intent 提供的 getter)。 */
|
||
getStep1Snapshot: () => Step1Snapshot;
|
||
/** 读 Step 2 当前快照(由 useStep2Orchestration 提供的 getter)。 */
|
||
getStep2Snapshot: () => DraftStep2Snapshot;
|
||
/** 读 Step 3「结果绘制」当前快照(无则 null)。 */
|
||
getStep3Snapshot?: () => DraftStep3Snapshot | null;
|
||
/** 加载草稿时把 Step 1 状态打回去。 */
|
||
hydrateStep1: (snapshot: Step1Snapshot) => void;
|
||
/** 加载草稿时把 Step 2 状态打回去。 */
|
||
hydrateStep2: (snapshot: DraftStep2Snapshot, step3: DraftStep3Snapshot | null) => void;
|
||
/** 加载草稿时把 Step 3 结果打回去(无则传 null 清空)。 */
|
||
hydrateStep3?: (snapshot: DraftStep3Snapshot | null) => void;
|
||
/** 加载草稿前先中断所有 Step 1 / Step 2 in-flight。 */
|
||
cancelAllStreams: () => void;
|
||
/**
|
||
* Step 2 的 init 闸门控制 —— 仅当 furthestStep>=2 时把闸门置上,跳过自动 SSE;
|
||
* 否则保持原 false 让进入 Step 2 时正常初始化。
|
||
*/
|
||
setStep2InitGate: (initialized: boolean) => void;
|
||
/**
|
||
* Step 1 是否正在等待任务理解回复。由 true→false 视为「本轮模型回答完」,
|
||
* 触发一次自动保存(详见 autoSave 的两个 effect)。
|
||
*/
|
||
intentSending: boolean;
|
||
/**
|
||
* Step 2 已收尾(streaming===false)的对话气泡数量。每次递增意味着总控/某个
|
||
* 子智能体刚交付完一轮,触发一次自动保存。流式增量不改变该计数,因此不会刷屏式保存。
|
||
*/
|
||
step2FinalizedCount: number;
|
||
/**
|
||
* Step 3「结果绘制」每完成一次绘制递增 —— 作为草稿自动保存触发信号,把最新
|
||
* 报告(step3)落进草稿。仿 step2FinalizedCount。
|
||
*/
|
||
step3SavedTick?: number;
|
||
}
|
||
|
||
export interface UseDraftPersistenceReturn {
|
||
drafts: DraftListItem[];
|
||
currentDraftId: string | null;
|
||
isHistoryOpen: boolean;
|
||
renamingDraftId: string | null;
|
||
renameDraftValue: string;
|
||
historyDropdownRef: React.MutableRefObject<HTMLDivElement | null>;
|
||
loadedFromDraftRef: React.MutableRefObject<boolean>;
|
||
setIsHistoryOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
|
||
setCurrentDraftId: React.Dispatch<React.SetStateAction<string | null>>;
|
||
handleManualSave: () => void;
|
||
handleLoadDraft: (id: string) => Promise<boolean>;
|
||
/** 按外部任务 id 取最新草稿并 hydrate,返回是否命中(用于抑制 autoStart)。 */
|
||
loadLatestByTask: (taskId: string) => Promise<boolean>;
|
||
handleDeleteDraft: (id: string) => void;
|
||
handleStartRename: (draft: DraftListItem) => void;
|
||
handleCommitRename: (id: string) => void;
|
||
handleCancelRename: () => void;
|
||
/**
|
||
* 幂等地保证后端存在一条对应当前会话的草稿,返回其 id(无可保存内容时返回 null)。
|
||
* 推荐弹窗打开前调用,确保推荐历史有 draft_id 可归属。
|
||
*/
|
||
ensureDraft: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
|
||
/**
|
||
* **强制**把当前会话内容存进草稿(不同于 ensureDraft 的"已有就跳过")——后台挂起
|
||
* 发起前 / 点查看前各存一次,保住当时的聊天记录不丢。返回 draft id。
|
||
*/
|
||
saveDraftNow: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
|
||
/**
|
||
* 用**显式传入的 step2 快照**强制存一次(不读 live 状态)。「后台挂起直接跳转」用:
|
||
* 跳转会清空 step2,调用方在跳转前冻结快照传进来,保证落库的是挂起那一刻的研讨记录。
|
||
*/
|
||
saveStep2SnapshotNow: (step2: DraftStep2Snapshot, furthestStep?: 1 | 2 | 3) => Promise<string | null>;
|
||
/** 强制刷新草稿列表。 */
|
||
refresh: () => Promise<void>;
|
||
}
|
||
|
||
export function useDraftPersistence(
|
||
options: UseDraftPersistenceOptions,
|
||
): UseDraftPersistenceReturn {
|
||
const {
|
||
currentStep,
|
||
setCurrentStep,
|
||
triggerToast,
|
||
taskId,
|
||
getStep1Snapshot,
|
||
getStep2Snapshot,
|
||
getStep3Snapshot,
|
||
hydrateStep1,
|
||
hydrateStep2,
|
||
hydrateStep3,
|
||
cancelAllStreams,
|
||
setStep2InitGate,
|
||
intentSending,
|
||
step2FinalizedCount,
|
||
step3SavedTick = 0,
|
||
} = options;
|
||
|
||
// 同步镜像 taskId,供保存队列在 render 之前读到最新值。
|
||
const taskIdRef = useRef<string | null>(taskId ?? null);
|
||
useEffect(() => {
|
||
taskIdRef.current = taskId ?? null;
|
||
}, [taskId]);
|
||
|
||
// taskId 模式下所有 CRUD 走**独立的 task-draft 存储**(不分权);否则走个人草稿存储。
|
||
// 读 taskIdRef.current(而非闭包捕获)以在 render 前拿到最新值。
|
||
const apiCreate = useCallback(
|
||
(payload: DraftCreatePayload): Promise<DraftRecord> =>
|
||
taskIdRef.current ? createTaskDraft(payload) : createDraft(payload),
|
||
[],
|
||
);
|
||
const apiUpdate = useCallback(
|
||
(id: string, patch: DraftUpdatePayload): Promise<DraftRecord | null> =>
|
||
taskIdRef.current ? updateTaskDraft(id, patch) : updateDraft(id, patch),
|
||
[],
|
||
);
|
||
const apiGet = useCallback(
|
||
(id: string): Promise<DraftRecord | null> => (taskIdRef.current ? getTaskDraft(id) : getDraft(id)),
|
||
[],
|
||
);
|
||
const apiDelete = useCallback(
|
||
(id: string): Promise<void> => (taskIdRef.current ? deleteTaskDraft(id) : deleteDraft(id)),
|
||
[],
|
||
);
|
||
|
||
const [drafts, setDrafts] = useState<DraftListItem[]>([]);
|
||
const [currentDraftId, setCurrentDraftIdState] = useState<string | null>(null);
|
||
const [isHistoryOpen, setIsHistoryOpen] = useState<boolean>(false);
|
||
const [renamingDraftId, setRenamingDraftId] = useState<string | null>(null);
|
||
const [renameDraftValue, setRenameDraftValue] = useState<string>("");
|
||
|
||
const loadedFromDraftRef = useRef<boolean>(false);
|
||
const prevStepRef = useRef<number>(currentStep);
|
||
const historyDropdownRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
// 同步镜像,供保存队列在 render 之前读到最新 id(修复重复建草稿竞态)。
|
||
const currentDraftIdRef = useRef<string | null>(null);
|
||
// 串行化所有写操作,避免并发 create。
|
||
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
||
// 当前步骤镜像 —— 自动保存(不带 furthestStep)落到 create 分支时,用它兜底建档步骤。
|
||
const currentStepRef = useRef<number>(currentStep);
|
||
useEffect(() => {
|
||
currentStepRef.current = currentStep;
|
||
}, [currentStep]);
|
||
|
||
// 包一层 setter:同时更新 state 与同步 ref。主页面通过它 setCurrentDraftId(null)。
|
||
const setCurrentDraftId = useCallback<React.Dispatch<React.SetStateAction<string | null>>>(
|
||
(action) => {
|
||
setCurrentDraftIdState((prev) => {
|
||
const next = typeof action === "function" ? (action as (p: string | null) => string | null)(prev) : action;
|
||
currentDraftIdRef.current = next;
|
||
return next;
|
||
});
|
||
},
|
||
[],
|
||
);
|
||
|
||
const refreshList = useCallback(async () => {
|
||
try {
|
||
// taskId 模式:历史下拉只列该任务的草稿(一个 taskId 多条记录、跨用户共享);
|
||
// 普通模式:列当前用户全部草稿。
|
||
const tid = taskIdRef.current;
|
||
const metas = tid ? await listTaskDraftsByTask(tid) : await listDrafts();
|
||
setDrafts(
|
||
metas.map((m) => ({
|
||
id: m.id,
|
||
name: m.title,
|
||
furthestStep: m.furthestStep,
|
||
createdAt: m.createdAt,
|
||
updatedAt: m.updatedAt,
|
||
status: m.status ?? null,
|
||
jobId: m.jobId ?? null,
|
||
})),
|
||
);
|
||
} catch (err) {
|
||
console.warn("[drafts] 刷新草稿列表失败:", err);
|
||
}
|
||
}, []);
|
||
|
||
// 首次挂载拉一次草稿列表。
|
||
useEffect(() => {
|
||
void refreshList();
|
||
}, [refreshList]);
|
||
|
||
/** 当前会话是否有值得保存的内容。 */
|
||
const hasSavableContent = useCallback((step1: Step1Snapshot, step2: DraftStep2Snapshot): boolean => {
|
||
const hasUserMessage = step1.interactiveMessages.some((m) => m.sender === "user");
|
||
return hasUserMessage || step2.step2RoundtableDialogues.length > 0;
|
||
}, []);
|
||
|
||
/**
|
||
* 串行执行一次保存:有 id 走 update(不带 title,保护重命名);无 id 走 create
|
||
* (写一次派生标题)。create/update 成功后同步 currentDraftIdRef。
|
||
* 返回 draft id(无可保存内容返回 null)。
|
||
*
|
||
* `furthestStep` 可选:
|
||
* - 传值(前进切换 / 手动保存 / ensureDraft)→ update 时一并推进 furthest_step;
|
||
* - 省略(每轮模型回答完的自动保存)→ update **不带** furthest_step,只回写
|
||
* step1/step2 内容,避免把「回到上一步继续研讨」误判成 furthest 回退。create
|
||
* 分支用 currentStepRef 兜底建档步骤。
|
||
*/
|
||
const enqueueSave = useCallback(
|
||
(furthestStep?: 1 | 2 | 3): Promise<string | null> => {
|
||
const run = async (): Promise<string | null> => {
|
||
const step1 = getStep1Snapshot();
|
||
const step2 = getStep2Snapshot();
|
||
if (!hasSavableContent(step1, step2)) return null;
|
||
// 仅在有结果时带上 step3,避免普通的 step1/step2 自动保存把已存的报告抹掉。
|
||
const step3 = getStep3Snapshot?.() ?? null;
|
||
|
||
const id = currentDraftIdRef.current;
|
||
if (id) {
|
||
const updated = await apiUpdate(id, {
|
||
...(furthestStep !== undefined ? { furthestStep } : {}),
|
||
step1,
|
||
step2,
|
||
...(step3 ? { step3 } : {}),
|
||
});
|
||
if (updated) return updated.id;
|
||
// 服务端已不存在(被删/换账号)→ 落回新建。
|
||
}
|
||
const created = await apiCreate({
|
||
...(taskIdRef.current ? { taskId: taskIdRef.current } : {}),
|
||
title: deriveDraftName({
|
||
intentReady: step1.intentReady,
|
||
interactiveMessages: step1.interactiveMessages,
|
||
}),
|
||
furthestStep: furthestStep ?? ((currentStepRef.current as 1 | 2 | 3) || 1),
|
||
step1,
|
||
step2,
|
||
...(step3 ? { step3 } : {}),
|
||
});
|
||
currentDraftIdRef.current = created.id;
|
||
setCurrentDraftId(created.id);
|
||
return created.id;
|
||
};
|
||
const next = saveQueueRef.current.then(run, run);
|
||
saveQueueRef.current = next.catch(() => undefined);
|
||
return next as Promise<string | null>;
|
||
},
|
||
[apiCreate, apiUpdate, getStep1Snapshot, getStep2Snapshot, getStep3Snapshot, hasSavableContent, setCurrentDraftId],
|
||
);
|
||
|
||
const ensureDraft = useCallback(
|
||
async (furthestStep: 1 | 2 | 3 = (currentStep as 1 | 2 | 3) || 1): Promise<string | null> => {
|
||
if (currentDraftIdRef.current) return currentDraftIdRef.current;
|
||
const id = await enqueueSave(furthestStep);
|
||
if (id) await refreshList();
|
||
return id;
|
||
},
|
||
[currentStep, enqueueSave, refreshList],
|
||
);
|
||
|
||
// 强制存当前内容(与 ensureDraft 不同:已有草稿也照存,把最新对话回写进去)。
|
||
const saveDraftNow = useCallback(
|
||
async (furthestStep: 1 | 2 | 3 = (currentStep as 1 | 2 | 3) || 1): Promise<string | null> => {
|
||
const hadId = currentDraftIdRef.current;
|
||
const id = await enqueueSave(furthestStep);
|
||
if (id && !hadId) await refreshList();
|
||
return id;
|
||
},
|
||
[currentStep, enqueueSave, refreshList],
|
||
);
|
||
|
||
/**
|
||
* 用**显式传入的 step2 快照**强制存一次(不读 live 状态)。
|
||
*
|
||
* 「后台挂起改直接跳转」用:点挂起后页面立刻 resetToStep1 会清空 step2,而存稿是
|
||
* fire-and-forget 在跳转之后才真正落库——若仍走 `saveDraftNow`(其 `getStep2Snapshot`
|
||
* 是延迟读取)就会存到被重置后的空 step2。调用方在跳转前同步 `getStep2Snapshot()` 冻结
|
||
* 一份快照传进来,这里只认这份,确保存的是挂起那一刻的研讨记录。
|
||
*
|
||
* 有草稿 id → update(保护重命名,不带 title);无 id → 用当前 step1 + 传入 step2 建档。
|
||
*/
|
||
const saveStep2SnapshotNow = useCallback(
|
||
(step2: DraftStep2Snapshot, furthestStep: 1 | 2 | 3 = 2): Promise<string | null> => {
|
||
const run = async (): Promise<string | null> => {
|
||
const id = currentDraftIdRef.current;
|
||
if (id) {
|
||
const updated = await apiUpdate(id, { furthestStep, step2 });
|
||
if (updated) {
|
||
currentDraftIdRef.current = updated.id;
|
||
return updated.id;
|
||
}
|
||
// 服务端已不存在 → 落回新建。
|
||
}
|
||
const step1 = getStep1Snapshot();
|
||
if (!hasSavableContent(step1, step2)) return null;
|
||
const created = await apiCreate({
|
||
...(taskIdRef.current ? { taskId: taskIdRef.current } : {}),
|
||
title: deriveDraftName({
|
||
intentReady: step1.intentReady,
|
||
interactiveMessages: step1.interactiveMessages,
|
||
}),
|
||
furthestStep,
|
||
step1,
|
||
step2,
|
||
});
|
||
currentDraftIdRef.current = created.id;
|
||
setCurrentDraftId(created.id);
|
||
return created.id;
|
||
};
|
||
const next = saveQueueRef.current.then(run, run);
|
||
saveQueueRef.current = next.catch(() => undefined);
|
||
return next as Promise<string | null>;
|
||
},
|
||
[apiCreate, apiUpdate, getStep1Snapshot, hasSavableContent, setCurrentDraftId],
|
||
);
|
||
|
||
const handleManualSave = useCallback(() => {
|
||
const furthest = (currentStep as 1 | 2 | 3) || 1;
|
||
void (async () => {
|
||
const id = await enqueueSave(furthest);
|
||
if (id) {
|
||
await refreshList();
|
||
triggerToast("💾 已保存为草稿");
|
||
} else {
|
||
triggerToast("当前会话还没有可保存的内容");
|
||
}
|
||
})();
|
||
}, [currentStep, enqueueSave, refreshList, triggerToast]);
|
||
|
||
// 前进切换时自动保存;后退不保存以避免覆盖 furthestStep。
|
||
useEffect(() => {
|
||
const prev = prevStepRef.current;
|
||
prevStepRef.current = currentStep;
|
||
if (currentStep > prev) {
|
||
void (async () => {
|
||
const id = await enqueueSave(currentStep as 1 | 2 | 3);
|
||
if (id) await refreshList();
|
||
})();
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [currentStep]);
|
||
|
||
/**
|
||
* 每轮模型回答完成后落一次草稿。不带 furthestStep(见 enqueueSave),只回写内容。
|
||
* 仅当本次保存是「新建」时才刷新列表 —— 普通增量回写无需让下拉重排/多发一个 GET。
|
||
*/
|
||
const autoSave = useCallback(async (): Promise<void> => {
|
||
const hadId = currentDraftIdRef.current;
|
||
const id = await enqueueSave();
|
||
if (id && !hadId) await refreshList();
|
||
}, [enqueueSave, refreshList]);
|
||
|
||
// ── 触发器 1:Step 1 任务理解一轮回复结束(isIntentSending true→false)。 ──
|
||
const prevIntentSendingRef = useRef<boolean>(intentSending);
|
||
useEffect(() => {
|
||
const was = prevIntentSendingRef.current;
|
||
prevIntentSendingRef.current = intentSending;
|
||
// 此刻 React 已 flush 完终态 setState(气泡内容/INTENT_READY),
|
||
// 快照 getter 读到的是收尾后的最新状态,不会丢最后一段。
|
||
if (was && !intentSending) void autoSave();
|
||
}, [intentSending, autoSave]);
|
||
|
||
// ── 触发器 2:Step 2 某个气泡刚收尾(已收尾计数递增)。 ──
|
||
// 加载草稿会把历史对话整批 hydrate 进来,计数从 0 跳到 N —— 那不是一次真实交付,
|
||
// 用 suppress latch 跳过这一跳变(handleLoadDraft 在 hydrate 前置位)。
|
||
const prevFinalizedCountRef = useRef<number>(step2FinalizedCount);
|
||
const suppressStep2AutoSaveRef = useRef<boolean>(false);
|
||
useEffect(() => {
|
||
const prev = prevFinalizedCountRef.current;
|
||
prevFinalizedCountRef.current = step2FinalizedCount;
|
||
if (suppressStep2AutoSaveRef.current) {
|
||
suppressStep2AutoSaveRef.current = false;
|
||
return;
|
||
}
|
||
if (step2FinalizedCount > prev) void autoSave();
|
||
}, [step2FinalizedCount, autoSave]);
|
||
|
||
// ── 触发器 3:Step 3「结果绘制」完成一次(step3SavedTick 递增)。 ──
|
||
// 此刻页面已把最新报告写进 step3Report state,getStep3Snapshot 读到的是新值。
|
||
const prevStep3TickRef = useRef<number>(step3SavedTick);
|
||
useEffect(() => {
|
||
const prev = prevStep3TickRef.current;
|
||
prevStep3TickRef.current = step3SavedTick;
|
||
if (step3SavedTick > prev) void autoSave();
|
||
}, [step3SavedTick, autoSave]);
|
||
|
||
/** 把一份已取到的草稿 hydrate 进各步骤状态(handleLoadDraft / loadLatestByTask 共用)。 */
|
||
const applyLoadedDraft = useCallback(
|
||
(draft: DraftRecord, toastMsg?: string) => {
|
||
cancelAllStreams();
|
||
// 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect
|
||
// 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。
|
||
loadedFromDraftRef.current = true;
|
||
// hydrate 会让 step2 已收尾计数从 0 跳到 N,抑制由此触发的「假交付」自动保存,
|
||
// 避免刚加载就把同样内容原样回写一遍、并把该草稿顶到列表最前。
|
||
suppressStep2AutoSaveRef.current = true;
|
||
|
||
if (draft.step1) hydrateStep1(draft.step1 as Step1Snapshot);
|
||
if (draft.step2) {
|
||
hydrateStep2(
|
||
draft.step2 as DraftStep2Snapshot,
|
||
(draft.step3 as DraftStep3Snapshot | null) ?? null,
|
||
);
|
||
}
|
||
// 恢复 Step 3 报告(无则传 null 清掉上一条草稿残留),进入「结果绘制」即可看回。
|
||
hydrateStep3?.((draft.step3 as DraftStep3Snapshot | null) ?? null);
|
||
setStep2InitGate(draft.furthestStep >= 2);
|
||
|
||
setCurrentDraftId(draft.id);
|
||
setIsHistoryOpen(false);
|
||
prevStepRef.current = draft.furthestStep;
|
||
setCurrentStep(draft.furthestStep);
|
||
if (toastMsg) triggerToast(toastMsg);
|
||
},
|
||
[
|
||
cancelAllStreams,
|
||
hydrateStep1,
|
||
hydrateStep2,
|
||
hydrateStep3,
|
||
setCurrentDraftId,
|
||
setCurrentStep,
|
||
setStep2InitGate,
|
||
triggerToast,
|
||
],
|
||
);
|
||
|
||
// 返回 Promise<是否成功 hydrate>:调用方(如"查看运行中作业")需在 hydrate 完成
|
||
// **之后**再订阅 SSE 实时流,否则草稿快照会覆盖实时帧(竞态)。
|
||
const handleLoadDraft = useCallback(
|
||
(id: string): Promise<boolean> => {
|
||
return (async () => {
|
||
let draft;
|
||
try {
|
||
draft = await apiGet(id);
|
||
} catch (err) {
|
||
console.warn("[drafts] 加载草稿失败:", err);
|
||
triggerToast("⚠️ 加载草稿失败,请重试");
|
||
return false;
|
||
}
|
||
if (!draft) {
|
||
triggerToast("⚠️ 该草稿已不存在");
|
||
await refreshList();
|
||
return false;
|
||
}
|
||
applyLoadedDraft(draft, `📂 已加载草稿:${draft.title}`);
|
||
return true;
|
||
})();
|
||
},
|
||
[apiGet, applyLoadedDraft, refreshList, triggerToast],
|
||
);
|
||
|
||
/**
|
||
* 按外部任务 id 取最新一条草稿并 hydrate 回显(无界抽屉打开时调用)。
|
||
* 返回是否命中(命中 → 调用方应抑制 autoStart,避免重复发首条消息)。
|
||
*/
|
||
const loadLatestByTask = useCallback(
|
||
async (tid: string): Promise<boolean> => {
|
||
let draft: DraftRecord | null = null;
|
||
try {
|
||
draft = await getLatestTaskDraftByTask(tid);
|
||
} catch (err) {
|
||
console.warn("[drafts] 按任务加载草稿失败:", err);
|
||
return false;
|
||
}
|
||
if (!draft) return false;
|
||
applyLoadedDraft(draft);
|
||
return true;
|
||
},
|
||
[applyLoadedDraft],
|
||
);
|
||
|
||
const handleDeleteDraft = useCallback(
|
||
(id: string) => {
|
||
if (typeof window !== "undefined" && !window.confirm("确定要删除这条历史记录吗?此操作不可恢复。")) {
|
||
return;
|
||
}
|
||
void (async () => {
|
||
try {
|
||
await apiDelete(id);
|
||
} catch (err) {
|
||
console.warn("[drafts] 删除草稿失败:", err);
|
||
triggerToast("⚠️ 删除失败,请重试");
|
||
return;
|
||
}
|
||
await refreshList();
|
||
if (currentDraftIdRef.current === id) setCurrentDraftId(null);
|
||
if (renamingDraftId === id) setRenamingDraftId(null);
|
||
triggerToast("🗑 已删除历史记录");
|
||
})();
|
||
},
|
||
[apiDelete, refreshList, renamingDraftId, setCurrentDraftId, triggerToast],
|
||
);
|
||
|
||
const handleStartRename = useCallback((draft: DraftListItem) => {
|
||
setRenamingDraftId(draft.id);
|
||
setRenameDraftValue(draft.name);
|
||
}, []);
|
||
|
||
const handleCommitRename = useCallback(
|
||
(id: string) => {
|
||
const trimmed = renameDraftValue.trim();
|
||
if (!trimmed) {
|
||
setRenamingDraftId(null);
|
||
setRenameDraftValue("");
|
||
return;
|
||
}
|
||
void (async () => {
|
||
try {
|
||
// 只发 title —— 与自动保存的「不带 title」互补,重命名是唯一改标题的入口。
|
||
const updated = await apiUpdate(id, { title: trimmed });
|
||
if (updated) {
|
||
await refreshList();
|
||
triggerToast("✏️ 已重命名");
|
||
}
|
||
} catch (err) {
|
||
console.warn("[drafts] 重命名失败:", err);
|
||
triggerToast("⚠️ 重命名失败,请重试");
|
||
} finally {
|
||
setRenamingDraftId(null);
|
||
setRenameDraftValue("");
|
||
}
|
||
})();
|
||
},
|
||
[apiUpdate, refreshList, renameDraftValue, triggerToast],
|
||
);
|
||
|
||
const handleCancelRename = useCallback(() => {
|
||
setRenamingDraftId(null);
|
||
setRenameDraftValue("");
|
||
}, []);
|
||
|
||
// 点击外部收起下拉。只在打开时挂监听,关闭时立刻摘掉。
|
||
useEffect(() => {
|
||
if (!isHistoryOpen) return;
|
||
const handler = (e: MouseEvent) => {
|
||
if (!historyDropdownRef.current) return;
|
||
if (e.target instanceof Node && !historyDropdownRef.current.contains(e.target)) {
|
||
setIsHistoryOpen(false);
|
||
}
|
||
};
|
||
document.addEventListener("mousedown", handler);
|
||
return () => document.removeEventListener("mousedown", handler);
|
||
}, [isHistoryOpen]);
|
||
|
||
return {
|
||
drafts,
|
||
currentDraftId,
|
||
isHistoryOpen,
|
||
renamingDraftId,
|
||
renameDraftValue,
|
||
historyDropdownRef,
|
||
loadedFromDraftRef,
|
||
setIsHistoryOpen,
|
||
setRenameDraftValue,
|
||
setCurrentDraftId,
|
||
handleManualSave,
|
||
handleLoadDraft,
|
||
loadLatestByTask,
|
||
handleDeleteDraft,
|
||
handleStartRename,
|
||
handleCommitRename,
|
||
handleCancelRename,
|
||
ensureDraft,
|
||
saveDraftNow,
|
||
saveStep2SnapshotNow,
|
||
/** 强制刷新草稿列表(如后台挂起后让「进行中」状态立刻出现)。 */
|
||
refresh: refreshList,
|
||
};
|
||
}
|