Files
memind/src/components/MindSpaceView.tsx
T
John 2e14873f2d Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 15:04:43 -07:00

1688 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState, type MouseEvent } from 'react';
import { useLocation } from 'react-router-dom';
import {
cancelMindSpaceAgentJob,
createMindSpaceAgentJob,
createMindSpacePage,
createMindSpacePageFromAsset,
deleteMindSpaceAsset,
deleteMindSpacePage,
getMindSpaceAgentJob,
getMindSpace,
getMindSpacePageDeletePreview,
listMindSpaceAgentJobs,
listMindSpaceAssets,
listMindSpaceCleanupItems,
listMindSpacePages,
retryMindSpaceAgentJob,
runMindSpaceAgentJob,
runMindSpaceCleanup,
uploadMindSpaceAsset,
ApiError,
} from '../api/client';
import type {
MindSpace,
MindSpaceAsset,
MindSpaceAgentJob,
MindSpaceCategory,
MindSpaceCleanupItem,
MindSpacePage,
MindSpacePageDeletePreview,
MindSpaceSaveCategory,
PortalUser,
} from '../types';
import { buildMindSpaceChatContext } from '../utils/mindspaceChatContext';
import { MindSpaceSpaceChat } from './MindSpaceSpaceChat';
import {
assetIcon,
assetSourceLabel,
buildAssetPreviewFrameConfig,
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 { ShareSheet } from './ShareSheet';
import { WorkCover } from './WorkCover';
import { buildAssetSharePayload, type SharePayload } from '../utils/shareChannels';
import { resolvePublicPageUrl, resolvePlazaHomeUrl } from '../utils/publicUrl';
import { PagePreviewFrame } from './PagePreviewFrame';
const CATEGORY_DESCRIPTIONS: Record<MindSpaceCategory['code'], string> = {
oa: '上传文档、表格和资料,让 AI 生成报告与页面',
private: '保存敏感资料,发布前必须完成风险检查与脱敏',
public: '管理可公开使用的页面、图片和静态资源',
draft: '继续编辑尚未发布或等待安全检查的页面',
archive: '保存不再活跃但仍需要保留的内容',
};
const CATEGORY_ACTIONS: Partial<Record<MindSpaceCategory['code'], string>> = {
oa: '进入工作区',
private: '进入私人区',
public: '查看发布',
draft: '继续创作',
};
const AGENT_JOBS_PAGE_SIZE = 10;
const JOB_STATUS_LABELS: Record<MindSpaceAgentJob['status'], string> = {
queued: '排队中',
running: '执行中',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
timed_out: '超时',
};
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' | '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;
};
export function MindSpaceView({
user,
previewMode = false,
initialPageId,
initialCategoryCode,
onBack,
onLogout,
routeSync,
}: {
user: PortalUser;
previewMode?: boolean;
initialPageId?: string | null;
initialCategoryCode?: MindSpaceSaveCategory | null;
onBack: () => void;
onLogout: () => 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 [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
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 [previewAssetId, setPreviewAssetId] = 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 [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 [deletingPageId, setDeletingPageId] = useState<string | null>(null);
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 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]);
useEffect(() => {
if (!selectedPageId) {
setPageLiveContext(null);
}
}, [selectedPageId]);
const mindspaceChatContext = useMemo(() => {
if (!space) return null;
return buildMindSpaceChatContext({
space,
ownerUsername: user.username,
selectedCategory,
selectedPageId,
pages,
pageLive: pageLiveContext,
assets,
focusedAsset: agentAsset,
route: `${location.pathname}${location.search}`,
});
}, [
space,
user.username,
selectedCategory,
selectedPageId,
pages,
pageLiveContext,
assets,
agentAsset,
location.pathname,
location.search,
]);
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 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);
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);
};
const confirmDeletePage = async () => {
if (!pageDeleteTarget) return;
setDeletingPageId(pageDeleteTarget.id);
setError(null);
try {
await deleteMindSpacePage(pageDeleteTarget.id);
if (selectedPageId === pageDeleteTarget.id) {
closePage();
}
setPageDeleteTarget(null);
setPageDeletePreview(null);
await load();
} 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 openPageDeleteDialog(page);
},
},
];
const closePage = () => {
setSelectedPageId(null);
if (selectedCategory) {
routeSync?.pushCategory(selectedCategory.code);
} else {
routeSync?.pushHome();
}
};
const closeCategory = () => {
setSelectedCategory(null);
setUploadOpen(false);
setPendingDeleteId(null);
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 {
await runMindSpaceCleanup(selectedCleanupIds);
setCleanupOpen(false);
setCleanupItems([]);
setSelectedCleanupIds([]);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '清理失败');
} finally {
setCleanupRunning(false);
}
};
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);
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, nextPages] = await Promise.all([
getMindSpace(),
listMindSpacePages(),
]);
setSpace(nextSpace);
setPages(nextPages);
await refreshAgentJobsSummary();
} catch (err) {
setError(err instanceof Error ? err.message : '空间加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
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);
setSelectedCategory(category);
setSelectedPageId(null);
setNewPageOpen(false);
setUploadOpen(false);
setPendingDeleteId(null);
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));
} 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);
setUploadOpen(true);
};
const closeUpload = () => {
if (uploading) return;
setUploadOpen(false);
setSelectedFile(null);
};
const uploadDialogTitle = (category: MindSpaceCategory) =>
category.code === 'oa' ? '上传资料' : `上传到 ${category.name}`;
const submitUpload = async () => {
if (!selectedCategory || !selectedFile) return;
if (previewMode) {
setError(previewBlocked().message);
return;
}
setUploading(true);
setError(null);
try {
await uploadMindSpaceAsset(selectedCategory.id, selectedFile);
setUploadOpen(false);
setSelectedFile(null);
await load();
await openCategory(selectedCategory);
} catch (err) {
setError(err instanceof Error ? err.message : '上传失败');
} finally {
setUploading(false);
}
};
const removeAsset = async (assetId: string) => {
if (!selectedCategory) return;
if (previewMode) {
setError(previewBlocked().message);
return;
}
setError(null);
setAssetDeleteDialog(null);
try {
await deleteMindSpaceAsset(assetId);
setPendingDeleteId(null);
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 usedPercent = space
? Math.min(
100,
((space.quota.usedBytes + space.quota.reservedBytes) / space.quota.quotaBytes) * 100,
)
: 0;
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 filteredAssets = assets.filter((asset) => {
if (assetFilter === 'files') return !isWorkAsset(asset);
if (assetFilter === 'pages') return isWorkAsset(asset);
return true;
});
const previewAsset = useMemo(
() => assets.find((asset) => asset.id === previewAssetId) ?? null,
[assets, previewAssetId],
);
const previewAssetFrame = previewAsset ? buildAssetPreviewFrameConfig(previewAsset) : null;
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>
);
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>
)}
<span>{user.displayName}</span>
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
登出
</button>
</div>
</header>
<main className="mindspace-content">
<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);
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>免费版空间</span>
<strong>
{formatBytes(space.quota.usedBytes)} / {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">
单文件上限 {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>当前没有可清理的临时文件。</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>
{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-section mindspace-state">
<button
type="button"
className="mindspace-inline-back"
onClick={closePage}
>
返回空间首页
</button>
<p>预览模式不加载页面详情,返回后可继续调整首页布局。</p>
</section>
) : (
<MindSpacePageDetail
pageId={selectedPageId}
onBack={closePage}
onSaved={load}
onContextUpdate={setPageLiveContext}
autoOpenPlaza={selectedPageAutoPlaza}
/>
)
) : 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>
) : 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', 'private', 'public'].includes(selectedCategory.code) && (
<button
type="button"
className="mindspace-primary"
onClick={() => openUpload(selectedCategory)}
>
上传资料
</button>
)}
</div>
{selectedCategory.code === 'private' && (
<div className="mindspace-security-note">
私人区默认不可公开。后续生成公开内容时,系统会要求风险扫描和脱敏副本。
</div>
)}
{selectedCategory.code === 'draft' ? (
pages.length > 0 ? (
<div className="mindspace-feed-grid">
{pages.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
previewMode={previewMode}
onClick={() => showPage(page.id)}
/>
))}
</div>
) : (
<div className="mindspace-empty">
<h2>还没有页面草稿</h2>
<p>从聊天保存 AI 回答,或创建第一张页面。</p>
</div>
)
) : (
<>
{(() => {
const categoryPages = pages.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', '全部'],
['files', '资料'],
['pages', '页面'],
] as const
).map(([value, label]) => (
<button
type="button"
key={value}
className={assetFilter === value ? 'is-active' : undefined}
onClick={() => setAssetFilter(value)}
>
{label}
</button>
))}
</div>
<div className="mindspace-item-grid">
{filteredAssets.map((asset) => (
<article
className={`mindspace-item-card${isWorkAsset(asset) ? ' is-work' : ' is-file'}`}
key={asset.id}
>
<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">
<span className="mindspace-item-badge">{assetSourceLabel(asset)}</span>
<h3>{asset.displayName}</h3>
<p>
{formatBytes(asset.sizeBytes)} ·{' '}
{new Date(asset.updatedAt).toLocaleDateString()}
</p>
</div>
</button>
<div className="mindspace-item-actions">
{isPreviewableAsset(asset) && (
<button type="button" onClick={() => setPreviewAssetId(asset.id)}>
预览
</button>
)}
{isWorkAsset(asset) && (
<button
type="button"
onClick={() => void openAssetEditor(asset)}
disabled={editingAssetId === asset.id}
>
{editingAssetId === asset.id ? '打开中…' : '编辑'}
</button>
)}
{asset.sourcePageId && (
<button
type="button"
onClick={() => showPage(asset.sourcePageId!, true)}
>
发布到广场
</button>
)}
{canGenerateWithAgent(asset) && (
<button type="button" onClick={() => openAgentGenerator(asset)}>
AI 生成页面
</button>
)}
{asset.status === 'ready' && asset.scanStatus !== 'blocked' ? (
<button
type="button"
className="mindspace-asset-share"
onClick={() =>
setSharePayload(
buildAssetSharePayload(asset, selectedCategory?.code),
)
}
>
分享
</button>
) : (
<span className="mindspace-asset-blocked">暂不可用</span>
)}
{pendingDeleteId === asset.id ? (
<>
<button
type="button"
className="mindspace-delete-confirm"
onClick={() => void removeAsset(asset.id)}
>
确认删除
</button>
<button type="button" onClick={() => setPendingDeleteId(null)}>
取消
</button>
</>
) : (
<button type="button" onClick={() => setPendingDeleteId(asset.id)}>
删除
</button>
)}
</div>
</article>
))}
</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', 'private', '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>
))}
<button
type="button"
className="mindspace-agent-jobs-module"
onClick={openAgentJobsPanel}
>
<span className="mindspace-eyebrow">AGENT JOBS</span>
<strong>{agentJobsTotal} 条任务</strong>
<span>查看 AI 生成记录</span>
</button>
</div>
</div>
</section>
)}
{!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && (
<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>
{pages.length > 0 && <span>{pages.length} 个页面</span>}
</div>
{pages.length > 0 ? (
<div className="mindspace-feed-grid">
{pages.slice(0, 6).map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
previewMode={previewMode}
onClick={() => showPage(page.id)}
actions={buildPageCardActions(page)}
/>
))}
</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">
支持 Word、Excel、PDF、PPT、图片、Markdown、CSV 和 TXT,单文件最大 2 MB。
</p>
<input
type="file"
accept=".doc,.docx,.xls,.xlsx,.pdf,.ppt,.pptx,.png,.jpg,.jpeg,.webp,.md,.csv,.txt"
onChange={(event) => setSelectedFile(event.target.files?.[0] ?? null)}
/>
{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={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>
)}
<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={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 && (
<MindSpaceSpaceChat
context={mindspaceChatContext}
user={user}
onOpenFullChat={onBack}
onPageSaved={(result) => {
if (result.kind === 'page' && result.pageId) {
void load().then(() => showPage(result.pageId!));
}
}}
/>
)}
</div>
);
}