195 lines
7.2 KiB
TypeScript
195 lines
7.2 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { buildChatSavePreviewFrameUrl, downloadChatMessageDocx, quickShareFromChat } from '../api/client';
|
||
|
||
type SharePhase =
|
||
| { kind: 'idle' }
|
||
| { kind: 'loading' }
|
||
| { kind: 'done'; publicUrl: string }
|
||
| { kind: 'error'; message: string };
|
||
|
||
type ExportPhase =
|
||
| { kind: 'idle' }
|
||
| { kind: 'image-loading' }
|
||
| { kind: 'docx-loading' }
|
||
| { kind: 'error'; message: string };
|
||
|
||
function appendLongImageDownloadParam(url: string) {
|
||
const next = new URL(url, window.location.href);
|
||
next.searchParams.set('download', 'long-image');
|
||
return next.toString();
|
||
}
|
||
|
||
function triggerUrlDownload(url: string) {
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = '';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
}
|
||
|
||
function downloadBlob(blob: Blob, filename: string) {
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = filename;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||
}
|
||
|
||
export function ChatSharePreviewModal({
|
||
sessionId,
|
||
messageId,
|
||
selectedLinkIndex = 0,
|
||
onClose,
|
||
onSave,
|
||
}: {
|
||
sessionId: string;
|
||
messageId: string;
|
||
selectedLinkIndex?: number;
|
||
onClose: () => void;
|
||
onSave?: () => void;
|
||
}) {
|
||
const [phase, setPhase] = useState<SharePhase>({ kind: 'idle' });
|
||
const [exportPhase, setExportPhase] = useState<ExportPhase>({ kind: 'idle' });
|
||
const [copied, setCopied] = useState(false);
|
||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
const previewUrl = buildChatSavePreviewFrameUrl({ sessionId, messageId, selectedLinkIndex });
|
||
|
||
useEffect(() => {
|
||
const prev = document.body.style.overflow;
|
||
document.body.style.overflow = 'hidden';
|
||
return () => { document.body.style.overflow = prev; };
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [onClose]);
|
||
|
||
useEffect(() => () => { if (copyTimer.current) clearTimeout(copyTimer.current); }, []);
|
||
|
||
const share = async () => {
|
||
if (phase.kind === 'loading') return;
|
||
setPhase({ kind: 'loading' });
|
||
try {
|
||
const result = await quickShareFromChat({ sessionId, messageId, selectedLinkIndex });
|
||
setPhase({ kind: 'done', publicUrl: result.publicUrl });
|
||
} catch (err) {
|
||
setPhase({ kind: 'error', message: err instanceof Error ? err.message : '公开分享失败,请重试' });
|
||
}
|
||
};
|
||
|
||
const ensurePublicUrl = async () => {
|
||
if (phase.kind === 'done') return phase.publicUrl;
|
||
setPhase({ kind: 'loading' });
|
||
const result = await quickShareFromChat({ sessionId, messageId, selectedLinkIndex });
|
||
setPhase({ kind: 'done', publicUrl: result.publicUrl });
|
||
return result.publicUrl;
|
||
};
|
||
|
||
const downloadImage = async () => {
|
||
if (exportPhase.kind === 'image-loading' || exportPhase.kind === 'docx-loading') return;
|
||
setExportPhase({ kind: 'image-loading' });
|
||
try {
|
||
const publicUrl = await ensurePublicUrl();
|
||
triggerUrlDownload(appendLongImageDownloadParam(publicUrl));
|
||
setExportPhase({ kind: 'idle' });
|
||
} catch (err) {
|
||
setExportPhase({ kind: 'error', message: err instanceof Error ? err.message : '长图下载失败,请重试' });
|
||
setPhase((current) => (current.kind === 'loading' ? { kind: 'idle' } : current));
|
||
}
|
||
};
|
||
|
||
const downloadDocx = async () => {
|
||
if (exportPhase.kind === 'image-loading' || exportPhase.kind === 'docx-loading') return;
|
||
setExportPhase({ kind: 'docx-loading' });
|
||
try {
|
||
const result = await downloadChatMessageDocx({ sessionId, messageId, selectedLinkIndex });
|
||
downloadBlob(result.blob, result.filename);
|
||
setExportPhase({ kind: 'idle' });
|
||
} catch (err) {
|
||
setExportPhase({ kind: 'error', message: err instanceof Error ? err.message : '文档下载失败,请重试' });
|
||
}
|
||
};
|
||
|
||
const copy = async (url: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(url);
|
||
setCopied(true);
|
||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||
copyTimer.current = setTimeout(() => setCopied(false), 2000);
|
||
} catch { /* ignore */ }
|
||
};
|
||
|
||
const handleSave = () => {
|
||
onClose();
|
||
onSave?.();
|
||
};
|
||
|
||
return createPortal(
|
||
<div className="chat-share-preview-overlay" role="dialog" aria-modal="true" aria-label="页面预览">
|
||
<div className="chat-share-preview-header">
|
||
<span className="chat-share-preview-title">页面预览</span>
|
||
<button type="button" className="chat-share-preview-close" onClick={onClose} aria-label="关闭">✕</button>
|
||
</div>
|
||
<div className="chat-share-preview-body">
|
||
<iframe src={previewUrl} title="页面预览" sandbox="allow-same-origin allow-scripts allow-popups allow-downloads" className="chat-share-preview-frame" />
|
||
|
||
<div className="chat-share-side-actions">
|
||
{phase.kind === 'done' ? (
|
||
<div className="chat-share-result-card">
|
||
<p className="chat-share-result-label">✅ 已公开!</p>
|
||
<button type="button" className="chat-share-result-copy" onClick={() => void copy(phase.publicUrl)}>
|
||
{copied ? '已复制 ✓' : '复制链接'}
|
||
</button>
|
||
<a href={phase.publicUrl} target="_blank" rel="noreferrer" className="chat-share-result-open">
|
||
打开页面 →
|
||
</a>
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className={`chat-share-side-btn chat-share-side-btn-primary${phase.kind === 'loading' ? ' is-loading' : ''}`}
|
||
disabled={phase.kind === 'loading'}
|
||
onClick={() => void share()}
|
||
title="将页面转为公开链接,私有图片自动转为公开地址"
|
||
>
|
||
{phase.kind === 'loading' ? '处理中…' : '公开分享'}
|
||
</button>
|
||
)}
|
||
{phase.kind === 'error' && <p className="chat-share-side-error">{phase.message}</p>}
|
||
{onSave && (
|
||
<button type="button" className="chat-share-side-btn" onClick={handleSave}>
|
||
保存页面
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="chat-share-side-btn"
|
||
disabled={exportPhase.kind === 'image-loading' || exportPhase.kind === 'docx-loading'}
|
||
onClick={() => void downloadImage()}
|
||
>
|
||
{exportPhase.kind === 'image-loading' ? '生成图片…' : '下载图片'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="chat-share-side-btn"
|
||
disabled={exportPhase.kind === 'image-loading' || exportPhase.kind === 'docx-loading'}
|
||
onClick={() => void downloadDocx()}
|
||
>
|
||
{exportPhase.kind === 'docx-loading' ? '生成文档…' : '保存文档'}
|
||
</button>
|
||
{exportPhase.kind === 'error' && <p className="chat-share-side-error">{exportPhase.message}</p>}
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|