Files
memind/src/components/MindSpacePageDetail.tsx
T
john 2b8dba1784 feat(mindspace): 0630004 空间 UI、聊天连接、微信分享与 Agent 能力
含 MindSpace 三列布局与统计修复、聊天加载态与连接降级、平台页脚标记与 og:site_name 微信卡片、勾选资料删除 Agent 接口及内部话术过滤。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 09:30:51 +08:00

1494 lines
50 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.
import { useCallback, useEffect, useRef, useState } from 'react';
import { usePageDraftHistory, type PageDraftSnapshot } from '../hooks/usePageDraftHistory';
import {
checkMindSpacePagePublication,
deleteMindSpacePage,
fixMindSpacePagePublication,
getMindSpacePageDeletePreview,
getMindSpacePage,
getMindSpacePublicationStats,
listPlazaCategories,
offlineMindSpacePublication,
publishMindSpacePage,
publishPageToPlaza,
redactMindSpacePage,
updateMindSpacePage,
updatePublicationStatus,
} from '../api/client';
import type {
MindSpacePage,
MindSpacePageDeletePreview,
MindSpacePublication,
MindSpacePublicationStats,
MindSpacePublishCheck,
MindSpaceQuota,
MindSpaceRedactionChange,
PlazaCategory,
PlazaPostBrief,
} from '../types';
import { resolvePlazaPostUrl, resolvePublicPageUrl } from '../utils/publicUrl';
import { MindSpacePageDraftPreviewFrame, type MindSpacePreviewChangeHighlight } from './MindSpacePageDraftPreviewFrame';
import {
MindSpacePageFullscreenPreview,
} from './MindSpacePageFullscreenPreview';
import { MindSpacePagePreviewPanel } from './MindSpacePagePreviewPanel';
import { MindSpaceModal } from './MindSpaceModal';
import { MindSpaceDeletePlazaOption } from './MindSpaceDeletePlazaOption';
import { MindSpacePublishSuccess } from './MindSpacePublishSuccess';
import { prepareHtmlPageBrandMarkers } from '../../mindspace-page-tag.mjs';
type AccessMode = MindSpacePublishCheck['accessMode'];
const TEMPLATE_LABELS: Record<string, string> = {
editorial: '编辑长文',
report: '分析报告',
'knowledge-card': '知识卡片',
profile: '个人介绍',
'static-html': '静态 HTML',
};
const STATUS_LABELS: Record<string, string> = {
draft: '草稿',
reviewing: '待检查',
risk_found: '发现风险',
ready: '可发布',
published: '已公开',
protected: '受保护',
expired: '已过期',
offline: '已下线',
};
const ACCESS_MODE_LABELS: Record<AccessMode, string> = {
public: '完全公开',
password: '密码访问',
private_link: '私密链接',
time_limited: '限时访问',
login_required: '仅登录用户',
owner_only: '仅自己可见',
};
function slugFromTitle(title: string, pageId?: string) {
const ascii = title
.normalize('NFKC')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, pageId ? 54 : 64);
const base = ascii || 'page';
if (pageId) {
return `${base}-${pageId.replace(/-/g, '').slice(0, 8)}`.slice(0, 64);
}
return base;
}
function localDateTimeValue(timestamp: number) {
const date = new Date(timestamp);
return new Date(date.getTime() - date.getTimezoneOffset() * 60_000)
.toISOString()
.slice(0, 16);
}
function truncateLabel(text: string, max = 48) {
const value = String(text ?? '').replace(/\s+/g, ' ').trim();
if (value.length <= max) return value;
return `${value.slice(0, max - 1)}…`;
}
type Html2CanvasRenderer = (
element: HTMLElement,
options?: Record<string, unknown>,
) => Promise<HTMLCanvasElement>;
type Html2CanvasWindow = Window & { html2canvas?: Html2CanvasRenderer };
const HTML2CANVAS_CDN = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';
const LONG_IMAGE_RENDER_SCALE = 2;
let html2CanvasLoader: Promise<Html2CanvasRenderer> | null = null;
async function loadHtml2CanvasRenderer(): Promise<Html2CanvasRenderer> {
const existing = (window as Html2CanvasWindow).html2canvas;
if (existing) return existing;
if (html2CanvasLoader) return html2CanvasLoader;
html2CanvasLoader = new Promise<Html2CanvasRenderer>((resolve, reject) => {
const script = document.createElement('script');
script.src = HTML2CANVAS_CDN;
script.async = true;
script.onload = () => {
const loaded = (window as Html2CanvasWindow).html2canvas;
if (!loaded) {
reject(new Error('无法加载页面截图能力'));
return;
}
resolve(loaded);
};
script.onerror = () => {
reject(new Error('无法加载页面截图能力'));
};
document.body.appendChild(script);
}).catch((error) => {
html2CanvasLoader = null;
throw error;
});
return html2CanvasLoader;
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ''));
reader.onerror = () => reject(new Error('无法读取图片数据'));
reader.readAsDataURL(blob);
});
}
function downloadBlobAsFile(dataUrl: string, filename: string) {
const link = document.createElement('a');
link.href = dataUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
}
function buildExportFilename(pageId: string, title: string) {
const safeTitle = String(title ?? '')
.trim()
.replace(/[\\/*?:"<>|]/g, '-')
.replace(/\s+/g, '-')
.slice(0, 24);
const suffix = safeTitle ? `-${safeTitle}` : '';
const timestamp = new Date().toISOString().replace(/[T:]/g, '-').replace(/\..+/, '');
return `mindspace-page-${pageId}${suffix}-${timestamp}.png`;
}
function ensureBlobFromCanvas(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('图片生成失败'));
return;
}
resolve(blob);
}, 'image/png');
});
}
async function exportIframeToPng(iframe: HTMLIFrameElement): Promise<string> {
const renderer = await loadHtml2CanvasRenderer();
const doc = iframe.contentDocument;
if (!doc || !doc.body) {
throw new Error('预览内容无法访问');
}
const width = Math.max(doc.documentElement?.scrollWidth ?? 0, doc.body.scrollWidth, 320);
const height = Math.max(doc.documentElement?.scrollHeight ?? 0, doc.body.scrollHeight, 320);
const canvas = await renderer(doc.body, {
useCORS: true,
scale: LONG_IMAGE_RENDER_SCALE,
backgroundColor: '#ffffff',
width,
height,
windowWidth: width,
windowHeight: height,
x: 0,
y: 0,
scrollX: 0,
scrollY: 0,
});
const blob = await ensureBlobFromCanvas(canvas);
return await blobToDataUrl(blob);
}
async function fetchPageThumbnailAsDataUrl(pageId: string): Promise<string> {
const response = await fetch(`/api/mindspace/v1/pages/${encodeURIComponent(pageId)}/thumbnail?v=${Date.now()}`);
if (!response.ok) {
throw new Error('未生成页面封面图');
}
const blob = await response.blob();
if (!blob || !blob.size) {
throw new Error('未生成页面封面图');
}
return await blobToDataUrl(blob);
}
export function MindSpacePageDetail({
pageId,
onBack,
onSaved,
onContextUpdate,
autoOpenPlaza = false,
refreshTrigger = 0,
onFullscreenPreviewChange,
}: {
pageId: string;
onBack: () => void;
onSaved: (options?: { quota?: MindSpaceQuota; freedBytes?: number }) => Promise<void>;
onContextUpdate?: (value: {
page: MindSpacePage;
title: string;
summary: string;
content: string;
}) => void;
autoOpenPlaza?: boolean;
refreshTrigger?: number;
onFullscreenPreviewChange?: (open: boolean) => void;
}) {
const [page, setPage] = useState<MindSpacePage | null>(null);
const {
snapshot: draft,
update: updateDraft,
replace: replaceDraft,
undo,
redo,
reset: resetDraft,
canUndo,
canRedo,
} = usePageDraftHistory({ title: '', summary: '', content: '' });
const { title, summary, content } = draft;
const [templateId, setTemplateId] = useState('editorial');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [previewKey, setPreviewKey] = useState(0);
const [previewContent, setPreviewContent] = useState('');
const [previewRefreshPending, setPreviewRefreshPending] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
const [changeHighlight, setChangeHighlight] = useState<MindSpacePreviewChangeHighlight | null>(
null,
);
const changeHighlightRevisionRef = useRef(0);
const [publishOpen, setPublishOpen] = useState(false);
const [urlSlug, setUrlSlug] = useState('');
const [accessMode, setAccessMode] = useState<AccessMode>('public');
const [accessPassword, setAccessPassword] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [publishCheck, setPublishCheck] = useState<MindSpacePublishCheck | null>(null);
const [publishing, setPublishing] = useState(false);
const [publicationStats, setPublicationStats] =
useState<MindSpacePublicationStats | null>(null);
const [deletingPage, setDeletingPage] = useState(false);
const [deletePreviewOpen, setDeletePreviewOpen] = useState(false);
const [deletePreview, setDeletePreview] = useState<MindSpacePageDeletePreview | null>(null);
const [deletePreviewLoading, setDeletePreviewLoading] = useState(false);
const [deleteRemoveFromPlaza, setDeleteRemoveFromPlaza] = useState(false);
const [previewFrame, setPreviewFrame] = useState<HTMLIFrameElement | null>(null);
const [isExportingImage, setIsExportingImage] = useState(false);
const [exportFeedback, setExportFeedback] = useState<string | null>(null);
const [plazaOpen, setPlazaOpen] = useState(false);
const [plazaCategories, setPlazaCategories] = useState<PlazaCategory[]>([]);
const [plazaCategoryId, setPlazaCategoryId] = useState('');
const [plazaTags, setPlazaTags] = useState('');
const [plazaCoverUrl, setPlazaCoverUrl] = useState('');
const [plazaAllowComment, setPlazaAllowComment] = useState(true);
const [plazaSubmitting, setPlazaSubmitting] = useState(false);
const [plazaPost, setPlazaPost] = useState<PlazaPostBrief | null>(null);
const [pushToPlaza, setPushToPlaza] = useState(false);
const [fixNotice, setFixNotice] = useState<MindSpaceRedactionChange[] | null>(null);
const [publishSuccess, setPublishSuccess] = useState<{
publication: MindSpacePublication;
plazaPost: PlazaPostBrief | null;
pageTitle: string;
pushedToPlaza: boolean;
} | null>(null);
const [confirmPublicationStatusOpen, setConfirmPublicationStatusOpen] = useState(false);
const [statusConfirming, setStatusConfirming] = useState(false);
const applyPageRecord = useCallback(
(next: MindSpacePage, options?: { recordHistory?: boolean }) => {
setPage(next);
replaceDraft(
{
title: next.title,
summary: next.summary,
content: next.content ?? '',
},
{ recordHistory: options?.recordHistory },
);
setTemplateId(next.templateId);
},
[replaceDraft],
);
const load = async () => {
setLoading(true);
setError(null);
try {
const next = await getMindSpacePage(pageId);
const pageContent =
next.contentFormat === 'html'
? prepareHtmlPageBrandMarkers(next.content ?? '')
: (next.content ?? '');
setPage(next);
resetDraft({
title: next.title,
summary: next.summary,
content: pageContent,
});
setPreviewContent(pageContent);
setPreviewRefreshPending(false);
setPreviewKey((value) => value + 1);
setTemplateId(next.templateId);
setUrlSlug(next.publication?.urlSlug ?? slugFromTitle(next.title, next.id));
setAccessMode(next.publication?.accessMode ?? 'public');
setAccessPassword('');
setExpiresAt(
next.publication?.expiresAt
? localDateTimeValue(next.publication.expiresAt)
: '',
);
setPublishCheck(null);
setPublicationStats(
next.publication
? await getMindSpacePublicationStats(next.publication.id).catch(() => null)
: null,
);
if (
next.publication &&
next.publication.accessMode === 'public' &&
next.publication.userConfirmedAt === null
) {
setConfirmPublicationStatusOpen(true);
}
} catch (err) {
setError(err instanceof Error ? err.message : '页面加载失败');
} finally {
setLoading(false);
}
};
const runPublishCheck = async (
pageOverride?: MindSpacePage,
options?: { managePublishing?: boolean },
) => {
const targetPage = pageOverride ?? page;
if (!targetPage) return;
const managePublishing = options?.managePublishing ?? true;
if (managePublishing) setPublishing(true);
setError(null);
try {
setPublishCheck(
await checkMindSpacePagePublication(targetPage.id, {
pageVersionId: targetPage.currentVersionId,
accessMode,
urlSlug,
password: accessPassword || undefined,
expiresAt: expiresAt ? new Date(expiresAt).getTime() : null,
}),
);
} catch (err) {
setPublishCheck(null);
setError(err instanceof Error ? err.message : '发布检查失败');
} finally {
if (managePublishing) setPublishing(false);
}
};
const ensurePlazaCategories = async () => {
if (plazaCategories.length > 0) return plazaCategories;
const categories = await listPlazaCategories();
setPlazaCategories(categories);
if (!plazaCategoryId && categories[0]) {
setPlazaCategoryId(categories[0].id);
}
return categories;
};
const publishToPlazaWithDefaults = async (publicationId: string) => {
const categories = await ensurePlazaCategories();
const categoryId = plazaCategoryId || categories[0]?.id;
if (!categoryId) {
throw new Error('暂无广场分类,无法推送到 Plaza');
}
const tags = plazaTags
.split(/[,,]/)
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 5);
const post = await publishPageToPlaza({
publicationId,
categoryId,
tags,
coverUrl: plazaCoverUrl.trim(),
allowComment: plazaAllowComment,
});
setPlazaPost(post);
return post;
};
const publish = async () => {
if (!page || !publishCheck?.allowed) return;
setPublishing(true);
setError(null);
try {
const publication = await publishMindSpacePage(page.id, {
pageVersionId: page.currentVersionId,
accessMode,
urlSlug: publishCheck.urlSlug,
acknowledgedFindingIds: publishCheck.findings.map((finding) => finding.id),
password: accessPassword || undefined,
expiresAt: expiresAt ? new Date(expiresAt).getTime() : null,
});
let nextPlazaPost: PlazaPostBrief | null = plazaPost;
if (pushToPlaza) {
nextPlazaPost = await publishToPlazaWithDefaults(publication.id);
}
setPublishOpen(false);
setFullscreenPreviewOpen(false);
onFullscreenPreviewChange?.(false);
setPublishSuccess({
publication,
plazaPost: nextPlazaPost,
pageTitle: title || page.title,
pushedToPlaza: pushToPlaza,
});
await onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : pushToPlaza ? '发布或推送到 Plaza 失败' : '发布失败');
} finally {
setPublishing(false);
}
};
const confirmPublicationStatus = async (nextAccessMode: AccessMode) => {
if (!page || !page.publication) return;
setStatusConfirming(true);
try {
const nextExpiresAt =
nextAccessMode === 'public'
? null
: nextAccessMode === 'time_limited'
? Date.now() + 30 * 60 * 1000
: null;
const updated = await updatePublicationStatus(page.publication.id, {
accessMode: nextAccessMode,
expiresAt: nextExpiresAt,
});
setPage({ ...page, publication: updated });
setAccessMode(updated.accessMode);
setExpiresAt(
updated.expiresAt ? localDateTimeValue(updated.expiresAt) : '',
);
setConfirmPublicationStatusOpen(false);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : '更新发布状态失败');
} finally {
setStatusConfirming(false);
}
};
const offlineFromSuccess = async () => {
if (!publishSuccess) return;
setPublishing(true);
setError(null);
try {
await offlineMindSpacePublication(publishSuccess.publication.id);
await onSaved();
onBack();
} catch (err) {
setError(err instanceof Error ? err.message : '下线失败');
} finally {
setPublishing(false);
}
};
const offline = async () => {
if (!page?.publication) return;
setPublishing(true);
setError(null);
try {
await offlineMindSpacePublication(page.publication.id);
setPlazaPost(null);
await onSaved();
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '下线失败');
} finally {
setPublishing(false);
}
};
const openPlazaPanel = async () => {
setPlazaOpen(true);
setError(null);
try {
await ensurePlazaCategories();
} catch (err) {
setError(err instanceof Error ? err.message : '加载广场分类失败');
}
};
const submitPlaza = async () => {
if (!page?.publication || !plazaCategoryId) return;
setPlazaSubmitting(true);
setError(null);
try {
await publishToPlazaWithDefaults(page.publication.id);
setPlazaOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : '发布到广场失败');
} finally {
setPlazaSubmitting(false);
}
};
const openDeleteDialog = async () => {
if (!page) return;
setDeletePreviewOpen(true);
setDeletePreviewLoading(true);
setDeletePreview(null);
setDeleteRemoveFromPlaza(false);
setError(null);
try {
setDeletePreview(await getMindSpacePageDeletePreview(page.id));
} catch (err) {
setDeletePreviewOpen(false);
setError(err instanceof Error ? err.message : '无法加载删除预览');
} finally {
setDeletePreviewLoading(false);
}
};
const closeDeleteDialog = () => {
if (deletingPage) return;
setDeletePreviewOpen(false);
setDeletePreview(null);
setDeleteRemoveFromPlaza(false);
};
const confirmDeletePage = async () => {
if (!page || !deletePreview) return;
setDeletingPage(true);
setError(null);
try {
const result = await deleteMindSpacePage(page.id, {
removeFromPlaza: deleteRemoveFromPlaza,
});
setDeletePreviewOpen(false);
setDeletePreview(null);
setDeleteRemoveFromPlaza(false);
await onSaved({ quota: result.quota, freedBytes: result.freedBytes });
onBack();
} catch (err) {
setError(err instanceof Error ? err.message : '删除页面失败');
} finally {
setDeletingPage(false);
}
};
const applySuggestedSlug = async (slug: string) => {
setUrlSlug(slug);
setPublishCheck(null);
setFixNotice(null);
if (!page) return;
setPublishing(true);
setError(null);
try {
setPublishCheck(
await checkMindSpacePagePublication(page.id, {
pageVersionId: page.currentVersionId,
accessMode,
urlSlug: slug,
password: accessPassword || undefined,
expiresAt: expiresAt ? new Date(expiresAt).getTime() : null,
}),
);
} catch (err) {
setPublishCheck(null);
setError(err instanceof Error ? err.message : '发布检查失败');
} finally {
setPublishing(false);
}
};
const publishBlockedBySlug = Boolean(publishCheck && !publishCheck.slugAvailable);
const publishBlockedBySecurity = Boolean(
publishCheck && publishCheck.findings.some((finding) => finding.blocking),
);
const publishWarningFindings =
publishCheck?.findings.filter((finding) => !finding.blocking) ?? [];
const triggerChangeHighlight = useCallback((previous: PageDraftSnapshot) => {
changeHighlightRevisionRef.current += 1;
setChangeHighlight({
revision: changeHighlightRevisionRef.current,
previousTitle: previous.title,
previousContent: previous.content,
});
}, []);
const markPreviewRefreshPending = useCallback(() => {
setPreviewRefreshPending(true);
}, []);
const handleManualPreviewRefresh = useCallback((nextContent?: string) => {
const html = nextContent ?? content;
setPreviewContent(html);
setPreviewRefreshPending(false);
setPreviewKey((value) => value + 1);
}, [content]);
const syncPreviewAfterHistory = useCallback(
(snapshot: PageDraftSnapshot) => {
if (previewRefreshPending) return;
setPreviewContent(snapshot.content);
setPreviewKey((value) => value + 1);
},
[previewRefreshPending],
);
const handlePreviewContentChange = useCallback(
(html: string) => {
setPreviewContent(html);
replaceDraft({ ...draft, content: html }, { recordHistory: true });
},
[draft, replaceDraft],
);
const refreshFromServer = useCallback(async () => {
const currentPage = page;
if (!currentPage) return;
try {
const next = await getMindSpacePage(pageId);
const serverChanged =
next.versionNo !== currentPage.versionNo || next.updatedAt !== currentPage.updatedAt;
if (!serverChanged) return;
const draftChanged =
next.title !== title ||
next.summary !== summary ||
(next.content ?? '') !== content;
if (draftChanged) {
triggerChangeHighlight({ title, summary, content });
}
applyPageRecord(next, { recordHistory: true });
if (draftChanged) {
markPreviewRefreshPending();
}
} catch (err) {
setError(err instanceof Error ? err.message : '页面刷新失败');
}
}, [applyPageRecord, content, markPreviewRefreshPending, page, pageId, summary, title, triggerChangeHighlight]);
const applyOneClickFix = async () => {
if (!page) return;
setPublishing(true);
setError(null);
setFixNotice(null);
try {
let result;
try {
result = await fixMindSpacePagePublication(page.id, {
pageVersionId: page.currentVersionId,
expectedVersion: page.versionNo,
title,
summary,
content,
});
} catch {
result = await redactMindSpacePage(page.id, {
pageVersionId: page.currentVersionId,
expectedVersion: page.versionNo,
title,
summary,
content,
});
}
const updatedPage = result.page;
applyPageRecord(updatedPage, { recordHistory: true });
setFixNotice(result.changes);
await runPublishCheck(updatedPage, { managePublishing: false });
} catch (err) {
setFixNotice(null);
setError(err instanceof Error ? err.message : '一键修复失败');
} finally {
setPublishing(false);
}
};
useEffect(() => {
setPublishSuccess(null);
void load();
}, [pageId]);
useEffect(() => {
if (!refreshTrigger) return;
void refreshFromServer();
}, [refreshTrigger, refreshFromServer]);
useEffect(() => {
if (autoOpenPlaza && page?.publication && !plazaOpen) {
void openPlazaPanel();
}
}, [autoOpenPlaza, page]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const mod = event.metaKey || event.ctrlKey;
if (!mod || event.altKey) return;
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
) {
return;
}
if (event.key.toLowerCase() === 'z' && event.shiftKey) {
const next = redo();
if (next) {
syncPreviewAfterHistory(next);
event.preventDefault();
}
return;
}
if (event.key.toLowerCase() === 'z') {
const previous = undo();
if (previous) {
syncPreviewAfterHistory(previous);
event.preventDefault();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [redo, syncPreviewAfterHistory, undo]);
useEffect(() => {
if (!page || !onContextUpdate) return;
onContextUpdate({ page, title, summary, content });
}, [page, title, summary, content, onContextUpdate]);
const openFullscreenPreview = () => {
if (!page || page.contentFormat !== 'html' || !content.trim()) return;
setPreviewContent(content);
setPreviewRefreshPending(false);
setPreviewKey((value) => value + 1);
setFullscreenPreviewOpen(true);
onFullscreenPreviewChange?.(true);
};
const closeFullscreenPreview = () => {
setFullscreenPreviewOpen(false);
onFullscreenPreviewChange?.(false);
setPreviewKey((value) => value + 1);
setPreviewRefreshPending(false);
};
const handleExportPageImage = async () => {
if (!page) return;
setIsExportingImage(true);
setExportFeedback(null);
try {
let dataUrl = '';
let usedFallback = false;
if (previewFrame) {
try {
dataUrl = await exportIframeToPng(previewFrame);
} catch {
usedFallback = true;
}
} else {
usedFallback = true;
}
if (!dataUrl) {
dataUrl = await fetchPageThumbnailAsDataUrl(page.id);
}
downloadBlobAsFile(dataUrl, buildExportFilename(page.id, page.title || 'page'));
setExportFeedback(
usedFallback
? '截图暂不可用,已使用页面封面图保存'
: '已导出当前页面图片',
);
setTimeout(() => setExportFeedback(null), 2200);
} catch (err) {
setExportFeedback(err instanceof Error ? err.message : '导出图片失败');
} finally {
setIsExportingImage(false);
}
};
const handlePreviewUndo = () => {
const previous = undo();
if (!previous) return false;
if (previewRefreshPending) return true;
handleManualPreviewRefresh(previous.content);
return true;
};
const handlePreviewRedo = () => {
const next = redo();
if (!next) return false;
if (previewRefreshPending) return true;
handleManualPreviewRefresh(next.content);
return true;
};
useEffect(() => {
return () => {
onFullscreenPreviewChange?.(false);
};
}, [onFullscreenPreviewChange]);
const publicPageUrl = page?.publication
? resolvePublicPageUrl(page.publication.publicUrl)
: '';
if (loading) return <div className="mindspace-state">正在加载页面详情…</div>;
if (!page) {
return (
<div className="mindspace-state mindspace-error">
<span>{error ?? '页面不存在'}</span>
<button type="button" onClick={onBack}>
返回
</button>
</div>
);
}
if (publishSuccess) {
return (
<MindSpacePublishSuccess
pageTitle={publishSuccess.pageTitle}
publication={publishSuccess.publication}
plazaPost={publishSuccess.plazaPost}
pushedToPlaza={publishSuccess.pushedToPlaza}
offlineBusy={publishing}
error={error}
onOffline={offlineFromSuccess}
onBack={onBack}
/>
);
}
return (
<>
<section className="mindspace-page-detail">
<div className="mindspace-section-heading">
<div>
<button type="button" className="mindspace-inline-back" onClick={onBack}>
返回页面列表
</button>
<p className="mindspace-eyebrow">PAGE DETAIL · V{page.versionNo}</p>
<h2>{truncateLabel(title || page.title, 80)}</h2>
</div>
<div className="mindspace-page-status">
<span>{STATUS_LABELS[page.status] ?? page.status}</span>
<strong>{TEMPLATE_LABELS[page.templateId] ?? page.templateId}</strong>
<button
type="button"
onClick={() => void openDeleteDialog()}
disabled={deletingPage || publishing || deletePreviewLoading}
>
删除页面
</button>
<button
type="button"
className="mindspace-primary"
onClick={() => setPublishOpen((value) => !value)}
>
{page.publication ? '重新发布' : '发布页面'}
</button>
</div>
</div>
<div className="mindspace-page-floating-actions">
<button
type="button"
className="mindspace-page-floating-action is-secondary"
onClick={() => void handleExportPageImage()}
disabled={isExportingImage}
>
{isExportingImage ? '保存中…' : '保存图片'}
</button>
</div>
{exportFeedback ? (
<p className="mindspace-page-export-feedback" role="status">
{exportFeedback}
</p>
) : null}
{error && !deletePreviewOpen && <div className="mindspace-security-note">{error}</div>}
<MindSpaceModal
open={deletePreviewOpen}
onClose={closeDeleteDialog}
title="确认删除页面"
eyebrow="DELETE PAGE"
className="mindspace-delete-dialog"
disableClose={deletingPage || deletePreviewLoading}
>
{deletePreviewLoading && <div className="mindspace-state">正在分析连带内容…</div>}
{error && deletePreviewOpen && <div className="mindspace-security-note">{error}</div>}
{deletePreview && (
<>
<p className="mindspace-upload-dialog-desc">
删除后无法恢复。以下相关内容将一并处理:
</p>
<ul className="mindspace-delete-summary">
{deletePreview.summaryLines.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
{deletePreview.onlinePublication && (
<a
className="mindspace-delete-public-url"
href={resolvePublicPageUrl(deletePreview.onlinePublication.publicUrl)}
target="_blank"
rel="noreferrer"
>
{resolvePublicPageUrl(deletePreview.onlinePublication.publicUrl)}
</a>
)}
{deletePreview.linkedAssets.length > 0 && (
<div className="mindspace-delete-assets">
{deletePreview.linkedAssets.map((asset) => (
<div key={asset.id}>
<strong>{asset.displayName}</strong>
<span>{asset.kind === 'bundle' ? '发布快照' : '页面内容'}</span>
</div>
))}
</div>
)}
<MindSpaceDeletePlazaOption
plazaPosts={deletePreview.plazaPosts ?? []}
removeFromPlaza={deleteRemoveFromPlaza}
onRemoveFromPlazaChange={setDeleteRemoveFromPlaza}
/>
<div className="mindspace-page-editor-actions">
<button type="button" onClick={closeDeleteDialog} disabled={deletingPage}>
取消
</button>
<button
type="button"
className="mindspace-delete-confirm"
onClick={() => void confirmDeletePage()}
disabled={deletingPage}
>
{deletingPage ? '删除中…' : '确认删除'}
</button>
</div>
</>
)}
</MindSpaceModal>
{page.publication && (
<div className="mindspace-publication-live">
<div>
<span>{ACCESS_MODE_LABELS[page.publication.accessMode]}</span>
<a
className="mindspace-publication-url"
href={publicPageUrl}
target="_blank"
rel="noreferrer"
>
{publicPageUrl}
</a>
<small>已访问 {page.publication.viewCount} 次,线上版本保持不可变。</small>
{publicationStats && publicationStats.totalViews > 0 && (
<small>
设备:
{publicationStats.devices
.map((item) => `${item.deviceType} ${item.views}`)
.join(' · ')}
{' · '}来源:
{publicationStats.sources
.map((item) => `${item.source} ${item.views}`)
.join(' · ')}
</small>
)}
</div>
<div>
<a href={publicPageUrl} target="_blank" rel="noreferrer">
打开页面
</a>
{page.publication.status === 'online' ? (
<button type="button" onClick={() => void openPlazaPanel()} disabled={publishing}>
发布到广场
</button>
) : null}
<button type="button" onClick={() => void offline()} disabled={publishing}>
立即下线
</button>
</div>
</div>
)}
{plazaPost ? (
<div className="mindspace-publication-live">
<div>
<span>Plaza 广场</span>
<a
className="mindspace-publication-url"
href={resolvePlazaPostUrl(plazaPost.id)}
target="_blank"
rel="noreferrer"
>
{resolvePlazaPostUrl(plazaPost.id)}
</a>
<small>
状态:{plazaPost.status === 'published' ? '已公开' : '待审核'}。审核通过后会在广场首页展示。
</small>
</div>
</div>
) : null}
{plazaOpen ? (
<section className="mindspace-publish-panel">
<div>
<p className="mindspace-eyebrow">PLAZA</p>
<h3>发布到发现广场</h3>
</div>
<label>
广场分类
<select
value={plazaCategoryId}
onChange={(event) => setPlazaCategoryId(event.target.value)}
>
{plazaCategories.map((category) => (
<option value={category.id} key={category.id}>
{category.icon} {category.name}
</option>
))}
</select>
</label>
<label>
标签(可选,逗号分隔,最多 5 个)
<input
value={plazaTags}
onChange={(event) => setPlazaTags(event.target.value)}
placeholder="复盘, 季度报告"
/>
</label>
<label>
封面图 URL(可选,留空使用默认)
<input
value={plazaCoverUrl}
onChange={(event) => setPlazaCoverUrl(event.target.value)}
placeholder="https://..."
/>
</label>
<label className="mindspace-checkbox-row">
<input
type="checkbox"
checked={plazaAllowComment}
onChange={(event) => setPlazaAllowComment(event.target.checked)}
/>
允许评论
</label>
<div className="mindspace-page-editor-actions">
<span>发布后默认进入审核队列;开发环境可配置自动过审。</span>
<button type="button" onClick={() => setPlazaOpen(false)} disabled={plazaSubmitting}>
取消
</button>
<button
type="button"
className="mindspace-primary"
onClick={() => void submitPlaza()}
disabled={plazaSubmitting || !plazaCategoryId}
>
{plazaSubmitting ? '提交中…' : '确认发布到广场'}
</button>
</div>
</section>
) : null}
{publishOpen && (
<section className="mindspace-publish-panel">
<div>
<p className="mindspace-eyebrow">PUBLISH SAFELY</p>
<h3>发布设置与安全检查</h3>
</div>
<label>
页面地址
<div className="mindspace-slug-field">
<span>/u/我的主页/pages/</span>
<input
value={urlSlug}
maxLength={64}
onChange={(event) => {
setUrlSlug(event.target.value.toLowerCase());
setPublishCheck(null);
setFixNotice(null);
}}
/>
</div>
</label>
<label>
访问方式
<select
value={accessMode}
onChange={(event) => {
setAccessMode(event.target.value as AccessMode);
setPublishCheck(null);
setFixNotice(null);
}}
>
{Object.entries(ACCESS_MODE_LABELS).map(([value, label]) => (
<option value={value} key={value}>
{label}
</option>
))}
</select>
</label>
{accessMode === 'password' && (
<label>
访问密码
<input
type="password"
value={accessPassword}
minLength={8}
maxLength={128}
autoComplete="new-password"
onChange={(event) => {
setAccessPassword(event.target.value);
setPublishCheck(null);
setFixNotice(null);
}}
/>
</label>
)}
{accessMode === 'time_limited' && (
<label>
访问截止时间
<input
type="datetime-local"
value={expiresAt}
min={localDateTimeValue(Date.now() + 60_000)}
onChange={(event) => {
setExpiresAt(event.target.value);
setPublishCheck(null);
setFixNotice(null);
}}
/>
</label>
)}
{fixNotice && fixNotice.length > 0 ? (
<div className="mindspace-fix-notice">
<strong>
已修复 {fixNotice.reduce((total, change) => total + change.count, 0)} 处风险内容
</strong>
<ul>
{fixNotice.map((change) => (
<li key={`${change.field}-${change.type}`}>
{change.fieldLabel}:{change.label} {change.count} 处
</li>
))}
</ul>
<p>页面内容已就地更新,可直接点击「确认发布」。</p>
</div>
) : null}
{publishCheck && (
<div className={`mindspace-publish-check is-${publishCheck.status}`}>
<strong>
{publishCheck.allowed
? '可以发布'
: publishBlockedBySlug && !publishBlockedBySecurity
? '请更换页面地址'
: '当前不能发布'}{' '}
· 风险等级 {publishCheck.riskLevel}
</strong>
{!publishCheck.slugAvailable && (
<p>
页面地址 <code>/u/我的主页/pages/{publishCheck.urlSlug}</code> 已被其他页面使用
{publishCheck.slugConflict
? `:《${publishCheck.slugConflict.title}》`
: ''}
。中文标题常会生成相同地址,请改用唯一地址。
</p>
)}
{publishCheck.suggestedUrlSlug && !publishCheck.slugAvailable ? (
<p className="mindspace-publish-slug-suggest">
推荐地址:
<button
type="button"
onClick={() => void applySuggestedSlug(publishCheck.suggestedUrlSlug!)}
disabled={publishing}
>
/u/我的主页/pages/{publishCheck.suggestedUrlSlug}
</button>
</p>
) : null}
{publishCheck.findings.length === 0 && publishCheck.slugAvailable && (
<p>未发现敏感信息或外部链接。</p>
)}
{publishCheck.findings
.filter((finding) => finding.blocking)
.map((finding) => (
<p key={finding.id}>
阻断:{finding.label ?? finding.type} 共 {finding.occurrenceCount} 处(
{finding.sampleMasked})
</p>
))}
{publishWarningFindings.length > 0 && (
<p className="mindspace-publish-warning-note">
以下提醒不会阻止发布,确认发布时会自动记录:
</p>
)}
{publishWarningFindings.map((finding) => (
<p key={finding.id}>
提醒:{finding.label ?? finding.type} 共 {finding.occurrenceCount} 处(
{finding.sampleMasked})
</p>
))}
</div>
)}
<div className="mindspace-page-editor-actions">
<span>
{pushToPlaza
? '确认发布后会同步推送到 Plaza 发现广场。'
: '发布会创建独立快照,后续修改草稿不会影响线上页面。'}
</span>
<div className="mindspace-publish-action-group">
<button
type="button"
onClick={() => {
setFixNotice(null);
void runPublishCheck();
}}
disabled={publishing}
>
{publishing ? '检查中…' : '运行安全检查'}
</button>
{publishBlockedBySecurity ? (
<button type="button" onClick={() => void applyOneClickFix()} disabled={publishing}>
{publishing ? '修复中…' : '一键修复'}
</button>
) : null}
<label className="mindspace-publish-plaza-toggle">
<input
type="checkbox"
checked={pushToPlaza}
disabled={publishing}
onChange={(event) => {
const checked = event.target.checked;
setPushToPlaza(checked);
if (checked) {
void ensurePlazaCategories().catch((err) => {
setError(err instanceof Error ? err.message : '加载广场分类失败');
setPushToPlaza(false);
});
}
}}
/>
推送 Plaza
</label>
<button
type="button"
className="mindspace-primary"
onClick={() => void publish()}
disabled={publishing || !publishCheck?.allowed}
>
{publishing ? (pushToPlaza ? '发布并推送…' : '发布中…') : '确认发布'}
</button>
</div>
</div>
</section>
)}
<div
className={`mindspace-page-layout${page.contentFormat === 'html' ? ' is-html-page' : ''}`}
>
<div className="mindspace-page-editor">
<label>
标题
<input
value={title}
maxLength={255}
onChange={(event) => updateDraft({ ...draft, title: event.target.value })}
/>
</label>
<label>
摘要
<textarea
value={summary}
rows={4}
maxLength={1000}
onChange={(event) => updateDraft({ ...draft, summary: event.target.value })}
/>
</label>
{page.contentFormat !== 'html' && (
<label>
模板
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
{Object.entries(TEMPLATE_LABELS)
.filter(([value]) => value !== 'static-html')
.map(([value, label]) => (
<option value={value} key={value}>
{label}
</option>
))}
</select>
</label>
)}
{page.contentFormat !== 'html' && (
<label>
页面内容
<textarea
className="mindspace-page-content-input"
value={content}
rows={18}
onChange={(event) => updateDraft({ ...draft, content: event.target.value })}
/>
</label>
)}
</div>
<div className="mindspace-page-preview">
<div className="mindspace-page-preview-bar">
<span>{page.contentFormat === 'html' ? '页面预览编辑' : '安全预览'}</span>
<div className="mindspace-page-preview-actions">
<button type="button" onClick={() => {
const previous = undo();
if (previous) syncPreviewAfterHistory(previous);
}} disabled={!canUndo} title="撤销 (⌘Z)">
撤销
</button>
<button type="button" onClick={() => {
const next = redo();
if (next) syncPreviewAfterHistory(next);
}} disabled={!canRedo} title="重做 (⌘⇧Z)">
重做
</button>
<button
type="button"
onClick={openFullscreenPreview}
disabled={page.contentFormat !== 'html' || !content.trim()}
title={page.contentFormat === 'html' ? '进入沉浸式可视化编辑' : '仅 HTML 页面支持全屏编辑'}
>
全屏预览编辑
</button>
<button
type="button"
className={previewRefreshPending ? 'is-refresh-pending' : undefined}
onClick={handleManualPreviewRefresh}
title={previewRefreshPending ? '草稿已有新内容,点击刷新预览' : '将当前草稿同步到预览'}
>
刷新预览{previewRefreshPending ? ' · 有新修改' : ''}
</button>
</div>
</div>
{page.contentFormat === 'html' ? (
<MindSpacePagePreviewPanel
pageId={page.id}
title={title}
summary={summary}
content={previewContent}
onFrameReady={setPreviewFrame}
reloadKey={previewKey}
onContentChange={handlePreviewContentChange}
/>
) : (
<MindSpacePageDraftPreviewFrame
pageId={page.id}
title={title}
summary={summary}
content={content}
templateId={templateId}
onFrameReady={setPreviewFrame}
reloadKey={previewKey}
minHeight={680}
maxHeight={Number.POSITIVE_INFINITY}
/>
)}
{page.contentFormat === 'html' && (
<details className="mindspace-html-source-editor">
<summary>编辑 HTML 源码</summary>
<textarea
className="mindspace-page-content-input"
value={content}
rows={22}
onChange={(event) => {
const nextContent = event.target.value;
updateDraft({ ...draft, content: nextContent });
if (!previewRefreshPending) {
setPreviewContent(nextContent);
}
}}
/>
</details>
)}
</div>
</div>
<div className="mindspace-page-meta">
<div>
<span>来源会话</span>
<strong>{page.sourceSessionId ?? '手动创建'}</strong>
</div>
<div>
<span>当前版本</span>
<strong>v{page.versionNo}</strong>
</div>
<div>
<span>更新时间</span>
<strong>{new Date(page.updatedAt).toLocaleString()}</strong>
</div>
</div>
<section className="mindspace-version-history">
<p className="mindspace-eyebrow">VERSION HISTORY</p>
<h3>页面版本</h3>
{(page.versions ?? []).map((version) => (
<div className="mindspace-version-row" key={version.id}>
<strong>v{version.versionNo}</strong>
<span>{version.changeNote || '保存页面草稿'}</span>
<time>{new Date(version.createdAt).toLocaleString()}</time>
</div>
))}
</section>
</section>
{fullscreenPreviewOpen && page && page.contentFormat === 'html' ? (
<MindSpacePageFullscreenPreview
pageId={page.id}
pageTitle={title || page.title}
title={title}
content={content}
reloadKey={previewKey}
changeHighlight={changeHighlight}
canUndo={canUndo}
canRedo={canRedo}
onUndo={handlePreviewUndo}
onRedo={handlePreviewRedo}
onClose={closeFullscreenPreview}
onRefresh={() => handleManualPreviewRefresh()}
refreshPending={previewRefreshPending}
onContentChange={handlePreviewContentChange}
/>
) : null}
{confirmPublicationStatusOpen && page?.publication ? (
<MindSpaceModal className="mindspace-confirm-publication-status-modal">
<div className="mindspace-modal-content">
<h3>页面预览期确认</h3>
<p>你的页面当前处于 30 分钟预览期。请选择后续状态:</p>
<div className="mindspace-modal-actions">
<button
className="mindspace-secondary"
disabled={statusConfirming}
onClick={() => confirmPublicationStatus('private')}
>
{statusConfirming ? '处理中...' : '改为私有'}
</button>
<button
className="mindspace-secondary"
disabled={statusConfirming}
onClick={() => confirmPublicationStatus('time_limited')}
>
{statusConfirming ? '处理中...' : '再预览 30 分钟'}
</button>
<button
className="mindspace-primary"
disabled={statusConfirming}
onClick={() => confirmPublicationStatus('public')}
>
{statusConfirming ? '处理中...' : '发布为永久公开'}
</button>
</div>
</div>
</MindSpaceModal>
) : null}
</>
);
}