feat(web): per-topic 输入草稿记忆(切换暂存/恢复 + localStorage 持久化 + 删除清理)

- 文本草稿 debounce 持久化到 localStorage(key 按 topicId),附件仅存内存
- 切换话题时暂存当前草稿并恢复目标话题草稿,组件卸载时补齐落盘
- 删除话题时同步清除其持久化草稿,避免孤儿 key
This commit is contained in:
oudecheng 2026-08-15 15:35:07 +08:00
parent 8f59b6b93a
commit 53e2fb6dc6
2 changed files with 107 additions and 4 deletions

View File

@ -15,6 +15,40 @@ import type { Attachment } from '../../types/protocol';
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
/** per-topic 文本草稿的 localStorage key附件 File 对象不可序列化,仅存内存) */
export function draftStorageKey(topicId: string): string {
return `picobot:draft:${topicId}`;
}
function loadDraftText(topicId: string): string {
try {
return localStorage.getItem(draftStorageKey(topicId)) ?? '';
} catch {
return '';
}
}
function saveDraftText(topicId: string, text: string): void {
try {
if (text.trim()) {
localStorage.setItem(draftStorageKey(topicId), text);
} else {
localStorage.removeItem(draftStorageKey(topicId));
}
} catch {
// 隐私模式/配额满:静默降级为仅内存草稿
}
}
/** 删除话题时调用:清除其持久化草稿 */
export function clearTopicDraft(topicId: string): void {
try {
localStorage.removeItem(draftStorageKey(topicId));
} catch {
// ignore
}
}
interface MessageInputProps {
onSend: (content: string, attachments: Attachment[]) => void;
onStop?: () => void;
@ -24,7 +58,7 @@ interface MessageInputProps {
isReadOnly?: boolean;
channelName?: string;
selectedExpert?: { name: string; description: string } | null;
/** 当前话题 ID,切换话题时自动清空草稿 */
/** 当前话题 ID:切换话题时保留各自草稿(文本持久化到 localStorage附件仅内存 */
topicId?: string | null;
}
@ -65,16 +99,77 @@ export function MessageInput({
const fileInputRef = useRef<HTMLInputElement>(null);
const wasLoadingRef = useRef(false);
const prevTopicIdRef = useRef<string | null | undefined>(topicId);
// per-topic 草稿缓存:切换话题时暂存/恢复(附件 File 对象仅内存,刷新丢失)
const draftsRef = useRef<Map<string, { content: string; attachments: FileAttachment[] }>>(
new Map(),
);
const draftDebounceRef = useRef<number | null>(null);
// 切换话题时清空草稿(替代原来通过 key remount 的重置机制)
// 切换话题时:暂存当前草稿 → 恢复目标话题草稿(内存 miss 时读 localStorage
useEffect(() => {
if (prevTopicIdRef.current !== topicId) {
prevTopicIdRef.current = topicId;
if (prevTopicIdRef.current === topicId) return;
const prevTopicId = prevTopicIdRef.current;
prevTopicIdRef.current = topicId;
// 离开话题:立即持久化(补齐 debounce 未落盘部分)+ 暂存到内存(含附件)
if (prevTopicId != null) {
saveDraftText(prevTopicId, content);
draftsRef.current.set(prevTopicId, { content, attachments });
}
// 恢复目标话题草稿
if (topicId == null) {
setContent('');
setAttachments([]);
return;
}
const cached = draftsRef.current.get(topicId);
if (cached) {
setContent(cached.content);
setAttachments(cached.attachments);
} else {
setContent(loadDraftText(topicId));
setAttachments([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [topicId]);
// 文本草稿 debounce 持久化300ms
useEffect(() => {
if (topicId == null) return;
if (draftDebounceRef.current != null) {
window.clearTimeout(draftDebounceRef.current);
}
draftDebounceRef.current = window.setTimeout(() => {
saveDraftText(topicId, content);
}, 300);
return () => {
if (draftDebounceRef.current != null) {
window.clearTimeout(draftDebounceRef.current);
}
};
}, [content, topicId]);
// 卸载时补齐持久化debounce 可能未落盘)+ 暂存内存草稿(含附件,供同 SPA 会话恢复)
const latestContentRef = useRef(content);
const latestAttachmentsRef = useRef(attachments);
latestContentRef.current = content;
latestAttachmentsRef.current = attachments;
useEffect(() => {
const drafts = draftsRef.current;
return () => {
const tid = prevTopicIdRef.current;
if (tid != null) {
saveDraftText(tid, latestContentRef.current);
drafts.set(tid, {
content: latestContentRef.current,
attachments: latestAttachmentsRef.current,
});
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
@ -250,6 +345,11 @@ export function MessageInput({
content.trim(),
attachments.map((a) => a.attachment),
);
// 发送成功:清除该话题的草稿(内存 + localStorage
if (topicId != null) {
draftsRef.current.delete(topicId);
saveDraftText(topicId, '');
}
setContent('');
setAttachments([]);
setError(null);

View File

@ -8,6 +8,7 @@ import {
type MutableRefObject,
} from 'react';
import type { Topic, TopicList, TopicRenamed, TopicSummary, Command } from '../../types/protocol';
import { clearTopicDraft } from '../../components/Chat/MessageInput';
export interface UseTopicsReturn {
topics: Topic[];
@ -101,6 +102,8 @@ export function useTopics(): UseTopicsReturn {
}, []);
const deleteTopic = useCallback((topicId: string): Command => {
// 同步清除该话题的持久化草稿(删除后端话题成功与否均无碍:残留草稿 key 无话题可挂载)
clearTopicDraft(topicId);
return { type: 'delete_topic', topic_id: topicId };
}, []);