2e14873f2d
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
1688 lines
62 KiB
TypeScript
1688 lines
62 KiB
TypeScript
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>
|
||
);
|
||
}
|