Files
memind/src/components/MindSpaceView.tsx
T
john 71e536eceb feat(mindspace): 悬浮聊天勾选资料联动、UI 与交互优化
勾选单个文件时 Agent 获知上下文并主动问候;打开悬浮窗使用新会话。
统一弹窗浅色主题、固定高度与输入区布局,支持点击外部收起。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 18:08:06 +08:00

3507 lines
132 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 { useEffect, useMemo, useRef, useState, type ChangeEvent, type MouseEvent } from 'react';
import { useLocation } from 'react-router-dom';
import { useChat } from '../context/ChatProvider';
import {
cancelMindSpaceAgentJob,
createMindSpaceAgentJob,
createMindSpacePage,
createMindSpacePageFromAsset,
deleteMindSpaceAsset,
deleteMindSpacePage,
deleteMindSpaceScheduleReminders,
bindMindSpacePageLiveEdit,
getMindSpaceAgentJob,
getMindSpace,
getMindSpacePage,
getMindSpacePageDeletePreview,
ignoreMindSpaceScheduleReminder,
listMindSpaceAgentJobs,
listMindSpaceAssets,
listMindSpaceCleanupItems,
listMindSpacePages,
retryMindSpaceAgentJob,
runMindSpaceAgentJob,
runMindSpaceCleanup,
purchaseSpaceQuota,
uploadMindSpaceAsset,
ApiError,
} from '../api/client';
import { MINDSPACE_IMAGE_UPLOAD_MAX_BYTES } from '../utils/imageUpload';
import type {
MindSpace,
MindSpaceAsset,
MindSpaceAgentJob,
MindSpaceCategory,
MindSpaceCleanupItem,
MindSpacePage,
MindSpacePageDeletePreview,
MindSpaceQuota,
MindSpaceScheduleReminder,
MindSpaceSaveCategory,
PortalUser,
} from '../types';
import { buildMindSpaceChatContext } from '../utils/mindspaceChatContext';
import { resolveH5ApiBase } from '../utils/h5ApiBase';
import { MindSpaceSpaceChat } from './MindSpaceSpaceChat';
import {
assetIcon,
assetSourceLabel,
buildAssetImageUrl,
buildAssetPreviewFrameConfig,
isImageAsset,
isPreviewableAsset,
isWorkAsset,
} from '../utils/mindspaceCards';
import { PREVIEW_ASSETS, PREVIEW_PAGES, PREVIEW_SPACE } from '../dev/mindspacePreviewData';
import { MindSpaceFeedCard } from './MindSpaceFeedCard';
import { MindSpacePageDetail } from './MindSpacePageDetail';
import { MindSpaceModal } from './MindSpaceModal';
import { MindSpaceDeletePlazaOption } from './MindSpaceDeletePlazaOption';
import { ShareSheet } from './ShareSheet';
import { WorkCover } from './WorkCover';
import { buildAssetSharePayload, buildPageSharePayload, type SharePayload } from '../utils/shareChannels';
import { resolveMindSpaceHomeUrl, resolveMindSpacePageOwnerPreviewUrl, resolveMindSpacePagePublicViewUrl, resolvePublicPageUrl, resolvePlazaHomeUrl } from '../utils/publicUrl';
import { PagePreviewFrame } from './PagePreviewFrame';
import { BalanceRing } from './BalanceRing';
import { NotificationCenter } from './NotificationCenter';
const CATEGORY_DESCRIPTIONS: Record<MindSpaceCategory['code'], string> = {
oa: '上传文档、表格和资料,让 AI 生成报告与页面',
private: '已停用',
public: '管理可公开使用的页面、图片和静态资源',
draft: '继续编辑尚未发布或等待安全检查的页面',
archive: '保存不再活跃但仍需要保留的内容',
};
const CATEGORY_ACTIONS: Partial<Record<MindSpaceCategory['code'], string>> = {
oa: '进入工作区',
public: '查看发布',
draft: '继续创作',
};
const AGENT_JOBS_PAGE_SIZE = 10;
const RECENT_PAGES_PAGE_SIZE = 6;
const ALL_PAGES_PAGE_SIZE = 15;
function sortPagesByCreatedAt(pages: MindSpacePage[]) {
return [...pages].sort((left, right) => {
const createdDiff = (right.createdAt ?? 0) - (left.createdAt ?? 0);
if (createdDiff !== 0) return createdDiff;
return (right.updatedAt ?? 0) - (left.updatedAt ?? 0);
});
}
const IMAGE_PAGE_SIZE = 10;
const DEFAULT_MAX_UPLOAD_FILE_BYTES = 5 * 1024 * 1024;
const UPLOAD_FILE_EXTENSIONS = [
'.doc',
'.docx',
'.xls',
'.xlsx',
'.pdf',
'.ppt',
'.pptx',
'.png',
'.jpg',
'.jpeg',
'.webp',
'.md',
'.csv',
'.txt',
'.html',
'.htm',
] as const;
const UPLOAD_ACCEPT = UPLOAD_FILE_EXTENSIONS.join(',');
const UPLOAD_FILE_TYPE_LABEL = 'Word、Excel、PDF、PPT、图片、Markdown、CSV、TXT 和 HTML';
const JOB_STATUS_LABELS: Record<MindSpaceAgentJob['status'], string> = {
queued: '排队中',
running: '执行中',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
timed_out: '超时',
};
const PREVIEW_SCHEDULE_REMINDERS = [
{
id: 'schedule-preview-rem-1',
time: '明天 09:00',
title: '带项目材料去会议',
meta: '提前 30 分钟 · 服务号推送',
notifyLabel: '待通知',
canIgnore: true,
},
{
id: 'schedule-preview-rem-2',
time: '今天 14:00',
title: '产品评审会',
meta: '服务号推送',
notifyLabel: '已通知',
canIgnore: false,
},
] as const;
function scheduleReminderNotifyLabel(status: MindSpaceScheduleReminder['status']) {
return status === 'sent' ? '已通知' : '待通知';
}
function formatReminderEventWindow(reminder: MindSpaceScheduleReminder) {
const tz = reminder.itemTimezone || 'Asia/Shanghai';
const start = reminder.itemStartAt ?? null;
const end = reminder.itemEndAt ?? null;
if (!start) return null;
const startLabel = formatScheduleWhen(start, tz);
if (!end || end === start) return `事项 ${startLabel}`;
const endClock = formatScheduleClock(end, tz);
return `事项 ${startLabel} ~ ${endClock}`;
}
function formatScheduleClock(value: number | null | undefined, timezone?: string | null) {
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
try {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: timezone || undefined,
}).format(new Date(value));
} catch {
return new Date(value).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
}
function formatDigestSchedule(digest: {
hour: number;
minute: number;
timezone?: string | null;
nextRunAt?: number | null;
}) {
const time = `${String(digest.hour).padStart(2, '0')}:${String(digest.minute).padStart(2, '0')}`;
const nextRun = formatScheduleClock(digest.nextRunAt, digest.timezone);
return nextRun === '—' ? time : `${time} · 下次 ${nextRun}`;
}
function scheduleLocalDateParts(epochMs: number, timezone?: string | null) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: timezone || 'Asia/Shanghai',
year: 'numeric',
month: 'numeric',
day: 'numeric',
}).formatToParts(new Date(epochMs));
const pick = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? 0);
return { year: pick('year'), month: pick('month'), day: pick('day') };
}
function isSameScheduleDay(aMs: number, bMs: number, timezone?: string | null) {
const a = scheduleLocalDateParts(aMs, timezone);
const b = scheduleLocalDateParts(bMs, timezone);
return a.year === b.year && a.month === b.month && a.day === b.day;
}
function formatScheduleDayLabel(epochMs: number, timezone?: string | null) {
const tz = timezone || 'Asia/Shanghai';
const now = Date.now();
if (isSameScheduleDay(epochMs, now, tz)) return '今天';
if (isSameScheduleDay(epochMs, now + 24 * 60 * 60 * 1000, tz)) return '明天';
try {
return new Intl.DateTimeFormat('zh-CN', {
timeZone: tz,
month: 'numeric',
day: 'numeric',
weekday: 'short',
}).format(new Date(epochMs));
} catch {
return formatScheduleDayLabel(epochMs, 'Asia/Shanghai');
}
}
function formatScheduleWhen(epochMs: number | null | undefined, timezone?: string | null, allDay = false) {
if (typeof epochMs !== 'number' || Number.isNaN(epochMs)) return '—';
const day = formatScheduleDayLabel(epochMs, timezone);
if (allDay) return day;
const clock = formatScheduleClock(epochMs, timezone);
return clock === '—' ? day : `${day} ${clock}`;
}
const PREVIEW_PAGE_READERS: Record<string, { kicker: string; body: string[] }> = {
'page-1': {
kicker: '市场趋势',
body: ['消费者对 AI 辅助决策的接受度持续提升,企业侧开始从试点转向流程内嵌。', '下一阶段重点是把洞察沉淀为可复用页面,而不是停留在一次性聊天结论。'],
},
'page-2': {
kicker: '旅行攻略',
body: ['吉隆坡和槟城适合组合成 5 天轻行程,城市交通、夜市和海岛体验都比较均衡。', '预算建议优先保证住宿位置,减少跨城交通带来的时间损耗。'],
},
'page-3': {
kicker: '客户成功',
body: ['本月高价值客户续费意向稳定,但两个账号存在使用频次下降信号。', '建议在下周安排专项回访,并用案例页同步给销售团队。'],
},
'page-4': {
kicker: '经营摘要',
body: ['收入增长主要来自老客户扩容,新线索转化仍需提高。', '成本侧波动集中在交付人力,建议把高频交付问题整理为自动化模板。'],
},
'page-5': {
kicker: '私人清单',
body: ['出行前 48 小时确认证件、充电器、常用药和当地交通 App。', '个人资料区默认不公开,适合保存只给自己看的备忘。'],
},
'page-6': {
kicker: '选型笔记',
body: ['模型选择需要同时看质量、延迟、成本和可观测性。', '对高频生产任务,优先选择稳定性更强、可回放的流程。'],
},
'page-7': {
kicker: '发布指南',
body: ['公开发布前先做风险检查,再确认标题、摘要和封面是否准确。', '适合进入 Plaza 的内容应该有明确主题和可快速理解的首屏。'],
},
'page-8': {
kicker: '活动复盘',
body: ['旧活动的关键价值在于保留假设、执行过程和结论,而不是只保存结果截图。', '归档内容保持可搜索即可,不需要占据首页主要空间。'],
},
};
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_VIEWPORT_WIDTH = 1280;
const LONG_IMAGE_VIEWPORT_HEIGHT = 720;
const LONG_IMAGE_RENDER_SCALE = 2;
const LONG_IMAGE_MAX_PIXELS = 64_000_000;
const SPACE_PURCHASE_PRESETS_MB = [5, 10, 20];
let html2CanvasLoader: Promise<Html2CanvasRenderer> | null = null;
function formatYuan(cents: number) {
return `¥${(cents / 100).toFixed(2)}`;
}
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 fileExtension(filename: string) {
const dotIndex = filename.lastIndexOf('.');
return dotIndex >= 0 ? filename.slice(dotIndex).toLowerCase() : '';
}
function isUploadImageFile(file: File) {
const extension = fileExtension(file.name);
return (
file.type.startsWith('image/') ||
extension === '.png' ||
extension === '.jpg' ||
extension === '.jpeg' ||
extension === '.webp'
);
}
function validateSelectedUploadFile(file: File, maxBytes: number) {
if (file.size <= 0) return '文件不能为空,请重新选择。';
const extension = fileExtension(file.name);
if (!UPLOAD_FILE_EXTENSIONS.includes(extension as (typeof UPLOAD_FILE_EXTENSIONS)[number])) {
return `暂不支持 ${extension || '无扩展名'} 文件,请选择支持的资料格式。`;
}
const effectiveMaxBytes = isUploadImageFile(file)
? Math.min(maxBytes, MINDSPACE_IMAGE_UPLOAD_MAX_BYTES)
: maxBytes;
if (file.size > effectiveMaxBytes) {
return `文件不能超过 ${formatBytes(effectiveMaxBytes)},当前为 ${formatBytes(file.size)}。`;
}
return null;
}
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,
LONG_IMAGE_VIEWPORT_WIDTH,
);
const height = Math.max(
doc.documentElement?.scrollHeight ?? 0,
doc.body.scrollHeight,
LONG_IMAGE_VIEWPORT_HEIGHT,
);
const scale = Math.min(
LONG_IMAGE_RENDER_SCALE,
Math.max(1, Math.sqrt(LONG_IMAGE_MAX_PIXELS / Math.max(width * height, 1))),
);
const canvas = await renderer(doc.body, {
useCORS: true,
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 waitForIframeAssets(iframe: HTMLIFrameElement): Promise<void> {
const doc = iframe.contentDocument;
if (!doc) return;
await doc.fonts?.ready.catch(() => undefined);
await Promise.all(
Array.from(doc.images)
.filter((img) => !img.complete)
.map(
(img) =>
new Promise<void>((resolve) => {
img.addEventListener('load', () => resolve(), { once: true });
img.addEventListener('error', () => resolve(), { once: true });
}),
),
);
await new Promise((resolve) => window.setTimeout(resolve, 450));
}
async function exportPagePreviewToPng(page: MindSpacePage): Promise<string> {
const iframe = document.createElement('iframe');
iframe.src = `/api/mindspace/v1/pages/${encodeURIComponent(page.id)}/preview?v=${page.versionNo ?? page.updatedAt}`;
iframe.setAttribute('aria-hidden', 'true');
iframe.tabIndex = -1;
iframe.style.position = 'fixed';
iframe.style.left = '-99999px';
iframe.style.top = '0';
iframe.style.width = `${LONG_IMAGE_VIEWPORT_WIDTH}px`;
iframe.style.height = `${LONG_IMAGE_VIEWPORT_HEIGHT}px`;
iframe.style.border = '0';
iframe.style.opacity = '0';
iframe.style.pointerEvents = 'none';
document.body.appendChild(iframe);
try {
await new Promise<void>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error('页面预览加载超时')), 12000);
iframe.onload = () => {
window.clearTimeout(timer);
resolve();
};
iframe.onerror = () => {
window.clearTimeout(timer);
reject(new Error('页面预览加载失败'));
};
});
await waitForIframeAssets(iframe);
return await exportIframeToPng(iframe);
} finally {
iframe.remove();
}
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
type AssetFilter = 'all' | 'images' | 'files' | 'pages';
function canGenerateWithAgent(asset: MindSpaceAsset) {
const textLikeMimeTypes = new Set([
'text/plain',
'text/markdown',
'text/csv',
'text/html',
'application/json',
]);
return (
asset.status === 'ready' &&
asset.scanStatus !== 'blocked' &&
textLikeMimeTypes.has(asset.mimeType)
);
}
type MindSpaceRouteSync = {
pushHome: () => void;
pushCategory: (code: MindSpaceSaveCategory | MindSpaceCategory['code']) => void;
pushPage: (pageId: string) => void;
};
function MindSpaceBalanceRing() {
const { balanceCents, totalCreditCents, tokensUsed, subscription, openRecharge, openSubscribe } = useChat();
if (typeof balanceCents !== 'number') return null;
return (
<BalanceRing
balanceCents={balanceCents}
totalCreditCents={totalCreditCents}
tokensUsed={tokensUsed}
subscription={subscription}
onRecharge={openRecharge}
onSubscribe={openSubscribe}
/>
);
}
function MindSpaceNotificationCenter() {
const { openRecharge } = useChat();
return <NotificationCenter onOpenRecharge={() => openRecharge(false)} />;
}
export function MindSpaceView({
user,
previewMode = false,
initialPageId,
initialCategoryCode,
onBack,
onLogout,
onOpenFeedback,
routeSync,
}: {
user: PortalUser;
previewMode?: boolean;
initialPageId?: string | null;
initialCategoryCode?: MindSpaceSaveCategory | null;
onBack: () => void;
onLogout: () => void;
onOpenFeedback?: () => void;
routeSync?: MindSpaceRouteSync;
}) {
const [space, setSpace] = useState<MindSpace | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [selectedCategory, setSelectedCategory] = useState<MindSpaceCategory | null>(null);
const [assets, setAssets] = useState<MindSpaceAsset[]>([]);
const [pages, setPages] = useState<MindSpacePage[]>([]);
const [selectedPageId, setSelectedPageId] = useState<string | null>(initialPageId ?? null);
const [selectedPageAutoPlaza, setSelectedPageAutoPlaza] = useState(false);
const [assetsLoading, setAssetsLoading] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const [bulkDeletingAssets, setBulkDeletingAssets] = useState(false);
const [selectedDraftPageIds, setSelectedDraftPageIds] = useState<string[]>([]);
const [bulkDeletingDraftPages, setBulkDeletingDraftPages] = useState(false);
const [newPageOpen, setNewPageOpen] = useState(false);
const [newPageTitle, setNewPageTitle] = useState('');
const [newPageContent, setNewPageContent] = useState('');
const [newPageTemplate, setNewPageTemplate] = useState('editorial');
const [creatingPage, setCreatingPage] = useState(false);
const [assetFilter, setAssetFilter] = useState<AssetFilter>('all');
const [imagePage, setImagePage] = useState(0);
const [previewAssetId, setPreviewAssetId] = useState<string | null>(null);
const [openAssetMenuId, setOpenAssetMenuId] = useState<string | null>(null);
const [editingAssetId, setEditingAssetId] = useState<string | null>(null);
const [cleanupOpen, setCleanupOpen] = useState(false);
const [cleanupItems, setCleanupItems] = useState<MindSpaceCleanupItem[]>([]);
const [cleanupLoading, setCleanupLoading] = useState(false);
const [cleanupRunning, setCleanupRunning] = useState(false);
const [selectedCleanupIds, setSelectedCleanupIds] = useState<string[]>([]);
const [agentAsset, setAgentAsset] = useState<MindSpaceAsset | null>(null);
const [agentInstruction, setAgentInstruction] = useState('');
const [agentJob, setAgentJob] = useState<MindSpaceAgentJob | null>(null);
const [agentSubmitting, setAgentSubmitting] = useState(false);
const [agentJobsPanelOpen, setAgentJobsPanelOpen] = useState(false);
const [agentJobsList, setAgentJobsList] = useState<MindSpaceAgentJob[]>([]);
const [agentJobsTotal, setAgentJobsTotal] = useState(0);
const [agentJobsPage, setAgentJobsPage] = useState(0);
const [agentJobsLoading, setAgentJobsLoading] = useState(false);
const [allPagesOpen, setAllPagesOpen] = useState(false);
const [allPagesItems, setAllPagesItems] = useState<MindSpacePage[]>([]);
const [allPagesTotal, setAllPagesTotal] = useState(0);
const [allPagesPageIndex, setAllPagesPageIndex] = useState(0);
const [allPagesLoading, setAllPagesLoading] = useState(false);
const [selectedAllPageIds, setSelectedAllPageIds] = useState<string[]>([]);
const [bulkDeletingAllPages, setBulkDeletingAllPages] = useState(false);
const [selectedScheduleReminderIds, setSelectedScheduleReminderIds] = useState<string[]>([]);
const [scheduleReminderActionLoading, setScheduleReminderActionLoading] = useState(false);
const [recentPagesTotal, setRecentPagesTotal] = useState(0);
const [exportingPageId, setExportingPageId] = useState<string | null>(null);
const [sharePayload, setSharePayload] = useState<SharePayload | null>(null);
const [previewPageTarget, setPreviewPageTarget] = useState<MindSpacePage | null>(null);
const [pageDeleteTarget, setPageDeleteTarget] = useState<MindSpacePage | null>(null);
const [pageDeletePreview, setPageDeletePreview] = useState<MindSpacePageDeletePreview | null>(
null,
);
const [pageDeleteLoading, setPageDeleteLoading] = useState(false);
const [pageDeleteRemoveFromPlaza, setPageDeleteRemoveFromPlaza] = useState(false);
const [deletingPageId, setDeletingPageId] = useState<string | null>(null);
const [bulkPageDeleteOpen, setBulkPageDeleteOpen] = useState(false);
const [bulkPageDeleteSource, setBulkPageDeleteSource] = useState<'all' | 'draft'>('all');
const [bulkPageDeletePreviews, setBulkPageDeletePreviews] = useState<
MindSpacePageDeletePreview[] | null
>(null);
const [bulkPageDeleteLoading, setBulkPageDeleteLoading] = useState(false);
const [bulkPageDeleteRemoveFromPlaza, setBulkPageDeleteRemoveFromPlaza] = useState(false);
const [assetDeleteDialog, setAssetDeleteDialog] = useState<{
message: string;
references: Array<{ id: string; title: string; publishedOnline?: boolean }>;
} | null>(null);
const [pageLiveContext, setPageLiveContext] = useState<{
page: MindSpacePage;
title: string;
summary: string;
content: string;
} | null>(null);
const [pageRefreshTrigger, setPageRefreshTrigger] = useState(0);
const [pageFullscreenPreviewOpen, setPageFullscreenPreviewOpen] = useState(false);
const [spacePurchaseOpen, setSpacePurchaseOpen] = useState(false);
const [spacePurchaseMb, setSpacePurchaseMb] = useState('5');
const [spacePurchasePending, setSpacePurchasePending] = useState(false);
const [spacePurchaseMessage, setSpacePurchaseMessage] = useState<string | null>(null);
const { chatState, messages, session, balanceCents, completeRecharge, openRecharge } = useChat();
const prevChatStateRef = useRef(chatState);
const h5ApiBase = useMemo(() => resolveH5ApiBase(), []);
const location = useLocation();
const fromPlaza = useMemo(() => {
const params = new URLSearchParams(location.search);
return params.get('utm_source') === 'plaza';
}, [location.search]);
const plazaReturnUrl = useMemo(() => {
const params = new URLSearchParams(location.search);
return params.get('return_to') || resolvePlazaHomeUrl();
}, [location.search]);
const maxUploadFileBytes = space?.quota.maxFileBytes ?? DEFAULT_MAX_UPLOAD_FILE_BYTES;
useEffect(() => {
if (!selectedPageId) {
setPageLiveContext(null);
setPageFullscreenPreviewOpen(false);
}
}, [selectedPageId]);
useEffect(() => {
if (!allPagesOpen && !selectedPageId) return;
window.requestAnimationFrame(() => {
window.scrollTo({ top: 0, left: 0 });
});
}, [allPagesOpen, selectedPageId]);
useEffect(() => {
if (!selectedPageId || !session?.id || previewMode || pageFullscreenPreviewOpen) return;
void bindMindSpacePageLiveEdit(selectedPageId, session.id).catch(() => {});
}, [selectedPageId, session?.id, previewMode, pageFullscreenPreviewOpen]);
useEffect(() => {
if (!openAssetMenuId) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest('.mindspace-item-actions')) return;
setOpenAssetMenuId(null);
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [openAssetMenuId]);
const selectedAssetsForChat = useMemo(
() =>
selectedAssetIds
.map((id) => assets.find((asset) => asset.id === id))
.filter((asset): asset is MindSpaceAsset => Boolean(asset)),
[selectedAssetIds, assets],
);
const mindspaceChatContext = useMemo(() => {
if (!space) return null;
return buildMindSpaceChatContext({
space,
ownerUsername: user.username,
selectedCategory,
selectedPageId,
pages,
pageLive: pageLiveContext,
assets,
selectedAssets: selectedAssetsForChat,
route: `${location.pathname}${location.search}`,
agentSessionId: session?.id ?? null,
h5ApiBase,
});
}, [
space,
user.username,
selectedCategory,
selectedPageId,
pages,
pageLiveContext,
assets,
selectedAssetsForChat,
location.pathname,
location.search,
session?.id,
h5ApiBase,
]);
const 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)}…`;
};
const resolvePagePublicViewUrl = async (page: MindSpacePage): Promise<string | null> => {
const fromList = resolveMindSpacePagePublicViewUrl(page);
if (fromList) return fromList;
try {
const pageDetail = await getMindSpacePage(page.id);
const publicUrl = pageDetail.publication?.publicUrl ?? pageDetail.publicationUrl;
return publicUrl ? resolvePublicPageUrl(publicUrl) : null;
} catch {
return null;
}
};
const openPageViewInNewTab = async (page: MindSpacePage) => {
if (previewMode) {
setError('预览模式下暂不支持打开页面');
return;
}
const publicUrl = await resolvePagePublicViewUrl(page);
const viewUrl = publicUrl ?? resolveMindSpacePageOwnerPreviewUrl(page);
window.open(viewUrl, '_blank', 'noopener,noreferrer');
};
const openPageShare = async (page: MindSpacePage) => {
if (previewMode) {
setError('预览模式下暂不支持分享');
return;
}
const publicUrl = await resolvePagePublicViewUrl(page);
if (!publicUrl) {
setError('该页面尚未对外发布,暂无法分享链接;你可以先打开预览,或在编辑页完成发布后再分享');
return;
}
setSharePayload(buildPageSharePayload(page, publicUrl));
};
const showPage = (pageId: string, autoPlaza = false) => {
setSelectedPageId(pageId);
setSelectedPageAutoPlaza(autoPlaza);
routeSync?.pushPage(pageId);
};
const openPagePreview = (page: MindSpacePage) => {
setPreviewPageTarget(page);
};
const openPageDeleteDialog = async (page: MindSpacePage) => {
if (previewMode) {
setError('预览模式下无法删除页面');
return;
}
setPageDeleteTarget(page);
setPageDeletePreview(null);
setPageDeleteRemoveFromPlaza(false);
setPageDeleteLoading(true);
setError(null);
try {
setPageDeletePreview(await getMindSpacePageDeletePreview(page.id));
} catch (err) {
setPageDeleteTarget(null);
setError(err instanceof Error ? err.message : '无法加载删除预览');
} finally {
setPageDeleteLoading(false);
}
};
const closePageDeleteDialog = () => {
if (deletingPageId) return;
setPageDeleteTarget(null);
setPageDeletePreview(null);
setPageDeleteRemoveFromPlaza(false);
};
const confirmDeletePage = async () => {
if (!pageDeleteTarget) return;
setDeletingPageId(pageDeleteTarget.id);
setError(null);
try {
const result = await deleteMindSpacePage(pageDeleteTarget.id, {
removeFromPlaza: pageDeleteRemoveFromPlaza,
});
if (selectedPageId === pageDeleteTarget.id) {
closePage();
}
setPageDeleteTarget(null);
setPageDeletePreview(null);
setPageDeleteRemoveFromPlaza(false);
await refreshMindSpaceSnapshot({ quota: result.quota, freedBytes: result.freedBytes });
} catch (err) {
setError(err instanceof Error ? err.message : '删除页面失败');
} finally {
setDeletingPageId(null);
}
};
const buildPageCardActions = (page: MindSpacePage) => [
{
label: '预览',
onClick: (event: MouseEvent) => {
event.stopPropagation();
openPagePreview(page);
},
},
{
label: '编辑',
onClick: (event: MouseEvent) => {
event.stopPropagation();
showPage(page.id);
},
},
{
label: '公开链接',
onClick: (event: MouseEvent) => {
event.stopPropagation();
void openPagePublicShare(page);
},
},
{
label: exportingPageId === page.id ? '保存中…' : '保存长图',
onClick: (event: MouseEvent) => {
event.stopPropagation();
void exportPageLongImage(page);
},
},
{
label: '删除',
onClick: (event: MouseEvent) => {
event.stopPropagation();
void openPageDeleteDialog(page);
},
},
];
const closePage = () => {
setSelectedPageId(null);
if (selectedCategory) {
routeSync?.pushCategory(selectedCategory.code);
} else {
routeSync?.pushHome();
}
};
const openPagePublicShare = async (page: MindSpacePage) => {
await openPageViewInNewTab(page);
};
const exportPageLongImage = async (page: MindSpacePage) => {
if (previewMode) {
setError('预览模式下暂不支持保存长图');
return;
}
setExportingPageId(page.id);
setError(null);
try {
const dataUrl = await exportPagePreviewToPng(page);
downloadBlobAsFile(dataUrl, buildExportFilename(page.id, page.title || 'page'));
} catch (err) {
setError(err instanceof Error ? err.message : '保存长图失败');
} finally {
setExportingPageId((current) => (current === page.id ? null : current));
}
};
const closeCategory = () => {
setSelectedCategory(null);
setUploadOpen(false);
setPendingDeleteId(null);
setSelectedAssetIds([]);
setImagePage(0);
void refreshSpaceQuietly();
routeSync?.pushHome();
};
const loadCleanupItems = async () => {
if (previewMode) {
setCleanupItems([]);
return;
}
setCleanupLoading(true);
try {
const result = await listMindSpaceCleanupItems();
setCleanupItems(result.items);
setSelectedCleanupIds(result.items.map((item) => item.id));
} catch (err) {
setError(err instanceof Error ? err.message : '临时文件扫描失败');
} finally {
setCleanupLoading(false);
}
};
const runCleanup = async () => {
if (!selectedCleanupIds.length) return;
if (previewMode) {
setError(previewBlocked().message);
return;
}
setCleanupRunning(true);
setError(null);
try {
const result = await runMindSpaceCleanup(selectedCleanupIds);
if (result.quota) {
setSpace((prev) => (prev ? { ...prev, quota: result.quota! } : prev));
} else {
await refreshSpaceQuietly();
}
setCleanupOpen(false);
setCleanupItems([]);
setSelectedCleanupIds([]);
} catch (err) {
setError(err instanceof Error ? err.message : '清理失败');
} finally {
setCleanupRunning(false);
}
};
const loadAllPagesPage = async (pageIndex: number) => {
if (previewMode) {
setAllPagesItems(PREVIEW_PAGES);
setAllPagesTotal(PREVIEW_PAGES.length);
setAllPagesPageIndex(0);
return;
}
setAllPagesLoading(true);
setError(null);
try {
const { items, page } = await listMindSpacePages({
limit: ALL_PAGES_PAGE_SIZE,
offset: pageIndex * ALL_PAGES_PAGE_SIZE,
});
setAllPagesItems(items);
setAllPagesPageIndex(pageIndex);
setAllPagesTotal(page.total ?? items.length);
} catch (err) {
setError(err instanceof Error ? err.message : '全部页面加载失败');
} finally {
setAllPagesLoading(false);
}
};
const openAllPagesPanel = () => {
setSelectedCategory(null);
setSelectedPageId(null);
setNewPageOpen(false);
setAgentJobsPanelOpen(false);
setAllPagesOpen(true);
setSelectedAllPageIds([]);
routeSync?.pushHome();
void loadAllPagesPage(0);
};
const refreshAgentJobsSummary = async () => {
if (previewMode) return;
const { page } = await listMindSpaceAgentJobs({ limit: 1, offset: 0 });
setAgentJobsTotal(page.total ?? 0);
};
const loadAgentJobsPage = async (pageIndex: number) => {
if (previewMode) {
setAgentJobsList([]);
setAgentJobsTotal(0);
return;
}
setAgentJobsLoading(true);
setError(null);
try {
const { items, page } = await listMindSpaceAgentJobs({
limit: AGENT_JOBS_PAGE_SIZE,
offset: pageIndex * AGENT_JOBS_PAGE_SIZE,
});
setAgentJobsList(items);
setAgentJobsPage(pageIndex);
setAgentJobsTotal(page.total ?? 0);
} catch (err) {
setError(err instanceof Error ? err.message : '任务记录加载失败');
} finally {
setAgentJobsLoading(false);
}
};
const openAgentJobsPanel = () => {
setSelectedCategory(null);
setSelectedPageId(null);
setNewPageOpen(false);
setAllPagesOpen(false);
setAgentJobsPanelOpen(true);
routeSync?.pushHome();
void loadAgentJobsPage(0);
};
const closeAgentJobsPanel = () => {
setAgentJobsPanelOpen(false);
};
const refreshAgentJobsPanel = async () => {
await refreshAgentJobsSummary();
if (agentJobsPanelOpen) {
await loadAgentJobsPage(agentJobsPage);
}
};
const load = async () => {
setLoading(true);
setError(null);
try {
if (previewMode) {
setSpace(PREVIEW_SPACE);
setPages(PREVIEW_PAGES);
return;
}
const [nextSpace, recentPagesResult] = await Promise.all([
getMindSpace(),
listMindSpacePages({ limit: RECENT_PAGES_PAGE_SIZE, offset: 0 }),
]);
setSpace(nextSpace);
setPages(recentPagesResult.items);
setRecentPagesTotal(recentPagesResult.page.total ?? recentPagesResult.items.length);
await refreshAgentJobsSummary();
} catch (err) {
setError(err instanceof Error ? err.message : '空间加载失败');
} finally {
setLoading(false);
}
};
const refreshSpaceQuietly = async () => {
if (previewMode) return null;
try {
const nextSpace = await getMindSpace();
setSpace(nextSpace);
return nextSpace;
} catch {
return null;
}
};
const refreshPagesSilently = async () => {
if (previewMode) return;
try {
const recentPagesResult = await listMindSpacePages({
limit: RECENT_PAGES_PAGE_SIZE,
offset: 0,
});
setPages(recentPagesResult.items);
setRecentPagesTotal(recentPagesResult.page.total ?? recentPagesResult.items.length);
if (allPagesOpen) {
await loadAllPagesPage(allPagesPageIndex);
}
} catch {
// ignore background list refresh errors
}
};
const refreshMindSpaceSnapshot = async (options?: {
quota?: MindSpaceQuota;
freedBytes?: number;
}) => {
if (options?.quota) {
setSpace((prev) => (prev ? { ...prev, quota: options.quota! } : prev));
} else if (typeof options?.freedBytes === 'number' && options.freedBytes > 0) {
setSpace((prev) => {
if (!prev) return prev;
const usedBytes = Math.max(0, prev.quota.usedBytes - options.freedBytes!);
return {
...prev,
quota: {
...prev.quota,
usedBytes,
availableBytes: Math.max(
0,
prev.quota.quotaBytes - usedBytes - prev.quota.reservedBytes,
),
},
};
});
} else {
await refreshSpaceQuietly();
}
await refreshPagesSilently();
};
useEffect(() => {
const previous = prevChatStateRef.current;
prevChatStateRef.current = chatState;
const wasBusy = previous === 'streaming' || previous === 'waiting';
if (!wasBusy || chatState !== 'idle' || !selectedPageId || previewMode || pageFullscreenPreviewOpen) return;
void refreshPagesSilently();
}, [chatState, pageFullscreenPreviewOpen, previewMode, selectedPageId]);
useEffect(() => {
void load();
}, []);
useEffect(() => {
if (previewMode) return;
const refreshSchedule = () => {
if (!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen) {
void refreshSpaceQuietly();
}
};
window.addEventListener('focus', refreshSchedule);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') refreshSchedule();
});
return () => window.removeEventListener('focus', refreshSchedule);
}, [
previewMode,
selectedCategory,
selectedPageId,
newPageOpen,
agentJobsPanelOpen,
allPagesOpen,
]);
useEffect(() => {
if (initialPageId) setSelectedPageId(initialPageId);
}, [initialPageId]);
useEffect(() => {
if (!space || !initialCategoryCode || initialCategoryCode === 'draft') return;
const category = space.categories.find((item) => item.code === initialCategoryCode);
if (category) void openCategory(category);
}, [space, initialCategoryCode]);
useEffect(() => {
if (previewMode || !agentJob || !['queued', 'running'].includes(agentJob.status)) return;
const timer = window.setInterval(() => {
void getMindSpaceAgentJob(agentJob.id)
.then(async (nextJob) => {
setAgentJob(nextJob);
await refreshAgentJobsPanel();
if (nextJob.status === 'completed' && nextJob.resultPageId) {
await load();
setAgentAsset(null);
setAgentInstruction('');
setSelectedPageId(nextJob.resultPageId);
routeSync?.pushPage(nextJob.resultPageId);
}
})
.catch((err) => {
setError(err instanceof Error ? err.message : '任务状态刷新失败');
});
}, 1200);
return () => window.clearInterval(timer);
}, [agentJob, previewMode]);
const openCategory = async (category: MindSpaceCategory) => {
setAgentJobsPanelOpen(false);
setAllPagesOpen(false);
setSelectedCategory(category);
setSelectedPageId(null);
setNewPageOpen(false);
setUploadOpen(false);
setPendingDeleteId(null);
setImagePage(0);
setAssetsLoading(true);
setError(null);
try {
if (previewMode) {
setAssets(PREVIEW_ASSETS[category.code] ?? []);
if (category.code === 'draft') {
setPages(PREVIEW_PAGES);
}
return;
}
setAssets(await listMindSpaceAssets(category.code));
if (['draft', 'oa', 'public'].includes(category.code)) {
const { items } = await listMindSpacePages({ categoryCode: category.code, limit: 100 });
setPages(items);
if (category.code === 'draft') {
setSelectedDraftPageIds([]);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : '资产加载失败');
} finally {
setAssetsLoading(false);
}
routeSync?.pushCategory(category.code);
};
const openUpload = (category?: MindSpaceCategory) => {
const target =
category ??
selectedCategory ??
space?.categories.find((item) => item.code === 'oa') ??
null;
if (target) setSelectedCategory(target);
setSelectedFile(null);
setUploadError(null);
setUploadOpen(true);
};
const closeUpload = () => {
if (uploading) return;
setUploadOpen(false);
setSelectedFile(null);
setUploadError(null);
};
const uploadDialogTitle = (category: MindSpaceCategory) =>
category.code === 'oa' ? '上传资料' : `上传到 ${category.name}`;
const submitUpload = async () => {
if (!selectedCategory || !selectedFile) return;
const validationError = validateSelectedUploadFile(selectedFile, maxUploadFileBytes);
if (validationError) {
setUploadError(validationError);
return;
}
if (previewMode) {
setError(previewBlocked().message);
return;
}
setUploading(true);
setError(null);
setUploadError(null);
try {
await uploadMindSpaceAsset(selectedCategory.id, selectedFile, {
maxImageBytes: MINDSPACE_IMAGE_UPLOAD_MAX_BYTES,
});
setUploadOpen(false);
setSelectedFile(null);
await load();
await openCategory(selectedCategory);
} catch (err) {
setError(err instanceof Error ? err.message : '上传失败');
} finally {
setUploading(false);
}
};
const submitSpacePurchase = async (requestedMb?: number) => {
const sizeMb = Math.floor(Number(requestedMb ?? spacePurchaseMb));
if (!Number.isFinite(sizeMb) || sizeMb <= 0) {
setSpacePurchaseMessage('请输入大于 0 的空间大小(MB)');
return;
}
setSpacePurchasePending(true);
setSpacePurchaseMessage(null);
setError(null);
try {
const result = await purchaseSpaceQuota(sizeMb);
completeRecharge(result.balanceCents);
await refreshMindSpaceSnapshot({ quota: result.quota });
setSpacePurchaseMb(String(sizeMb));
setSpacePurchaseMessage(
`已扩容 ${sizeMb} MB,支付 ¥${(result.costCents / 100).toFixed(2)}。`,
);
} catch (err) {
if (err instanceof ApiError && err.code === 'INSUFFICIENT_BALANCE') {
setSpacePurchaseMessage('余额不足,请先到余额里充值,再回来购买空间。');
} else {
setSpacePurchaseMessage(err instanceof Error ? err.message : '购买空间失败');
}
} finally {
setSpacePurchasePending(false);
}
};
const handleUploadFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
if (!file) {
setSelectedFile(null);
setUploadError(null);
return;
}
const validationError = validateSelectedUploadFile(file, maxUploadFileBytes);
if (validationError) {
setSelectedFile(null);
setUploadError(validationError);
event.currentTarget.value = '';
return;
}
setSelectedFile(file);
setUploadError(null);
};
const removeAsset = async (assetId: string) => {
if (!selectedCategory) return;
if (previewMode) {
setError(previewBlocked().message);
return;
}
setError(null);
setAssetDeleteDialog(null);
try {
await deleteMindSpaceAsset(assetId);
setPendingDeleteId(null);
setSelectedAssetIds((ids) => ids.filter((id) => id !== assetId));
await load();
await openCategory(selectedCategory);
} catch (err) {
if (err instanceof ApiError && err.code === 'asset_in_use') {
const details = err.details as
| {
hint?: string;
references?: Array<{
id: string;
title: string;
publishedOnline?: boolean;
}>;
}
| undefined;
const references = details?.references ?? [];
setAssetDeleteDialog({
message:
details?.hint ??
(references.length
? `无法删除:页面「${truncateLabel(references[0]?.title ?? '未命名')}」正在使用该资产。`
: '资产正在被页面使用,不能删除'),
references,
});
return;
}
setAssetDeleteDialog({
message: err instanceof Error ? err.message : '删除失败',
references: [],
});
}
};
const occupiedBytes = space ? space.quota.usedBytes + space.quota.reservedBytes : 0;
const usedPercent = space
? Math.min(100, (occupiedBytes / space.quota.quotaBytes) * 100)
: 0;
const currentBalanceCents = typeof balanceCents === 'number' ? balanceCents : user.balanceCents;
const purchaseMbValue = Math.max(0, Math.floor(Number(spacePurchaseMb) || 0));
const purchaseCostCents = purchaseMbValue * 200;
const canPurchaseSpace = purchaseMbValue > 0 && currentBalanceCents >= purchaseCostCents;
const openAssetEditor = async (asset: MindSpaceAsset) => {
if (previewMode) {
setError(previewBlocked().message);
return;
}
const linkedPage = pages.find(
(page) => page.sourceAssetId === asset.id && page.status !== 'deleted',
);
if (linkedPage) {
setPreviewAssetId(null);
showPage(linkedPage.id);
return;
}
setEditingAssetId(asset.id);
setError(null);
try {
const page = await createMindSpacePageFromAsset({
assetId: asset.id,
title: asset.displayName,
});
setPreviewAssetId(null);
showPage(page.id);
void load();
} catch (err) {
setError(err instanceof Error ? err.message : '打开编辑失败');
} finally {
setEditingAssetId(null);
}
};
const openAgentGenerator = (asset: MindSpaceAsset) => {
setAgentAsset(asset);
setAgentJob(null);
setAgentInstruction(`请根据《${asset.displayName}》生成一个中文页面草稿,提炼核心结论、重点信息和可执行建议。`);
};
const closeAgentGenerator = () => {
if (agentSubmitting) return;
setAgentAsset(null);
setAgentInstruction('');
setAgentJob(null);
};
const submitAgentGenerator = async () => {
if (!agentAsset || !agentInstruction.trim()) return;
if (previewMode) {
setError(previewBlocked().message);
return;
}
setAgentSubmitting(true);
setError(null);
try {
const created = await createMindSpaceAgentJob({
jobType: 'generate_page',
instruction: agentInstruction.trim(),
allowedAssetIds: [agentAsset.id],
outputType: 'page_draft',
});
setAgentJob(created);
await refreshAgentJobsSummary();
await runMindSpaceAgentJob(created.id);
const running = await getMindSpaceAgentJob(created.id);
setAgentJob(running);
if (agentJobsPanelOpen) await loadAgentJobsPage(0);
} catch (err) {
setError(err instanceof Error ? err.message : '启动 AI 生成失败');
} finally {
setAgentSubmitting(false);
}
};
const cancelAgentJob = async (jobId: string) => {
try {
const cancelled = await cancelMindSpaceAgentJob(jobId);
setAgentJob((current) => (current?.id === jobId ? cancelled : current));
await refreshAgentJobsPanel();
} catch (err) {
setError(err instanceof Error ? err.message : '取消任务失败');
}
};
const retryAgentJob = async (jobId: string) => {
try {
const retried = await retryMindSpaceAgentJob(jobId);
setAgentJob((current) => (current?.id === jobId ? retried : current));
await refreshAgentJobsPanel();
await runMindSpaceAgentJob(jobId);
const running = await getMindSpaceAgentJob(jobId);
setAgentJob((current) => (current?.id === jobId ? running : current));
await refreshAgentJobsPanel();
} catch (err) {
setError(err instanceof Error ? err.message : '重试任务失败');
}
};
const imageAssets = useMemo(() => assets.filter(isImageAsset), [assets]);
const fileAssets = useMemo(
() => assets.filter((asset) => !isWorkAsset(asset) && !isImageAsset(asset)),
[assets],
);
const workAssets = useMemo(() => assets.filter(isWorkAsset), [assets]);
const visibleImageAssets =
assetFilter === 'all' || assetFilter === 'images' ? imageAssets : [];
const imagePaginationEnabled =
selectedCategory?.code !== 'public' && visibleImageAssets.length > IMAGE_PAGE_SIZE;
const imagePageCount = Math.max(
1,
Math.ceil(visibleImageAssets.length / IMAGE_PAGE_SIZE),
);
const safeImagePage = Math.min(imagePage, imagePageCount - 1);
const pagedImageAssets = imagePaginationEnabled
? visibleImageAssets.slice(
safeImagePage * IMAGE_PAGE_SIZE,
safeImagePage * IMAGE_PAGE_SIZE + IMAGE_PAGE_SIZE,
)
: visibleImageAssets;
const sortedPages = useMemo(() => sortPagesByCreatedAt(pages), [pages]);
const recentPagesPreview = sortedPages.slice(0, RECENT_PAGES_PAGE_SIZE);
const imageAssetsByDate = useMemo(() => {
const groups = new Map<string, MindSpaceAsset[]>();
for (const asset of visibleImageAssets) {
const match = asset.filename.match(/images\/(\d{4}-\d{2}-\d{2})\//);
const dateKey = match?.[1] ?? '其他';
const bucket = groups.get(dateKey) ?? [];
bucket.push(asset);
groups.set(dateKey, bucket);
}
return [...groups.entries()].sort((left, right) => right[0].localeCompare(left[0]));
}, [visibleImageAssets]);
const visibleCardAssets =
assetFilter === 'all'
? [...fileAssets, ...workAssets]
: assetFilter === 'files'
? fileAssets
: assetFilter === 'pages'
? workAssets
: [];
const visibleSelectableAssets = [...pagedImageAssets, ...visibleCardAssets];
const selectedAssetSet = useMemo(() => new Set(selectedAssetIds), [selectedAssetIds]);
const allVisibleAssetsSelected =
visibleSelectableAssets.length > 0 &&
visibleSelectableAssets.every((asset) => selectedAssetSet.has(asset.id));
useEffect(() => {
const availableIds = new Set(assets.map((asset) => asset.id));
setSelectedAssetIds((ids) => ids.filter((id) => availableIds.has(id)));
}, [assets]);
const toggleAssetSelected = (assetId: string) => {
setSelectedAssetIds((ids) =>
ids.includes(assetId) ? ids.filter((id) => id !== assetId) : [...ids, assetId],
);
};
const toggleVisibleAssetsSelected = () => {
const visibleIds = visibleSelectableAssets.map((asset) => asset.id);
if (allVisibleAssetsSelected) {
setSelectedAssetIds((ids) => ids.filter((id) => !visibleIds.includes(id)));
return;
}
setSelectedAssetIds((ids) => Array.from(new Set([...ids, ...visibleIds])));
};
const removeSelectedAssets = async () => {
if (!selectedCategory || selectedAssetIds.length === 0) return;
if (previewMode) {
setError(previewBlocked().message);
return;
}
setBulkDeletingAssets(true);
setError(null);
setAssetDeleteDialog(null);
const deletingIds = [...selectedAssetIds];
const failedIds: string[] = [];
const failedNames: string[] = [];
try {
for (const assetId of deletingIds) {
try {
await deleteMindSpaceAsset(assetId);
} catch (err) {
const assetName = assets.find((asset) => asset.id === assetId)?.displayName ?? assetId;
failedIds.push(assetId);
failedNames.push(assetName);
}
}
setSelectedAssetIds((ids) => ids.filter((id) => failedIds.includes(id)));
await load();
await openCategory(selectedCategory);
if (failedNames.length > 0) {
setAssetDeleteDialog({
message: `有 ${failedNames.length} 个资产未删除,可能正在被页面使用。`,
references: failedNames.slice(0, 6).map((title, index) => ({
id: `${index}`,
title,
})),
});
}
} finally {
setBulkDeletingAssets(false);
}
};
const selectedDraftPageSet = useMemo(() => new Set(selectedDraftPageIds), [selectedDraftPageIds]);
const allDraftPagesSelected =
pages.length > 0 && pages.every((p) => selectedDraftPageSet.has(p.id));
const toggleDraftPageSelected = (pageId: string) => {
setSelectedDraftPageIds((ids) =>
ids.includes(pageId) ? ids.filter((id) => id !== pageId) : [...ids, pageId],
);
};
const toggleAllDraftPagesSelected = () => {
if (allDraftPagesSelected) {
setSelectedDraftPageIds([]);
} else {
setSelectedDraftPageIds(pages.map((p) => p.id));
}
};
const bulkDeletePlazaPosts = useMemo(() => {
if (!bulkPageDeletePreviews) return [];
const seen = new Set<string>();
const posts = [];
for (const preview of bulkPageDeletePreviews) {
for (const post of preview.plazaPosts ?? []) {
if (seen.has(post.id)) continue;
seen.add(post.id);
posts.push(post);
}
}
return posts;
}, [bulkPageDeletePreviews]);
const closeBulkPageDeleteDialog = () => {
if (bulkDeletingAllPages || bulkDeletingDraftPages) return;
setBulkPageDeleteOpen(false);
setBulkPageDeletePreviews(null);
setBulkPageDeleteRemoveFromPlaza(false);
};
const startBulkPageDelete = async (pageIds: string[], source: 'all' | 'draft') => {
if (pageIds.length === 0) return;
if (previewMode) {
setError('预览模式下无法删除页面');
return;
}
setBulkPageDeleteSource(source);
setBulkPageDeletePreviews(null);
setBulkPageDeleteRemoveFromPlaza(false);
setBulkPageDeleteLoading(true);
setBulkPageDeleteOpen(true);
setError(null);
try {
const previews = await Promise.all(
pageIds.map((pageId) =>
getMindSpacePageDeletePreview(pageId).catch((err) => {
console.error('[MindSpace] delete preview failed:', pageId, err);
return null;
}),
),
);
const valid = previews.filter(Boolean) as MindSpacePageDeletePreview[];
if (valid.length === 0) {
setBulkPageDeleteOpen(false);
setError('无法加载删除预览');
return;
}
setBulkPageDeletePreviews(valid);
} catch (err) {
setBulkPageDeleteOpen(false);
setError(err instanceof Error ? err.message : '无法加载删除预览');
} finally {
setBulkPageDeleteLoading(false);
}
};
const confirmBulkPageDelete = async () => {
if (!bulkPageDeletePreviews || bulkPageDeletePreviews.length === 0) return;
const deletingIds = bulkPageDeletePreviews.map((preview) => preview.page.id);
const setBulkDeleting =
bulkPageDeleteSource === 'all' ? setBulkDeletingAllPages : setBulkDeletingDraftPages;
setBulkDeleting(true);
setError(null);
const failedIds: string[] = [];
const failedNames: string[] = [];
try {
for (const preview of bulkPageDeletePreviews) {
try {
await deleteMindSpacePage(preview.page.id, {
removeFromPlaza: bulkPageDeleteRemoveFromPlaza,
});
if (selectedPageId === preview.page.id) closePage();
} catch (err) {
failedIds.push(preview.page.id);
failedNames.push(preview.page.title);
console.error('[MindSpace] delete page failed:', preview.page.id, err);
}
}
const deletedIds = deletingIds.filter((id) => !failedIds.includes(id));
if (bulkPageDeleteSource === 'all') {
setSelectedAllPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
} else {
setSelectedDraftPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
}
setBulkPageDeleteOpen(false);
setBulkPageDeletePreviews(null);
setBulkPageDeleteRemoveFromPlaza(false);
await refreshMindSpaceSnapshot();
await load();
if (bulkPageDeleteSource === 'all' && deletedIds.length > 0) {
const nextPageIndex =
allPagesPageIndex > 0 && allPagesItems.length <= deletedIds.length
? Math.max(0, allPagesPageIndex - 1)
: allPagesPageIndex;
await loadAllPagesPage(nextPageIndex);
}
if (failedNames.length > 0) {
setError(`有 ${failedNames.length} 个页面删除失败:${failedNames.slice(0, 3).join('、')}`);
}
} finally {
setBulkDeleting(false);
}
};
const removeDraftPages = () => {
void startBulkPageDelete([...selectedDraftPageIds], 'draft');
};
const selectedAllPageSet = useMemo(() => new Set(selectedAllPageIds), [selectedAllPageIds]);
const allCurrentAllPagesSelected =
allPagesItems.length > 0 && allPagesItems.every((page) => selectedAllPageSet.has(page.id));
const toggleAllPagesOnCurrentPageSelected = () => {
if (allCurrentAllPagesSelected) {
const currentIds = new Set(allPagesItems.map((page) => page.id));
setSelectedAllPageIds((ids) => ids.filter((id) => !currentIds.has(id)));
} else {
const merged = new Set([...selectedAllPageIds, ...allPagesItems.map((page) => page.id)]);
setSelectedAllPageIds([...merged]);
}
};
const removeSelectedAllPages = () => {
void startBulkPageDelete([...selectedAllPageIds], 'all');
};
const allPagesPageCount = Math.max(1, Math.ceil(allPagesTotal / ALL_PAGES_PAGE_SIZE));
const previewAsset = useMemo(
() => assets.find((asset) => asset.id === previewAssetId) ?? null,
[assets, previewAssetId],
);
const previewAssetFrame = previewAsset ? buildAssetPreviewFrameConfig(previewAsset) : null;
const renderAssetActions = (asset: MindSpaceAsset) => {
const shareReady = asset.status === 'ready' && asset.scanStatus !== 'blocked';
const menuOpen = openAssetMenuId === asset.id;
return (
<div className="mindspace-item-actions">
{isWorkAsset(asset) ? (
<button
type="button"
className="mindspace-item-action is-primary"
onClick={() => void openAssetEditor(asset)}
disabled={editingAssetId === asset.id}
>
{editingAssetId === asset.id ? '打开中…' : '编辑'}
</button>
) : isPreviewableAsset(asset) ? (
<button
type="button"
className="mindspace-item-action is-primary"
onClick={() => setPreviewAssetId(asset.id)}
>
预览
</button>
) : (
<span className="mindspace-asset-blocked">暂不可预览</span>
)}
{shareReady ? (
<button
type="button"
className="mindspace-asset-share mindspace-item-action"
onClick={() => setSharePayload(buildAssetSharePayload(asset, selectedCategory?.code))}
>
分享
</button>
) : (
<span className="mindspace-asset-blocked">暂不可分享</span>
)}
<div className={`mindspace-item-more${menuOpen ? ' is-open' : ''}`}>
<button
type="button"
className="mindspace-item-more-trigger"
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label={`${asset.displayName} 更多操作`}
onClick={() => setOpenAssetMenuId((current) => (current === asset.id ? null : asset.id))}
>
···
</button>
{menuOpen && (
<div className="mindspace-item-more-menu" role="menu" aria-label="更多操作">
{isPreviewableAsset(asset) && !isWorkAsset(asset) && (
<button
type="button"
className="mindspace-item-more-entry"
role="menuitem"
onClick={() => {
setPreviewAssetId(asset.id);
setOpenAssetMenuId(null);
}}
>
预览
</button>
)}
{asset.sourcePageId && (
<button
type="button"
className="mindspace-item-more-entry"
role="menuitem"
onClick={() => {
showPage(asset.sourcePageId!, true);
setOpenAssetMenuId(null);
}}
>
发布到广场
</button>
)}
{asset.publicUrl && (
<a
className="mindspace-item-more-entry"
role="menuitem"
href={asset.publicUrl}
target="_blank"
rel="noreferrer"
onClick={() => setOpenAssetMenuId(null)}
>
打开公网图
</a>
)}
{pendingDeleteId === asset.id ? (
<>
<button
type="button"
className="mindspace-item-more-entry is-danger"
role="menuitem"
onClick={() => {
void removeAsset(asset.id);
setOpenAssetMenuId(null);
}}
>
确认删除
</button>
<button
type="button"
className="mindspace-item-more-entry"
role="menuitem"
onClick={() => {
setPendingDeleteId(null);
setOpenAssetMenuId(null);
}}
>
取消删除
</button>
</>
) : (
<button
type="button"
className="mindspace-item-more-entry is-danger"
role="menuitem"
onClick={() => {
setPendingDeleteId(asset.id);
setOpenAssetMenuId(null);
}}
>
删除
</button>
)}
</div>
)}
</div>
</div>
);
};
const createPage = async () => {
if (!newPageTitle.trim() || !newPageContent.trim()) return;
if (previewMode) {
setError(previewBlocked().message);
return;
}
setCreatingPage(true);
setError(null);
try {
const page = await createMindSpacePage({
title: newPageTitle.trim(),
content: newPageContent.trim(),
templateId: newPageTemplate,
});
setNewPageOpen(false);
setNewPageTitle('');
setNewPageContent('');
await load();
showPage(page.id);
} catch (err) {
setError(err instanceof Error ? err.message : '页面创建失败');
} finally {
setCreatingPage(false);
}
};
const agentJobsPageCount = Math.max(1, Math.ceil(agentJobsTotal / AGENT_JOBS_PAGE_SIZE));
const renderAgentJobCard = (job: MindSpaceAgentJob) => (
<article className={`mindspace-agent-history-card is-${job.status}`} key={job.id}>
<div className="mindspace-agent-history-top">
<div>
<strong>{job.assets[0]?.displayName ?? '未命名资料'}</strong>
<p>{job.instruction}</p>
</div>
<span>{JOB_STATUS_LABELS[job.status]}</span>
</div>
<div className="mindspace-agent-history-meta">
<span>阶段:{job.progress.stage}</span>
<span>{new Date(job.queuedAt).toLocaleString()}</span>
</div>
{job.errorMessage && <div className="mindspace-agent-history-error">{job.errorMessage}</div>}
<div className="mindspace-agent-history-actions">
{job.resultPageId && (
<button type="button" onClick={() => showPage(job.resultPageId!)}>
打开草稿
</button>
)}
{['queued', 'running'].includes(job.status) && (
<button type="button" onClick={() => void cancelAgentJob(job.id)}>
取消任务
</button>
)}
{job.retryable && ['failed', 'cancelled', 'timed_out'].includes(job.status) && (
<button type="button" onClick={() => void retryAgentJob(job.id)}>
重新生成
</button>
)}
</div>
</article>
);
const showHomeModules =
space && !selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen;
const rawUpcomingReminders = previewMode ? [] : space?.schedule?.upcomingReminders ?? [];
const scheduleReminders = previewMode
? PREVIEW_SCHEDULE_REMINDERS.map((item) => ({ ...item }))
: rawUpcomingReminders.map((reminder) => {
const metaParts = [
formatReminderEventWindow(reminder),
reminder.offsetMinutes != null && reminder.offsetMinutes > 0
? `提前 ${reminder.offsetMinutes} 分钟`
: null,
reminder.channel === 'wechat' ? '服务号推送' : '站内提醒',
].filter(Boolean);
return {
id: reminder.id,
time: formatScheduleWhen(reminder.remindAt, reminder.itemTimezone),
title: reminder.itemTitle || '待办提醒',
meta: metaParts.join(' · '),
notifyLabel: scheduleReminderNotifyLabel(reminder.status),
canIgnore: reminder.status === 'pending' || reminder.status === 'locked',
};
});
const scheduleDigests = previewMode ? [] : space?.schedule?.digestSubscriptions ?? [];
const pendingReminderCount = scheduleReminders.filter((item) => item.notifyLabel === '待通知').length;
const hasScheduleContent = scheduleReminders.length > 0 || scheduleDigests.length > 0;
const toggleScheduleReminderSelection = (reminderId: string) => {
setSelectedScheduleReminderIds((prev) =>
prev.includes(reminderId) ? prev.filter((id) => id !== reminderId) : [...prev, reminderId],
);
};
const toggleAllScheduleReminders = () => {
if (selectedScheduleReminderIds.length === scheduleReminders.length) {
setSelectedScheduleReminderIds([]);
} else {
setSelectedScheduleReminderIds(scheduleReminders.map((item) => item.id));
}
};
const ignoreScheduleReminder = async (reminderId: string) => {
if (previewMode) return;
setScheduleReminderActionLoading(true);
setError(null);
try {
await ignoreMindSpaceScheduleReminder(reminderId);
setSelectedScheduleReminderIds((prev) => prev.filter((id) => id !== reminderId));
await refreshSpaceQuietly();
} catch (err) {
setError(err instanceof Error ? err.message : '忽略提醒失败');
} finally {
setScheduleReminderActionLoading(false);
}
};
const deleteSelectedScheduleReminders = async () => {
if (previewMode || selectedScheduleReminderIds.length === 0) return;
setScheduleReminderActionLoading(true);
setError(null);
try {
await deleteMindSpaceScheduleReminders(selectedScheduleReminderIds);
setSelectedScheduleReminderIds([]);
await refreshSpaceQuietly();
} catch (err) {
setError(err instanceof Error ? err.message : '删除提醒失败');
} finally {
setScheduleReminderActionLoading(false);
}
};
const selectedPreviewPage = selectedPageId
? pages.find((page) => page.id === selectedPageId) ?? null
: null;
const selectedPreviewReader = selectedPreviewPage
? PREVIEW_PAGE_READERS[selectedPreviewPage.id] ?? {
kicker: selectedPreviewPage.templateId,
body: [
selectedPreviewPage.summary || '这是该页面的站内预览内容。',
'正式接入真实数据后,这里会直接呈现用户生成页面的完整内容。',
],
}
: null;
return (
<div className={`mindspace-page${previewMode ? ' mindspace-page-preview' : ''}`}>
{previewMode && (
<div className="mindspace-preview-banner">
<span>UI 预览模式 · 示例数据,无需登录</span>
<a href="/">退出预览</a>
</div>
)}
<header className="mindspace-header">
<div className="mindspace-header-nav">
{fromPlaza && (
<a href={plazaReturnUrl} className="mindspace-back mindspace-back-plaza">
返回广场
</a>
)}
<button type="button" className="mindspace-back" onClick={onBack}>
返回聊天
</button>
</div>
<div className="mindspace-header-actions">
{!fromPlaza && (
<a
href={resolvePlazaHomeUrl()}
className="ghost-btn mindspace-plaza-link"
target="_blank"
rel="noopener noreferrer"
>
发现广场
</a>
)}
{previewMode ? (
<BalanceRing
balanceCents={user.balanceCents}
totalCreditCents={user.totalCreditCents}
tokensUsed={user.tokensUsed}
onRecharge={() => {}}
/>
) : (
<MindSpaceBalanceRing />
)}
{previewMode ? (
<NotificationCenter onOpenRecharge={() => {}} />
) : (
<MindSpaceNotificationCenter />
)}
{!previewMode && onOpenFeedback && (
<button type="button" className="ghost-btn" onClick={onOpenFeedback}>
反馈
</button>
)}
<span>{user.displayName}</span>
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
登出
</button>
</div>
</header>
<main className="mindspace-content">
{!allPagesOpen && (
<section className="mindspace-hero">
<div className="mindspace-hero-left">
<p className="mindspace-eyebrow">MINDSPACE</p>
<h1>{space?.name ?? '我的空间'}</h1>
<p>让每一次 AI 对话、分析和创作,都成为可以继续使用的作品。</p>
<div className="mindspace-actions">
<button
type="button"
className="mindspace-primary"
onClick={() => openUpload()}
disabled={!space}
>
上传文件
</button>
<button
type="button"
className="mindspace-secondary"
onClick={() => {
setSelectedCategory(null);
setSelectedPageId(null);
setAllPagesOpen(false);
setNewPageOpen(true);
}}
>
新建页面
</button>
<a
href={resolvePlazaHomeUrl()}
className="mindspace-secondary mindspace-plaza-link"
target="_blank"
rel="noopener noreferrer"
>
逛 Plaza
</a>
</div>
</div>
{space && (
<aside className="mindspace-hero-quota">
<div className="mindspace-hero-quota-top">
<span className="mindspace-hero-quota-label">
<span>空间容量</span>
<button
type="button"
className="mindspace-hero-quota-buy-trigger"
aria-label="购买空间扩容"
onClick={() => {
setSpacePurchaseMessage(null);
setSpacePurchaseOpen(true);
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
<span>扩容</span>
</button>
</span>
<strong>
{formatBytes(occupiedBytes)} / {formatBytes(space.quota.quotaBytes)}
</strong>
</div>
<div
className="mindspace-hero-quota-track"
role="progressbar"
aria-label="空间使用量"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(usedPercent)}
>
<span style={{ width: `${usedPercent}%` }} />
</div>
<p className="mindspace-hero-quota-meta">
{space.quota.reservedBytes > 0 && (
<>
含上传预留 {formatBytes(space.quota.reservedBytes)} ·{' '}
</>
)}
剩余 {formatBytes(space.quota.availableBytes)} ·{' '}
单文件上限 {formatBytes(space.quota.maxFileBytes)} · 可发布页面{' '}
{space.quota.publicPageUsed}/{space.quota.publicPageLimit} · 今日 AI{' '}
{space.quota.aiDailyUsed}/{space.quota.aiDailyLimit}
</p>
<button
type="button"
className="mindspace-hero-quota-cleanup"
onClick={() => {
setCleanupOpen((value) => !value);
if (!cleanupOpen) void loadCleanupItems();
}}
>
{cleanupOpen ? '收起清理' : '清理空间'}
</button>
</aside>
)}
{cleanupOpen && (
<div className="mindspace-hero-cleanup">
{cleanupLoading && <p>正在扫描可清理项…</p>}
{!cleanupLoading && cleanupItems.length === 0 && (
<p>
{space && occupiedBytes >= space.quota.quotaBytes * 0.85
? `空间已用 ${formatBytes(occupiedBytes)} / ${formatBytes(space.quota.quotaBytes)},暂无可自动清理的临时文件或历史版本。请到下方分类中手动删除不需要的页面或资产。`
: '当前没有可清理的临时文件或历史页面版本。'}
</p>
)}
{!cleanupLoading && cleanupItems.length > 0 && (
<>
<p>
可释放约{' '}
{formatBytes(
cleanupItems
.filter((item) => selectedCleanupIds.includes(item.id))
.reduce((sum, item) => sum + item.sizeBytes, 0),
)}
</p>
<ul className="mindspace-cleanup-list">
{cleanupItems.map((item) => (
<li key={item.id}>
<label>
<input
type="checkbox"
checked={selectedCleanupIds.includes(item.id)}
onChange={(event) => {
setSelectedCleanupIds((current) =>
event.target.checked
? [...current, item.id]
: current.filter((id) => id !== item.id),
);
}}
/>
<span>
<strong>{item.label}</strong>
<small>
{item.detail} · {formatBytes(item.sizeBytes)} · {item.path}
</small>
</span>
</label>
</li>
))}
</ul>
<div className="mindspace-cleanup-actions">
<button
type="button"
onClick={() =>
setSelectedCleanupIds(
selectedCleanupIds.length === cleanupItems.length
? []
: cleanupItems.map((item) => item.id),
)
}
>
{selectedCleanupIds.length === cleanupItems.length ? '取消全选' : '全选'}
</button>
<button
type="button"
className="mindspace-primary"
disabled={cleanupRunning || selectedCleanupIds.length === 0}
onClick={() => void runCleanup()}
>
{cleanupRunning ? '清理中…' : '清除所选'}
</button>
</div>
</>
)}
</div>
)}
</section>
)}
{showHomeModules && (
<section className="mindspace-schedule-module" aria-labelledby="mindspace-schedule-title">
<div className="mindspace-schedule-head">
<div>
<p className="mindspace-eyebrow">TODAY</p>
<h2 id="mindspace-schedule-title">待办行事历</h2>
</div>
<button type="button" className="mindspace-schedule-link">
查看日历
</button>
</div>
<div className="mindspace-schedule-summary">
<strong>
未来 7 天 {scheduleReminders.length} 条提醒 · {pendingReminderCount} 待通知
</strong>
<span>{hasScheduleContent ? '仅展示提醒通知' : '还没有安排'}</span>
</div>
{scheduleReminders.length > 0 && (
<div className="mindspace-schedule-toolbar">
<label className="mindspace-schedule-select-all">
<input
type="checkbox"
checked={
scheduleReminders.length > 0 &&
selectedScheduleReminderIds.length === scheduleReminders.length
}
onChange={() => toggleAllScheduleReminders()}
disabled={scheduleReminderActionLoading || previewMode}
/>
<span>全选</span>
</label>
<button
type="button"
className="mindspace-schedule-toolbar-btn danger"
disabled={
previewMode ||
scheduleReminderActionLoading ||
selectedScheduleReminderIds.length === 0
}
onClick={() => void deleteSelectedScheduleReminders()}
>
{scheduleReminderActionLoading ? '处理中…' : '删除提醒'}
</button>
</div>
)}
{scheduleReminders.length > 0 && (
<div className="mindspace-schedule-list">
{scheduleReminders.map((item) => (
<article className="mindspace-schedule-item has-checkbox" key={item.id}>
<label className="mindspace-schedule-checkbox">
<input
type="checkbox"
checked={selectedScheduleReminderIds.includes(item.id)}
onChange={() => toggleScheduleReminderSelection(item.id)}
disabled={scheduleReminderActionLoading || previewMode}
/>
</label>
<time>{item.time}</time>
<span
className={`mindspace-schedule-notify is-${item.notifyLabel === '已通知' ? 'sent' : 'pending'}`}
>
{item.notifyLabel}
</span>
<div className="mindspace-schedule-item-body">
<strong>{item.title}</strong>
<small>{item.meta}</small>
</div>
{item.canIgnore && !previewMode && (
<button
type="button"
className="mindspace-schedule-ignore"
disabled={scheduleReminderActionLoading}
onClick={() => void ignoreScheduleReminder(item.id)}
>
忽略
</button>
)}
</article>
))}
</div>
)}
{scheduleDigests.length > 0 && (
<div className="mindspace-schedule-list">
<p className="mindspace-schedule-subhead">订阅</p>
{scheduleDigests.map((digest) => (
<article className="mindspace-schedule-item mindspace-schedule-item-digest" key={digest.id}>
<time>{String(digest.hour).padStart(2, '0')}:{String(digest.minute).padStart(2, '0')}</time>
<span className="mindspace-schedule-notify is-pending">订阅</span>
<div className="mindspace-schedule-item-body">
<strong>每天待办摘要</strong>
<small>{formatDigestSchedule(digest)}</small>
</div>
</article>
))}
</div>
)}
{!hasScheduleContent && (
<p className="mindspace-schedule-empty">
未来 7 天还没有提醒,可以在聊天里说「明天早上 6 点跑步,5 点 55 提醒我」。
</p>
)}
</section>
)}
{loading && <div className="mindspace-state">正在加载个人空间…</div>}
{error && !assetDeleteDialog && (
<div className="mindspace-state mindspace-error">
<span>{error}</span>
<button type="button" onClick={() => void load()}>
重新加载
</button>
</div>
)}
{space && (
<>
{selectedPageId ? (
previewMode ? (
<section className="mindspace-page-reader">
<header className="mindspace-page-reader-bar">
<button type="button" className="mindspace-inline-back" onClick={closePage}>
{allPagesOpen ? '返回全部页面' : '返回空间首页'}
</button>
<span>站内浏览</span>
</header>
{selectedPreviewPage && selectedPreviewReader ? (
<article className="mindspace-page-reader-sheet">
<p className="mindspace-eyebrow">{selectedPreviewReader.kicker}</p>
<h2>{selectedPreviewPage.title}</h2>
<p className="mindspace-page-reader-summary">
{selectedPreviewPage.summary}
</p>
<div className="mindspace-page-reader-body">
{selectedPreviewReader.body.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
</article>
) : (
<div className="mindspace-state">页面不存在或已被删除</div>
)}
</section>
) : (
<MindSpacePageDetail
pageId={selectedPageId}
onBack={closePage}
onSaved={refreshMindSpaceSnapshot}
onContextUpdate={setPageLiveContext}
autoOpenPlaza={selectedPageAutoPlaza}
refreshTrigger={pageRefreshTrigger}
onFullscreenPreviewChange={setPageFullscreenPreviewOpen}
/>
)
) : newPageOpen ? (
<section className="mindspace-section mindspace-new-page">
<button
type="button"
className="mindspace-inline-back"
onClick={() => setNewPageOpen(false)}
>
返回空间首页
</button>
<p className="mindspace-eyebrow">CREATE PAGE</p>
<h2>创建页面草稿</h2>
<label>
标题
<input
value={newPageTitle}
maxLength={255}
onChange={(event) => setNewPageTitle(event.target.value)}
/>
</label>
<label>
模板
<select
value={newPageTemplate}
onChange={(event) => setNewPageTemplate(event.target.value)}
>
<option value="editorial">编辑长文</option>
<option value="report">分析报告</option>
<option value="knowledge-card">知识卡片</option>
<option value="profile">个人介绍</option>
</select>
</label>
<label>
内容
<textarea
value={newPageContent}
rows={14}
onChange={(event) => setNewPageContent(event.target.value)}
/>
</label>
<div className="mindspace-upload-actions">
<button
type="button"
className="mindspace-primary"
disabled={creatingPage || !newPageTitle.trim() || !newPageContent.trim()}
onClick={() => void createPage()}
>
{creatingPage ? '创建中…' : '创建并预览'}
</button>
</div>
</section>
) : allPagesOpen ? (
<section className="mindspace-independent-page mindspace-all-pages">
<div className="mindspace-section-heading">
<div>
<button
type="button"
className="mindspace-inline-back"
onClick={() => setAllPagesOpen(false)}
>
返回空间首页
</button>
<p className="mindspace-eyebrow">ALL PAGES</p>
<h2>全部页面</h2>
</div>
<span>{allPagesTotal} 个页面</span>
</div>
{allPagesLoading && allPagesItems.length === 0 ? (
<div className="mindspace-state">正在加载页面…</div>
) : allPagesTotal > 0 ? (
<>
<div className="mindspace-asset-bulkbar mindspace-all-pages-bulkbar">
<button
type="button"
onClick={toggleAllPagesOnCurrentPageSelected}
disabled={bulkDeletingAllPages || allPagesItems.length === 0}
>
{allCurrentAllPagesSelected ? '取消全选' : '全选'}
</button>
<span>已选 {selectedAllPageIds.length} 项</span>
{selectedAllPageIds.length > 0 && (
<button
type="button"
onClick={() => setSelectedAllPageIds([])}
disabled={bulkDeletingAllPages}
>
清空
</button>
)}
<button
type="button"
className="mindspace-asset-bulk-delete"
onClick={() => void removeSelectedAllPages()}
disabled={selectedAllPageIds.length === 0 || bulkDeletingAllPages}
>
{bulkDeletingAllPages ? '删除中…' : '删除选中'}
</button>
</div>
<div className="mindspace-feed-grid mindspace-recent-work-grid is-all-pages">
{allPagesItems.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
previewMode={previewMode}
coverMode="thumbnail-only"
checkboxPlacement="top-right"
checked={selectedAllPageSet.has(page.id)}
onCheckChange={(checked) => {
setSelectedAllPageIds((ids) =>
checked
? ids.includes(page.id)
? ids
: [...ids, page.id]
: ids.filter((id) => id !== page.id),
);
}}
onClick={() => void openPageViewInNewTab(page)}
onEditPage={() => showPage(page.id)}
onOpenPage={() => void openPageViewInNewTab(page)}
onShare={() => void openPageShare(page)}
/>
))}
</div>
{allPagesTotal > ALL_PAGES_PAGE_SIZE && (
<div className="mindspace-pagination">
<button
type="button"
disabled={allPagesPageIndex <= 0 || allPagesLoading}
onClick={() => void loadAllPagesPage(allPagesPageIndex - 1)}
>
上一页
</button>
<span>
第 {allPagesPageIndex + 1} / {allPagesPageCount} 页
</span>
<button
type="button"
disabled={
allPagesLoading ||
(allPagesPageIndex + 1) * ALL_PAGES_PAGE_SIZE >= allPagesTotal
}
onClick={() => void loadAllPagesPage(allPagesPageIndex + 1)}
>
下一页
</button>
</div>
)}
</>
) : (
<div className="mindspace-recent-work-empty">
<p>上传资料或从聊天保存页面后,所有页面会集中显示在这里。</p>
</div>
)}
</section>
) : agentJobsPanelOpen ? (
<section className="mindspace-section">
<div className="mindspace-section-heading">
<div>
<button
type="button"
className="mindspace-inline-back"
onClick={closeAgentJobsPanel}
>
返回空间首页
</button>
<p className="mindspace-eyebrow">AGENT JOBS</p>
<h2>AI 生成任务</h2>
</div>
<span>共 {agentJobsTotal} 条</span>
</div>
{agentJobsLoading && <div className="mindspace-state">正在加载任务记录…</div>}
{!agentJobsLoading && agentJobsList.length === 0 && (
<div className="mindspace-empty">
<h2>还没有 AI 生成任务</h2>
<p>在 OA 工作区选择资料并点击「AI 生成页面」后,任务会出现在这里。</p>
</div>
)}
{!agentJobsLoading && agentJobsList.length > 0 && (
<>
<div className="mindspace-agent-history">{agentJobsList.map(renderAgentJobCard)}</div>
{agentJobsTotal > AGENT_JOBS_PAGE_SIZE && (
<div className="mindspace-pagination">
<button
type="button"
disabled={agentJobsPage <= 0 || agentJobsLoading}
onClick={() => void loadAgentJobsPage(agentJobsPage - 1)}
>
上一页
</button>
<span>
第 {agentJobsPage + 1} / {agentJobsPageCount} 页
</span>
<button
type="button"
disabled={
agentJobsLoading ||
(agentJobsPage + 1) * AGENT_JOBS_PAGE_SIZE >= agentJobsTotal
}
onClick={() => void loadAgentJobsPage(agentJobsPage + 1)}
>
下一页
</button>
</div>
)}
</>
)}
</section>
) : selectedCategory ? (
<section className="mindspace-section mindspace-assets-section">
<div className="mindspace-section-heading">
<div>
<button
type="button"
className="mindspace-inline-back"
onClick={closeCategory}
>
返回空间首页
</button>
<p className="mindspace-eyebrow">
{selectedCategory.code.toUpperCase()} WORKSPACE
</p>
<h2>{selectedCategory.name}</h2>
</div>
{['oa', 'public'].includes(selectedCategory.code) && (
<button
type="button"
className="mindspace-primary"
onClick={() => openUpload(selectedCategory)}
>
上传资料
</button>
)}
</div>
{selectedCategory.code === 'draft' ? (
pages.length > 0 ? (
<>
<div className="mindspace-asset-bulkbar">
<button
type="button"
onClick={toggleAllDraftPagesSelected}
disabled={bulkDeletingDraftPages}
>
{allDraftPagesSelected ? '取消全选' : '全选'}
</button>
<span>已选 {selectedDraftPageIds.length} 项</span>
{selectedDraftPageIds.length > 0 && (
<button
type="button"
onClick={() => setSelectedDraftPageIds([])}
disabled={bulkDeletingDraftPages}
>
清空
</button>
)}
<button
type="button"
className="mindspace-asset-bulk-delete"
onClick={() => void removeDraftPages()}
disabled={selectedDraftPageIds.length === 0 || bulkDeletingDraftPages}
>
{bulkDeletingDraftPages ? '删除中…' : '删除选中'}
</button>
</div>
<div className="mindspace-feed-grid">
{sortedPages.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
previewMode={previewMode}
onClick={() => showPage(page.id)}
checked={selectedDraftPageSet.has(page.id)}
onCheckChange={() => toggleDraftPageSelected(page.id)}
/>
))}
</div>
</>
) : (
<div className="mindspace-empty">
<h2>还没有页面草稿</h2>
<p>从聊天保存 AI 回答,或创建第一张页面。</p>
</div>
)
) : (
<>
{(() => {
const categoryPages = sortedPages.filter(
(p) => p.categoryCode === selectedCategory.code,
);
return categoryPages.length > 0 ? (
<div className="mindspace-feed-grid mindspace-category-pages">
{categoryPages.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
previewMode={previewMode}
onClick={() => showPage(page.id)}
actions={
page.status === 'published'
? [{ label: '发布到广场', onClick: (e) => { e.stopPropagation(); showPage(page.id, true); } }]
: undefined
}
/>
))}
</div>
) : null;
})()}
{assetsLoading && <div className="mindspace-state">正在加载资产…</div>}
{!assetsLoading && assets.length === 0 && (
<div className="mindspace-empty">
<h2>这里还没有内容</h2>
<p>上传第一份资料后,它会成为 AI 可以安全处理的资产。</p>
</div>
)}
{!assetsLoading && assets.length > 0 && (
<>
<div className="mindspace-asset-filters">
{(
[
['all', '全部'],
['images', '图片'],
['files', '资料'],
['pages', '页面'],
] as const
).map(([value, label]) => (
<button
type="button"
key={value}
className={assetFilter === value ? 'is-active' : undefined}
onClick={() => {
setAssetFilter(value);
setImagePage(0);
}}
>
{label}
{value === 'images' && imageAssets.length > 0
? ` (${imageAssets.length})`
: ''}
</button>
))}
</div>
<div className="mindspace-asset-bulkbar">
<button
type="button"
onClick={toggleVisibleAssetsSelected}
disabled={visibleSelectableAssets.length === 0 || bulkDeletingAssets}
>
{allVisibleAssetsSelected ? '取消当前列表' : '选择当前列表'}
</button>
<span>已选 {selectedAssetIds.length} 项</span>
{selectedAssetIds.length > 0 && (
<button
type="button"
onClick={() => setSelectedAssetIds([])}
disabled={bulkDeletingAssets}
>
清空
</button>
)}
<button
type="button"
className="mindspace-asset-bulk-delete"
onClick={() => void removeSelectedAssets()}
disabled={selectedAssetIds.length === 0 || bulkDeletingAssets}
>
{bulkDeletingAssets ? '删除中…' : '删除选中'}
</button>
</div>
{visibleImageAssets.length > 0 && (
<>
{assetFilter === 'all' && (
<div className="mindspace-section-subheading">
<h3>图片</h3>
<span>{visibleImageAssets.length} 张</span>
</div>
)}
{selectedCategory?.code === 'public' ? (
imageAssetsByDate.map(([dateKey, datedAssets]) => (
<div className="mindspace-image-date-group" key={dateKey}>
<div className="mindspace-section-subheading">
<h3>{dateKey === '其他' ? '其他图片' : dateKey}</h3>
<span>{datedAssets.length} 张</span>
</div>
<div className="mindspace-image-grid">
{datedAssets.map((asset) => (
<article className="mindspace-image-card" key={asset.id}>
<label className="mindspace-asset-select">
<input
type="checkbox"
checked={selectedAssetSet.has(asset.id)}
onChange={() => toggleAssetSelected(asset.id)}
/>
<span>选择</span>
</label>
<button
type="button"
className="mindspace-image-thumb"
onClick={() => setPreviewAssetId(asset.id)}
title={asset.displayName}
>
<img
src={buildAssetImageUrl(asset)}
alt={asset.displayName}
loading="lazy"
/>
</button>
<div className="mindspace-image-meta">
<span className="mindspace-image-name">{asset.displayName}</span>
<span className="mindspace-image-size">
{formatBytes(asset.sizeBytes)}
</span>
</div>
{renderAssetActions(asset)}
</article>
))}
</div>
</div>
))
) : (
<div
className={`mindspace-image-grid${imagePaginationEnabled ? ' is-compact' : ''}`}
>
{pagedImageAssets.map((asset) => (
<article
className={`mindspace-image-card${imagePaginationEnabled ? ' is-compact' : ''}`}
key={asset.id}
>
<label className="mindspace-asset-select">
<input
type="checkbox"
checked={selectedAssetSet.has(asset.id)}
onChange={() => toggleAssetSelected(asset.id)}
/>
<span>选择</span>
</label>
<button
type="button"
className="mindspace-image-thumb"
onClick={() => setPreviewAssetId(asset.id)}
title={asset.displayName}
>
<img
src={buildAssetImageUrl(asset)}
alt={asset.displayName}
loading="lazy"
/>
</button>
{!imagePaginationEnabled && (
<>
<div className="mindspace-image-meta">
<span className="mindspace-image-name">{asset.displayName}</span>
<span className="mindspace-image-size">
{formatBytes(asset.sizeBytes)}
</span>
</div>
{renderAssetActions(asset)}
</>
)}
</article>
))}
</div>
)}
{imagePaginationEnabled && (
<div className="mindspace-pagination mindspace-image-pagination">
<button
type="button"
disabled={safeImagePage <= 0}
onClick={() => setImagePage((page) => Math.max(0, page - 1))}
>
上一页
</button>
<span>
第 {safeImagePage + 1} / {imagePageCount} 页 · 共{' '}
{visibleImageAssets.length} 张
</span>
<button
type="button"
disabled={safeImagePage >= imagePageCount - 1}
onClick={() =>
setImagePage((page) => Math.min(imagePageCount - 1, page + 1))
}
>
下一页
</button>
</div>
)}
</>
)}
{visibleCardAssets.length > 0 && (
<>
{assetFilter === 'all' && visibleImageAssets.length > 0 && (
<div className="mindspace-section-subheading">
<h3>资料与页面</h3>
<span>{visibleCardAssets.length} 项</span>
</div>
)}
<div className="mindspace-item-grid">
{visibleCardAssets.map((asset) => (
<article
className={`mindspace-item-card${isWorkAsset(asset) ? ' is-work' : ' is-file'}`}
key={asset.id}
>
<label className="mindspace-asset-select is-card">
<input
type="checkbox"
checked={selectedAssetSet.has(asset.id)}
onChange={() => toggleAssetSelected(asset.id)}
/>
<span>选择</span>
</label>
<div className="mindspace-item-card-frame">
<div className="mindspace-item-card-head">
<span className="mindspace-item-badge">
{assetSourceLabel(asset)}
</span>
</div>
<button
type="button"
className="mindspace-item-card-main"
onClick={() => {
if (isWorkAsset(asset)) {
void openAssetEditor(asset);
return;
}
if (isPreviewableAsset(asset)) setPreviewAssetId(asset.id);
}}
>
{isWorkAsset(asset) ? (
<div className="mindspace-item-cover">
{isPreviewableAsset(asset) ? (
<WorkCover
thumbnailUrl={`/api/mindspace/v1/assets/${asset.id}/thumbnail?v=${asset.updatedAt}`}
previewAsset={asset}
fallbackLabel={assetIcon(asset)}
title={asset.displayName}
/>
) : (
<span>{assetIcon(asset)}</span>
)}
</div>
) : (
<div className="mindspace-item-icon">{assetIcon(asset)}</div>
)}
<div className="mindspace-item-copy">
<h3>{asset.displayName}</h3>
<p>
{formatBytes(asset.sizeBytes)} ·{' '}
{new Date(asset.updatedAt).toLocaleDateString()}
</p>
</div>
</button>
{renderAssetActions(asset)}
</div>
</article>
))}
</div>
</>
)}
{visibleImageAssets.length === 0 && visibleCardAssets.length === 0 && (
<div className="mindspace-empty">
<h2>该分类下暂无内容</h2>
<p>试试切换其他筛选,或上传新资料。</p>
</div>
)}
</>
)}
{previewAsset && previewAssetFrame && (
<div className="mindspace-asset-preview-backdrop" role="presentation">
<section className="mindspace-asset-preview-panel" role="dialog" aria-modal="true">
<div className="mindspace-asset-preview-bar">
<strong>文件预览</strong>
<button type="button" onClick={() => setPreviewAssetId(null)}>
关闭
</button>
</div>
<iframe
title="资产预览"
src={previewAssetFrame.src}
{...(previewAssetFrame.sandbox ? { sandbox: previewAssetFrame.sandbox } : {})}
/>
</section>
</div>
)}
</>
)}
</section>
) : (
<section className="mindspace-section">
<div className="mindspace-section-heading">
<div>
<p className="mindspace-eyebrow">QUICK ACCESS</p>
<h2>空间分类</h2>
</div>
<span>先从成果开始,目录稍后再整理</span>
</div>
<div className="mindspace-grid">
{space.categories
.filter((category) => ['oa', 'public'].includes(category.code))
.map((category) => (
<article
className={`mindspace-card mindspace-card-${category.code}`}
key={category.id}
>
<div className="mindspace-card-top">
<span className="mindspace-card-code">{category.code.toUpperCase()}</span>
<span>{category.itemCount} 项</span>
</div>
<h3>{category.name}</h3>
<p>{CATEGORY_DESCRIPTIONS[category.code]}</p>
{CATEGORY_ACTIONS[category.code] && (
<button type="button" onClick={() => void openCategory(category)}>
{CATEGORY_ACTIONS[category.code]}
</button>
)}
</article>
))}
<div className="mindspace-grid-secondary">
{space.categories
.filter((category) => category.code === 'draft')
.map((category) => (
<article
className={`mindspace-card mindspace-card-${category.code}`}
key={category.id}
>
<div className="mindspace-card-top">
<span className="mindspace-card-code">{category.code.toUpperCase()}</span>
<span>{category.itemCount} 项</span>
</div>
<h3>{category.name}</h3>
<p>{CATEGORY_DESCRIPTIONS[category.code]}</p>
{CATEGORY_ACTIONS[category.code] && (
<button type="button" onClick={() => void openCategory(category)}>
{CATEGORY_ACTIONS[category.code]}
</button>
)}
</article>
))}
{space.categories
.filter((category) => category.code === 'archive')
.map((category) => (
<article
className={`mindspace-card mindspace-card-${category.code}`}
key={category.id}
>
<div className="mindspace-card-top">
<span className="mindspace-card-code">{category.code.toUpperCase()}</span>
<span>{category.itemCount} 项</span>
</div>
<h3>{category.name}</h3>
<p>{CATEGORY_DESCRIPTIONS[category.code]}</p>
</article>
))}
</div>
</div>
</section>
)}
{!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && (
<section className="mindspace-section mindspace-recent-work">
<div className="mindspace-section-heading">
<div>
<p className="mindspace-eyebrow">RECENT WORK</p>
<h2>{pages.length > 0 ? '最近页面' : '这里会出现你的 AI 成果'}</h2>
</div>
{recentPagesTotal > 0 && (
<button
type="button"
className="mindspace-section-link"
onClick={openAllPagesPanel}
>
查看全部
</button>
)}
</div>
{pages.length > 0 ? (
<>
<div className="mindspace-feed-grid mindspace-recent-work-grid">
{recentPagesPreview.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
previewMode={previewMode}
onClick={() => showPage(page.id)}
/>
))}
</div>
</>
) : (
<div className="mindspace-recent-work-empty">
<p>上传资料或从聊天保存页面后,报告、图片和作品会以卡片形式出现在这里。</p>
</div>
)}
</section>
)}
</>
)}
</main>
{uploadOpen && selectedCategory && (
<div
className="mindspace-upload-backdrop"
role="presentation"
onClick={(event) => {
if (event.target === event.currentTarget) closeUpload();
}}
>
<section
className="mindspace-upload-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="mindspace-upload-title"
>
<div className="mindspace-upload-dialog-bar">
<div>
<p className="mindspace-eyebrow">UPLOAD</p>
<h3 id="mindspace-upload-title">{uploadDialogTitle(selectedCategory)}</h3>
</div>
<button
type="button"
className="mindspace-upload-dialog-close"
onClick={closeUpload}
disabled={uploading}
aria-label="关闭"
>
关闭
</button>
</div>
<p className="mindspace-upload-dialog-desc">
支持 {UPLOAD_FILE_TYPE_LABEL},文档最大 {formatBytes(maxUploadFileBytes)},图片最大{' '}
{formatBytes(Math.min(maxUploadFileBytes, MINDSPACE_IMAGE_UPLOAD_MAX_BYTES))}。
</p>
<input
type="file"
accept={UPLOAD_ACCEPT}
onChange={handleUploadFileChange}
/>
{uploadError && <p className="mindspace-upload-error">{uploadError}</p>}
{selectedFile && (
<div className="mindspace-upload-file">
<span>{selectedFile.name}</span>
<strong>{formatBytes(selectedFile.size)}</strong>
</div>
)}
<div className="mindspace-upload-actions">
<button
type="button"
className="mindspace-secondary"
onClick={closeUpload}
disabled={uploading}
>
取消
</button>
<button
type="button"
className="mindspace-primary"
onClick={() => void submitUpload()}
disabled={!selectedFile || uploading}
>
{uploading ? '上传中…' : '开始上传'}
</button>
</div>
</section>
</div>
)}
{agentAsset && (
<div
className="mindspace-upload-backdrop"
role="presentation"
onClick={(event) => {
if (event.target === event.currentTarget) closeAgentGenerator();
}}
>
<section
className="mindspace-upload-dialog mindspace-agent-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="mindspace-agent-title"
>
<div className="mindspace-upload-dialog-bar">
<div>
<p className="mindspace-eyebrow">AGENT JOB</p>
<h3 id="mindspace-agent-title">基于资料生成页面</h3>
</div>
<button
type="button"
className="mindspace-upload-dialog-close"
onClick={closeAgentGenerator}
disabled={agentSubmitting}
aria-label="关闭"
>
关闭
</button>
</div>
<div className="mindspace-agent-asset">
<strong>{agentAsset.displayName}</strong>
<span>{formatBytes(agentAsset.sizeBytes)} · {agentAsset.mimeType}</span>
</div>
<p className="mindspace-upload-dialog-desc">
当前会读取这份资料,为你生成一个新的草稿页。适合摘要、周报、说明页和知识整理。
</p>
<label className="mindspace-agent-field">
<span>生成要求</span>
<textarea
value={agentInstruction}
onChange={(event) => setAgentInstruction(event.target.value)}
placeholder="描述你希望页面如何组织、强调什么、输出成什么风格"
rows={7}
disabled={agentSubmitting || Boolean(agentJob && ['running', 'completed'].includes(agentJob.status))}
/>
</label>
{agentJob && (
<div className={`mindspace-agent-status is-${agentJob.status}`}>
<strong>
{agentJob.status === 'completed'
? '任务已完成'
: agentJob.status === 'failed'
? '任务失败'
: agentJob.status === 'running'
? '正在生成'
: '任务已排队'}
</strong>
<span>
{agentJob.progress.message ??
agentJob.errorMessage ??
`当前阶段:${agentJob.progress.stage}`}
</span>
</div>
)}
<div className="mindspace-upload-actions">
<button
type="button"
className="mindspace-secondary"
onClick={closeAgentGenerator}
disabled={agentSubmitting}
>
取消
</button>
<button
type="button"
className="mindspace-primary"
onClick={() => void submitAgentGenerator()}
disabled={
!agentInstruction.trim() ||
agentSubmitting ||
Boolean(agentJob && ['queued', 'running', 'completed'].includes(agentJob.status))
}
>
{agentSubmitting
? '提交中…'
: agentJob?.status === 'running'
? '生成中…'
: agentJob?.status === 'completed'
? '已生成'
: '开始生成'}
</button>
</div>
</section>
</div>
)}
{sharePayload && (
<ShareSheet payload={sharePayload} onClose={() => setSharePayload(null)} />
)}
{previewPageTarget ? (
<div className="mindspace-asset-preview-backdrop" role="presentation">
<section className="mindspace-asset-preview-panel" role="dialog" aria-modal="true">
<div className="mindspace-asset-preview-bar">
<strong>{previewPageTarget.title}</strong>
<button type="button" onClick={() => setPreviewPageTarget(null)}>
关闭
</button>
</div>
<PagePreviewFrame
title={previewPageTarget.title}
src={`/api/mindspace/v1/pages/${previewPageTarget.id}/preview?v=${previewPageTarget.versionNo ?? previewPageTarget.updatedAt}`}
/>
</section>
</div>
) : null}
<MindSpaceModal
open={spacePurchaseOpen && Boolean(space)}
onClose={() => setSpacePurchaseOpen(false)}
title="购买空间"
eyebrow="SPACE UPGRADE"
className="mindspace-space-purchase-dialog"
disableClose={spacePurchasePending}
>
{space && (
<>
<p className="mindspace-upload-dialog-desc">
仅支持使用余额支付。购买成功后会立即同步后台空间配额,并在当前页面实时生效。
</p>
<div className="mindspace-space-purchase-summary">
<div>
<span>当前余额</span>
<strong>{formatYuan(currentBalanceCents)}</strong>
</div>
<div>
<span>当前剩余空间</span>
<strong>{formatBytes(space.quota.availableBytes)}</strong>
</div>
<div>
<span>购买价格</span>
<strong>{purchaseMbValue > 0 ? `${formatYuan(purchaseCostCents)} / ${purchaseMbValue} MB` : '请选择容量'}</strong>
</div>
</div>
<div className="mindspace-hero-purchase">
<div className="mindspace-hero-purchase-row">
{SPACE_PURCHASE_PRESETS_MB.map((sizeMb) => (
<button
key={sizeMb}
type="button"
className={`mindspace-hero-purchase-chip${purchaseMbValue === sizeMb ? ' is-active' : ''}`}
disabled={spacePurchasePending}
onClick={() => {
setSpacePurchaseMb(String(sizeMb));
setSpacePurchaseMessage(null);
}}
>
{sizeMb} MB
</button>
))}
</div>
<div className="mindspace-hero-purchase-row">
<input
className="mindspace-hero-purchase-input"
type="number"
min="1"
step="1"
value={spacePurchaseMb}
onChange={(event) => {
setSpacePurchaseMb(event.target.value);
setSpacePurchaseMessage(null);
}}
placeholder="自定义 MB"
/>
</div>
<p className="mindspace-hero-purchase-meta">
价格为每 1 MB = ¥2,购买后直接增加总容量,不展示在首页主区。
</p>
{spacePurchaseMessage && (
<p className="mindspace-hero-purchase-message">{spacePurchaseMessage}</p>
)}
</div>
{!canPurchaseSpace && purchaseMbValue > 0 && (
<div className="mindspace-space-purchase-recharge">
<span>当前余额不足以支付这次扩容。</span>
<button
type="button"
className="mindspace-secondary"
onClick={() => {
setSpacePurchaseOpen(false);
openRecharge(false);
}}
>
去余额充值
</button>
</div>
)}
<div className="mindspace-page-editor-actions">
<button
type="button"
onClick={() => setSpacePurchaseOpen(false)}
disabled={spacePurchasePending}
>
取消
</button>
<button
type="button"
className="mindspace-primary"
onClick={() => void submitSpacePurchase()}
disabled={spacePurchasePending || purchaseMbValue <= 0 || !canPurchaseSpace}
>
{spacePurchasePending ? '支付中…' : '确认购买'}
</button>
</div>
</>
)}
</MindSpaceModal>
<MindSpaceModal
open={Boolean(pageDeleteTarget)}
onClose={closePageDeleteDialog}
title="确认删除页面"
eyebrow="DELETE PAGE"
className="mindspace-delete-dialog"
disableClose={pageDeleteLoading || Boolean(deletingPageId)}
>
{pageDeleteLoading && <div className="mindspace-state">正在分析连带内容…</div>}
{pageDeletePreview && (
<>
<p className="mindspace-upload-dialog-desc">
删除「{truncateLabel(pageDeleteTarget?.title ?? '未命名页面', 48)}」后无法恢复。以下相关内容将一并处理:
</p>
<ul className="mindspace-delete-summary">
{pageDeletePreview.summaryLines.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
{pageDeletePreview.onlinePublication && (
<a
className="mindspace-delete-public-url"
href={resolvePublicPageUrl(pageDeletePreview.onlinePublication.publicUrl)}
target="_blank"
rel="noreferrer"
>
{resolvePublicPageUrl(pageDeletePreview.onlinePublication.publicUrl)}
</a>
)}
<MindSpaceDeletePlazaOption
plazaPosts={pageDeletePreview.plazaPosts ?? []}
removeFromPlaza={pageDeleteRemoveFromPlaza}
onRemoveFromPlazaChange={setPageDeleteRemoveFromPlaza}
/>
<div className="mindspace-page-editor-actions">
<button type="button" onClick={closePageDeleteDialog} disabled={Boolean(deletingPageId)}>
取消
</button>
<button
type="button"
className="mindspace-primary"
onClick={() => void confirmDeletePage()}
disabled={Boolean(deletingPageId)}
>
{deletingPageId ? '删除中…' : '确认删除'}
</button>
</div>
</>
)}
</MindSpaceModal>
<MindSpaceModal
open={bulkPageDeleteOpen}
onClose={closeBulkPageDeleteDialog}
title="确认批量删除页面"
eyebrow="DELETE PAGES"
className="mindspace-delete-dialog"
disableClose={
bulkPageDeleteLoading || bulkDeletingAllPages || bulkDeletingDraftPages
}
>
{bulkPageDeleteLoading && <div className="mindspace-state">正在分析连带内容…</div>}
{bulkPageDeletePreviews && (
<>
<p className="mindspace-upload-dialog-desc">
确定删除选中的 {bulkPageDeletePreviews.length} 个页面吗?删除后无法恢复,关联附件与在线发布也会一并移除。
</p>
<ul className="mindspace-delete-summary">
{bulkPageDeletePreviews.map((preview) => (
<li key={preview.page.id}>
{preview.page.title}
{preview.onlinePublication ? '(含在线公开链接)' : ''}
{(preview.plazaPosts?.length ?? 0) > 0 ? '(含广场帖子)' : ''}
</li>
))}
</ul>
<MindSpaceDeletePlazaOption
plazaPosts={bulkDeletePlazaPosts}
removeFromPlaza={bulkPageDeleteRemoveFromPlaza}
onRemoveFromPlazaChange={setBulkPageDeleteRemoveFromPlaza}
/>
<div className="mindspace-page-editor-actions">
<button
type="button"
onClick={closeBulkPageDeleteDialog}
disabled={bulkDeletingAllPages || bulkDeletingDraftPages}
>
取消
</button>
<button
type="button"
className="mindspace-primary"
onClick={() => void confirmBulkPageDelete()}
disabled={bulkDeletingAllPages || bulkDeletingDraftPages}
>
{bulkDeletingAllPages || bulkDeletingDraftPages ? '删除中…' : '确认删除'}
</button>
</div>
</>
)}
</MindSpaceModal>
<MindSpaceModal
open={Boolean(assetDeleteDialog)}
onClose={() => setAssetDeleteDialog(null)}
title={assetDeleteDialog?.references.length ? '无法删除资产' : '删除失败'}
eyebrow={assetDeleteDialog?.references.length ? 'ASSET IN USE' : 'DELETE FAILED'}
className="mindspace-delete-dialog"
>
{assetDeleteDialog && (
<>
<p className="mindspace-upload-dialog-desc">{assetDeleteDialog.message}</p>
{assetDeleteDialog.references.length > 0 && (
<div className="mindspace-asset-delete-blockers">
{assetDeleteDialog.references.map((reference) => (
<button
type="button"
key={reference.id}
onClick={() => {
setAssetDeleteDialog(null);
showPage(reference.id);
}}
>
打开页面「{truncateLabel(reference.title)}」
{reference.publishedOnline ? '(删除时会自动下线)' : ''}
</button>
))}
</div>
)}
<div className="mindspace-page-editor-actions">
<button type="button" onClick={() => setAssetDeleteDialog(null)}>
知道了
</button>
</div>
</>
)}
</MindSpaceModal>
{!previewMode && mindspaceChatContext && !pageFullscreenPreviewOpen && (
<MindSpaceSpaceChat
context={mindspaceChatContext}
user={user}
onOpenFullChat={onBack}
onPageSaved={(result) => {
if (result.kind === 'page' && result.pageId) {
void refreshPagesSilently();
if (result.pageId === selectedPageId) {
setPageRefreshTrigger((value) => value + 1);
} else {
showPage(result.pageId);
}
}
}}
/>
)}
</div>
);
}