feat(web): per-topic 输入草稿记忆(切换暂存/恢复 + localStorage 持久化 + 删除清理)
- 文本草稿 debounce 持久化到 localStorage(key 按 topicId),附件仅存内存 - 切换话题时暂存当前草稿并恢复目标话题草稿,组件卸载时补齐落盘 - 删除话题时同步清除其持久化草稿,避免孤儿 key
This commit is contained in:
parent
8f59b6b93a
commit
53e2fb6dc6
@ -15,6 +15,40 @@ import type { Attachment } from '../../types/protocol';
|
|||||||
|
|
||||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
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 {
|
interface MessageInputProps {
|
||||||
onSend: (content: string, attachments: Attachment[]) => void;
|
onSend: (content: string, attachments: Attachment[]) => void;
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
@ -24,7 +58,7 @@ interface MessageInputProps {
|
|||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
channelName?: string;
|
channelName?: string;
|
||||||
selectedExpert?: { name: string; description: string } | null;
|
selectedExpert?: { name: string; description: string } | null;
|
||||||
/** 当前话题 ID,切换话题时自动清空草稿 */
|
/** 当前话题 ID:切换话题时保留各自草稿(文本持久化到 localStorage,附件仅内存) */
|
||||||
topicId?: string | null;
|
topicId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,16 +99,77 @@ export function MessageInput({
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const wasLoadingRef = useRef(false);
|
const wasLoadingRef = useRef(false);
|
||||||
const prevTopicIdRef = useRef<string | null | undefined>(topicId);
|
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(() => {
|
useEffect(() => {
|
||||||
if (prevTopicIdRef.current !== topicId) {
|
if (prevTopicIdRef.current === topicId) return;
|
||||||
|
const prevTopicId = prevTopicIdRef.current;
|
||||||
prevTopicIdRef.current = topicId;
|
prevTopicIdRef.current = topicId;
|
||||||
|
|
||||||
|
// 离开话题:立即持久化(补齐 debounce 未落盘部分)+ 暂存到内存(含附件)
|
||||||
|
if (prevTopicId != null) {
|
||||||
|
saveDraftText(prevTopicId, content);
|
||||||
|
draftsRef.current.set(prevTopicId, { content, attachments });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复目标话题草稿
|
||||||
|
if (topicId == null) {
|
||||||
setContent('');
|
setContent('');
|
||||||
setAttachments([]);
|
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]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
@ -250,6 +345,11 @@ export function MessageInput({
|
|||||||
content.trim(),
|
content.trim(),
|
||||||
attachments.map((a) => a.attachment),
|
attachments.map((a) => a.attachment),
|
||||||
);
|
);
|
||||||
|
// 发送成功:清除该话题的草稿(内存 + localStorage)
|
||||||
|
if (topicId != null) {
|
||||||
|
draftsRef.current.delete(topicId);
|
||||||
|
saveDraftText(topicId, '');
|
||||||
|
}
|
||||||
setContent('');
|
setContent('');
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
type MutableRefObject,
|
type MutableRefObject,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import type { Topic, TopicList, TopicRenamed, TopicSummary, Command } from '../../types/protocol';
|
import type { Topic, TopicList, TopicRenamed, TopicSummary, Command } from '../../types/protocol';
|
||||||
|
import { clearTopicDraft } from '../../components/Chat/MessageInput';
|
||||||
|
|
||||||
export interface UseTopicsReturn {
|
export interface UseTopicsReturn {
|
||||||
topics: Topic[];
|
topics: Topic[];
|
||||||
@ -101,6 +102,8 @@ export function useTopics(): UseTopicsReturn {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const deleteTopic = useCallback((topicId: string): Command => {
|
const deleteTopic = useCallback((topicId: string): Command => {
|
||||||
|
// 同步清除该话题的持久化草稿(删除后端话题成功与否均无碍:残留草稿 key 无话题可挂载)
|
||||||
|
clearTopicDraft(topicId);
|
||||||
return { type: 'delete_topic', topic_id: topicId };
|
return { type: 'delete_topic', topic_id: topicId };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user