9b4a25799f
Replace fixed ackText with a rule-based AckProvider that picks response templates by message type and intent (translate, summary, rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync, zero I/O, auto-falls back to config.ackText on any error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
226 lines
7.2 KiB
TypeScript
226 lines
7.2 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import {
|
||
regenerateMindSpacePageThumbnail,
|
||
uploadMindSpacePageThumbnail,
|
||
} from '../api/client';
|
||
import { compressImageForUpload } from '../utils/imageUpload';
|
||
import { MindSpacePageEditablePreviewFrame } from './MindSpacePageEditablePreviewFrame';
|
||
|
||
async function readCompressedImageAsBase64(file: File): Promise<{ base64: string; mimeType: string }> {
|
||
const compressed = await compressImageForUpload(file, {
|
||
maxInputBytes: 8 * 1024 * 1024,
|
||
maxOutputBytes: 900 * 1024,
|
||
maxDimension: 1200,
|
||
});
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const result = String(reader.result ?? '');
|
||
const match = result.match(/^data:([^;]+);base64,(.+)$/);
|
||
if (!match) {
|
||
reject(new Error('无法读取图片'));
|
||
return;
|
||
}
|
||
resolve({ mimeType: match[1], base64: match[2] });
|
||
};
|
||
reader.onerror = () => reject(new Error('无法读取图片'));
|
||
reader.readAsDataURL(compressed);
|
||
});
|
||
}
|
||
|
||
export function MindSpacePagePreviewPanel({
|
||
pageId,
|
||
title,
|
||
summary,
|
||
content,
|
||
reloadKey,
|
||
onFrameReady,
|
||
onContentChange,
|
||
}: {
|
||
pageId: string;
|
||
title: string;
|
||
summary: string;
|
||
content: string;
|
||
reloadKey: number;
|
||
onFrameReady?: (iframe: HTMLIFrameElement | null) => void;
|
||
onContentChange: (html: string) => void;
|
||
}) {
|
||
const [thumbnailFailed, setThumbnailFailed] = useState(false);
|
||
const [thumbnailVersion, setThumbnailVersion] = useState(reloadKey);
|
||
const [thumbBusy, setThumbBusy] = useState<'upload' | 'ai' | null>(null);
|
||
const [thumbNotice, setThumbNotice] = useState<string | null>(null);
|
||
const [thumbError, setThumbError] = useState<string | null>(null);
|
||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||
const thumbnailRefreshTokenRef = useRef(0);
|
||
const latestDraftRef = useRef({ title, summary, content });
|
||
|
||
const thumbnailUrl = `/api/mindspace/v1/pages/${pageId}/thumbnail?v=${thumbnailVersion}`;
|
||
|
||
useEffect(() => {
|
||
setThumbnailVersion(reloadKey);
|
||
}, [reloadKey]);
|
||
|
||
useEffect(() => {
|
||
setThumbnailFailed(false);
|
||
}, [thumbnailUrl]);
|
||
|
||
useEffect(() => {
|
||
latestDraftRef.current = { title, summary, content };
|
||
}, [content, summary, title]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
const refreshToken = ++thumbnailRefreshTokenRef.current;
|
||
const { title: draftTitle, summary: draftSummary, content: draftContent } = latestDraftRef.current;
|
||
|
||
const syncThumbnail = async () => {
|
||
try {
|
||
const result = await regenerateMindSpacePageThumbnail(pageId, {
|
||
title: draftTitle,
|
||
summary: draftSummary,
|
||
content: draftContent,
|
||
});
|
||
if (cancelled || thumbnailRefreshTokenRef.current !== refreshToken) return;
|
||
setThumbnailVersion(result.updatedAt ?? Date.now());
|
||
setThumbnailFailed(false);
|
||
} catch {
|
||
if (cancelled || thumbnailRefreshTokenRef.current !== refreshToken) return;
|
||
setThumbnailFailed(false);
|
||
}
|
||
};
|
||
|
||
void syncThumbnail();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [pageId, reloadKey]);
|
||
|
||
const bumpThumbnail = () => {
|
||
setThumbnailVersion(Date.now());
|
||
setThumbnailFailed(false);
|
||
};
|
||
|
||
const handleUploadClick = () => {
|
||
uploadInputRef.current?.click();
|
||
};
|
||
|
||
const handleUploadFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = '';
|
||
if (!file) return;
|
||
if (!file.type.startsWith('image/')) {
|
||
setThumbError('请选择图片文件');
|
||
return;
|
||
}
|
||
setThumbBusy('upload');
|
||
setThumbError(null);
|
||
setThumbNotice(null);
|
||
try {
|
||
const { base64, mimeType } = await readCompressedImageAsBase64(file);
|
||
await uploadMindSpacePageThumbnail(pageId, {
|
||
imageBase64: base64,
|
||
mimeType,
|
||
title,
|
||
summary,
|
||
content,
|
||
});
|
||
bumpThumbnail();
|
||
setThumbNotice('封面已替换,保存页面后仍会保留');
|
||
} catch (err) {
|
||
setThumbError(err instanceof Error ? err.message : '上传封面失败');
|
||
} finally {
|
||
setThumbBusy(null);
|
||
}
|
||
};
|
||
|
||
const handleAiRegenerate = async () => {
|
||
setThumbBusy('ai');
|
||
setThumbError(null);
|
||
setThumbNotice(null);
|
||
try {
|
||
const result = await regenerateMindSpacePageThumbnail(pageId, {
|
||
title,
|
||
summary,
|
||
content,
|
||
useAi: true,
|
||
instruction: '根据当前页面内容与标题,生成更精美、可在信息流中直接展示的中文封面。',
|
||
});
|
||
if (result.content) {
|
||
onContentChange(result.content);
|
||
}
|
||
bumpThumbnail();
|
||
setThumbNotice('AI 已重新生成封面');
|
||
} catch (err) {
|
||
setThumbError(err instanceof Error ? err.message : 'AI 封面生成失败');
|
||
} finally {
|
||
setThumbBusy(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="page-save-preview-dual mindspace-page-preview-dual">
|
||
<div
|
||
className="page-save-preview-pane page-save-preview-pane-page"
|
||
onWheelCapture={(event) => event.stopPropagation()}
|
||
>
|
||
<span className="page-save-preview-label">页面预览编辑</span>
|
||
<p className="page-save-preview-hint">
|
||
微型页面,可上下滑动查看完整效果 · 虚线框:蓝=文字 · 橙=图片 · 紫=链接 · 绿=背景
|
||
</p>
|
||
<div className="page-save-mini-page">
|
||
<MindSpacePageEditablePreviewFrame
|
||
title={title}
|
||
content={content}
|
||
reloadKey={reloadKey}
|
||
compact
|
||
showHint={false}
|
||
onFrameReady={onFrameReady}
|
||
onContentChange={onContentChange}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="page-save-preview-pane page-save-preview-pane-thumb">
|
||
<span className="page-save-preview-label">卡片预览图</span>
|
||
<p className="page-save-preview-hint">保存后在信息流中显示的 3:4 封面</p>
|
||
<div className="page-save-card-thumb">
|
||
{!thumbnailFailed ? (
|
||
<img
|
||
src={thumbnailUrl}
|
||
alt={`${title || '页面'} 卡片预览图`}
|
||
onError={() => setThumbnailFailed(true)}
|
||
/>
|
||
) : (
|
||
<span className="page-save-card-thumb-placeholder">预览图生成失败</span>
|
||
)}
|
||
</div>
|
||
<div className="mindspace-page-thumb-actions">
|
||
<input
|
||
ref={uploadInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
hidden
|
||
onChange={(event) => void handleUploadFile(event)}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={handleUploadClick}
|
||
disabled={thumbBusy !== null}
|
||
>
|
||
{thumbBusy === 'upload' ? '上传中…' : '上传替换'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleAiRegenerate()}
|
||
disabled={thumbBusy !== null}
|
||
>
|
||
{thumbBusy === 'ai' ? '生成中…' : 'AI 重新生成'}
|
||
</button>
|
||
</div>
|
||
{thumbNotice ? <p className="mindspace-page-thumb-note">{thumbNotice}</p> : null}
|
||
{thumbError ? <p className="mindspace-page-thumb-error">{thumbError}</p> : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|