Files
deerflow-code/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts
T
fengxiangdebaoziandClaude Opus 4.8 a8da6c2632 feat(roundtable): taskId 会商记录独立存储(不分权) + 第三步生成过程对话/步骤条入库
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>
2026-06-18 15:11:50 +08:00

626 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 圆桌规划草稿持久化 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,
};
}