2b8dba1784
含 MindSpace 三列布局与统计修复、聊天加载态与连接降级、平台页脚标记与 og:site_name 微信卡片、勾选资料删除 Agent 接口及内部话术过滤。 Co-authored-by: Cursor <cursoragent@cursor.com>
1494 lines
50 KiB
TypeScript
1494 lines
50 KiB
TypeScript
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}
|
||
</>
|
||
);
|
||
}
|