71e536eceb
勾选单个文件时 Agent 获知上下文并主动问候;打开悬浮窗使用新会话。 统一弹窗浅色主题、固定高度与输入区布局,支持点击外部收起。 Co-authored-by: Cursor <cursoragent@cursor.com>
3507 lines
132 KiB
TypeScript
3507 lines
132 KiB
TypeScript
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>
|
||
);
|
||
}
|