feat(doc-preview): 上传文档多格式在线预览 + IndexedDB 持久化恢复
## 新增功能 - 支持用户上传的 Word(.docx)、PDF、Excel(.xlsx/.csv)、PPT(.pptx)、 图像(png/jpg/svg/webp等)、纯文本/代码文件的在线高保真预览 - 预览与受控工作流文档严格隔离:上传文档走 FilePreviewPanel, 工作流/对话入库文档继续走 BlockRenderer,互不干扰 ## 核心 Bug 修复 - 修复页面刷新后上传文件无法预览/下载的根本问题 - 根因:File 对象无法 JSON 序列化,刷新后内存引用丢失 - 方案:新增 fileStore.ts,使用 IndexedDB 持久化文件二进制内容 - App 启动时自动 restoreFileRawToDeliverables() 从 IndexedDB 恢复 fileRaw - 上传时 saveFile() 同步写入 IndexedDB(fire-and-forget) ## 下载按钮修复 - 优先从内存 File 对象动态创建 blob URL,避免依赖已 revoke 的旧 URL - 文件内容不可用时按钮禁用并显示 tooltip 说明 ## 存储层改进 - saveDeliverables() 序列化前剔除 fileRaw(不可持久化)和 blob: URL 避免 localStorage 保存损坏的空对象 ## 新增文件 - web/src/data/fileStore.ts — IndexedDB 封装(saveFile/getFile/restore) - web/src/components/doc/preview/FilePreviewPanel.tsx — 预览面板主组件 - web/src/components/doc/preview/renderers/DocxViewer.tsx — Word 预览 - web/src/components/doc/preview/renderers/PdfViewer.tsx — PDF 预览 - web/src/components/doc/preview/renderers/ExcelViewer.tsx — Excel 预览 - web/src/components/doc/preview/renderers/PptViewer.tsx — PPT 预览 - web/src/components/doc/preview/renderers/ImageViewer.tsx — 图像预览 - web/src/components/doc/preview/renderers/TextViewer.tsx — 文本预览 - web/src/tests/file-preview.test.ts — 预览功能单元测试
This commit is contained in:
Generated
+10
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.14.11",
|
||||
"docx": "^9.7.1",
|
||||
"docx-preview": "^0.4.0",
|
||||
"katex": "^0.18.7",
|
||||
"lucide-react": "^1.16.0",
|
||||
"mathml2omml": "^0.5.0",
|
||||
@@ -2467,6 +2468,15 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/docx-preview": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.4.0.tgz",
|
||||
"integrity": "sha512-OdKtE/uj3M4RfGarLkGjahUzRg8/kBp0Sraj1r1NAY1tp/sTpHOBqDrzVf9onMBt9vxP6SdQ6bpLCUCsFwjgcA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jszip": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/@types/node": {
|
||||
"version": "25.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.14.11",
|
||||
"docx": "^9.7.1",
|
||||
"docx-preview": "^0.4.0",
|
||||
"katex": "^0.18.7",
|
||||
"lucide-react": "^1.16.0",
|
||||
"mathml2omml": "^0.5.0",
|
||||
|
||||
+329
-56
@@ -17,7 +17,7 @@ import {
|
||||
unshareFreeformDoc,
|
||||
deleteFreeformDoc,
|
||||
} from './api/freeformDoc';
|
||||
import { fetchProjectLineage } from './api/projectWorkflow';
|
||||
import { fetchProjectLineage, getProject } from './api/projectWorkflow';
|
||||
import { DocumentLineage } from './types/projectWorkflow';
|
||||
import { WorkflowPrerequisiteBanner } from './components/workflow/WorkflowPrerequisiteBanner';
|
||||
import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice';
|
||||
@@ -31,6 +31,7 @@ import { AdminSettingsModal } from './components/admin/AdminSettingsModal';
|
||||
import { DocVisibilityModal } from './components/doc/DocVisibilityModal';
|
||||
import { CreateProjectModal } from './components/project/CreateProjectModal';
|
||||
import { ProjectMembersModal } from './components/project/ProjectMembersModal';
|
||||
import { FilePreviewPanel } from './components/doc/preview/FilePreviewPanel';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { LoginView } from './components/auth/LoginView';
|
||||
import { FirstLoginPasswordModal } from './components/auth/FirstLoginPasswordModal';
|
||||
@@ -88,7 +89,11 @@ import {
|
||||
resolveCardInMessages,
|
||||
cancelCardInMessages,
|
||||
} from './engine/cardActionEngine';
|
||||
import { createProjectTemplateDeliverables } from './engine/templateFactory';
|
||||
import {
|
||||
createProjectTemplateDeliverables,
|
||||
getTemplateDefinitionByTaskKey,
|
||||
buildTemplateASTForProject,
|
||||
} from './engine/templateFactory';
|
||||
import {
|
||||
loadSessions,
|
||||
saveSessions,
|
||||
@@ -122,6 +127,7 @@ import { useUnreadMarks } from './hooks/useUnreadMarks';
|
||||
import { CategoryDock } from './components/chat/CategoryDock';
|
||||
import { fetchChatCategories, fetchChatDates } from './api/chatWindow';
|
||||
import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench';
|
||||
import { saveFile, restoreFileRawToDeliverables } from './data/fileStore';
|
||||
|
||||
export const INITIAL_DELIVERABLES: DeliverableItem[] = [
|
||||
{
|
||||
@@ -485,11 +491,25 @@ export default function App() {
|
||||
loadDeliverables(ALL_INITIAL_DELIVERABLES)
|
||||
);
|
||||
|
||||
// 自动持久化 deliverables
|
||||
// 自动持久化 deliverables(序列化时剔除不可持久化的 File 对象,见 storage.ts)
|
||||
useEffect(() => {
|
||||
saveDeliverables(deliverables);
|
||||
}, [deliverables]);
|
||||
|
||||
// ✅ 启动时从 IndexedDB 恢复上传文件的 fileRaw(解决页面刷新后 File 对象丢失问题)
|
||||
// 仅在组件 mount 时执行一次;恢复成功后对 deliverables 做最小化 patch。
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
restoreFileRawToDeliverables(deliverables).then((restored) => {
|
||||
if (!cancelled && restored !== deliverables) {
|
||||
// 只有真正有内容被恢复时才更新 state(避免无意义的 re-render)
|
||||
setDeliverables(restored);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 只在 mount 时运行一次
|
||||
|
||||
const [activeCategory, setActiveCategory] = useState<DeliverableCategory>('workflow');
|
||||
|
||||
// 项目与成员体系状态 (支持持久化)
|
||||
@@ -541,8 +561,13 @@ export default function App() {
|
||||
|
||||
const [activeDocId, setActiveDocId] = useState<string>(() => {
|
||||
const activeSess = sessions.find((s) => s.sessionId === activeSessionId);
|
||||
if (activeSess?.projectId === 5201) return 'doc-neuropulse-feasibility_reg';
|
||||
if (activeSess?.projectId === 3) return 'doc-cusa-feasibility-reg';
|
||||
const targetProjId = activeSess ? activeSess.projectId : currentProjectId;
|
||||
const projDocs = deliverables.filter((d) => d.projectId === targetProjId);
|
||||
if (projDocs.length > 0) {
|
||||
return projDocs[0].docId;
|
||||
}
|
||||
if (targetProjId === 5201) return 'doc-neuropulse-feasibility_reg';
|
||||
if (targetProjId === 3) return 'doc-cusa-feasibility-reg';
|
||||
return deliverables[0]?.docId || 'doc-001';
|
||||
});
|
||||
const [deckDrawerOpen, setDeckDrawerOpen] = useState(false);
|
||||
@@ -612,6 +637,25 @@ export default function App() {
|
||||
creatorId: currentActiveSession?.userId || currentUser.id || 2,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProjectId) {
|
||||
getProject(currentProjectId)
|
||||
.then((p) => {
|
||||
if (p) {
|
||||
setProjectWorkflowsState((prev) => ({
|
||||
...prev,
|
||||
[currentProjectId]: {
|
||||
activeKey: p.active_workflow_key || 'main',
|
||||
version: p.workflow_selection_version || 1,
|
||||
creatorId: p.creator_user_id || 1,
|
||||
},
|
||||
}));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [currentProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProjectId) {
|
||||
fetchProjectLineage(currentProjectId)
|
||||
@@ -710,14 +754,108 @@ export default function App() {
|
||||
approver: meta.approver || '王总监',
|
||||
});
|
||||
});
|
||||
|
||||
// 自动为持久化存储的自定义项目受控模板预热补全完整高质量 AST,杜绝刷新后丢失
|
||||
try {
|
||||
const savedDeliverables = loadDeliverables(ALL_INITIAL_DELIVERABLES);
|
||||
const savedProjects = loadProjects();
|
||||
savedDeliverables.forEach((item) => {
|
||||
if (item.docId && !initialCache[item.docId] && item.taskKey) {
|
||||
const tmpl = getTemplateDefinitionByTaskKey(item.taskKey);
|
||||
if (tmpl) {
|
||||
const proj = savedProjects.find((p) => p.id === item.projectId);
|
||||
initialCache[item.docId] = buildTemplateASTForProject(tmpl, {
|
||||
projectId: item.projectId || 0,
|
||||
projectName: proj?.title || '医疗器械研发项目',
|
||||
deviceType: proj?.deviceType || '通用医疗器械',
|
||||
docId: item.docId,
|
||||
creatorId: item.creatorId || 1,
|
||||
creatorName: item.creatorName || '系统',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// 忽略预热异常
|
||||
}
|
||||
|
||||
return initialCache;
|
||||
});
|
||||
|
||||
const docAST =
|
||||
docCache[activeDocId] ||
|
||||
(visibleDeliverables[0] && docCache[visibleDeliverables[0].docId]) ||
|
||||
docCache['doc-neuropulse-feasibility_reg'] ||
|
||||
docCache['doc-001'];
|
||||
const docAST = useMemo<DocumentAST>(() => {
|
||||
// 1. 命中缓存
|
||||
if (docCache[activeDocId]) {
|
||||
return docCache[activeDocId];
|
||||
}
|
||||
// 2. 若缓存缺失,基于目标 deliverable 实时自愈高质量 AST
|
||||
const target = deliverables.find((d) => d.docId === activeDocId);
|
||||
if (target) {
|
||||
const proj = projects.find((p) => p.id === target.projectId);
|
||||
const tmpl = target.taskKey ? getTemplateDefinitionByTaskKey(target.taskKey) : null;
|
||||
if (tmpl) {
|
||||
return buildTemplateASTForProject(tmpl, {
|
||||
projectId: target.projectId || 0,
|
||||
projectName: proj?.title || '医疗器械研发项目',
|
||||
deviceType: proj?.deviceType || '通用医疗器械',
|
||||
docId: target.docId,
|
||||
creatorId: target.creatorId || 1,
|
||||
creatorName: target.creatorName || '系统',
|
||||
});
|
||||
}
|
||||
|
||||
// 关键修复:对于用户上传文件或自由研讨文档,原地自愈生成 AST,绝不跳至第一份工作流文档
|
||||
const skeleton = `# ${target.title}\n\n- 交付物阶段:${target.stage || 'V0.5'}\n- 归属分类:${target.category === 'private' ? '个人私密' : target.category === 'shared' ? '项目共享' : '流程受控'}\n- 上传/编制人:${target.author || target.creatorName || '本人'}\n- 更新时间:${target.updatedAt || new Date().toISOString().substring(0, 10)}\n\n## 1. 文档概述与基本信息\n${target.summary || '用户上传文档或外部参考资料。'}\n`;
|
||||
const fallbackAST = markdownToBlockAST(skeleton, {
|
||||
docId: target.docId,
|
||||
title: target.title,
|
||||
taskKey: target.taskKey || 'user_requirements',
|
||||
version: target.stage || 'V0.5',
|
||||
category: target.category,
|
||||
creatorId: target.creatorId,
|
||||
creatorName: target.creatorName,
|
||||
lifecycleState: target.lifecycleState || 'drafting',
|
||||
isClaimed: target.isClaimed ?? true,
|
||||
author: target.author || target.creatorName || '本人',
|
||||
reviewer: target.reviewer || '李主管',
|
||||
approver: target.approver || '王总监',
|
||||
});
|
||||
fallbackAST.governanceMode = target.governanceMode || 'freeform';
|
||||
fallbackAST.space = target.space || (target.category === 'shared' ? 'project_shared' : 'my_private');
|
||||
return fallbackAST;
|
||||
}
|
||||
// 3. 当前项目第一个可见交付物
|
||||
const firstProjDoc = visibleDeliverables[0];
|
||||
if (firstProjDoc && docCache[firstProjDoc.docId]) {
|
||||
return docCache[firstProjDoc.docId];
|
||||
}
|
||||
if (firstProjDoc) {
|
||||
const proj = projects.find((p) => p.id === firstProjDoc.projectId);
|
||||
const tmpl = firstProjDoc.taskKey ? getTemplateDefinitionByTaskKey(firstProjDoc.taskKey) : null;
|
||||
if (tmpl) {
|
||||
return buildTemplateASTForProject(tmpl, {
|
||||
projectId: firstProjDoc.projectId || 0,
|
||||
projectName: proj?.title || '医疗器械研发项目',
|
||||
deviceType: proj?.deviceType || '通用医疗器械',
|
||||
docId: firstProjDoc.docId,
|
||||
creatorId: firstProjDoc.creatorId || 1,
|
||||
creatorName: firstProjDoc.creatorName || '系统',
|
||||
});
|
||||
}
|
||||
}
|
||||
// 4. 极端兜底
|
||||
return (
|
||||
docCache['doc-neuropulse-feasibility_reg'] ||
|
||||
docCache['doc-001'] ||
|
||||
Object.values(docCache)[0]
|
||||
);
|
||||
}, [docCache, activeDocId, deliverables, projects, visibleDeliverables]);
|
||||
|
||||
// 自愈后自动回写入缓存,保障后续操作直接命中
|
||||
useEffect(() => {
|
||||
if (docAST && !docCache[docAST.docId]) {
|
||||
setDocCache((prev) => ({ ...prev, [docAST.docId]: docAST }));
|
||||
}
|
||||
}, [docAST, docCache]);
|
||||
|
||||
// 根据当前激活文档与用户权限矩阵,计算当前生效角色
|
||||
const currentDocRole: UserRole = (() => {
|
||||
@@ -742,7 +880,60 @@ export default function App() {
|
||||
// 切换文档
|
||||
const handleSelectDoc = (id: string) => {
|
||||
setActiveDocId(id);
|
||||
const targetDoc = deliverables.find((d) => d.docId === id);
|
||||
let targetDoc = deliverables.find((d) => d.docId === id);
|
||||
|
||||
// 关键自愈:若该文档不在全局 deliverables 中(例如历史消息中的附件),自动从消息池中寻回并自愈注册
|
||||
if (!targetDoc) {
|
||||
for (const msg of messages) {
|
||||
if (msg.attachments) {
|
||||
const matchAtt = msg.attachments.find((att) => att.docId === id);
|
||||
if (matchAtt) {
|
||||
const extMatch = matchAtt.name.match(/\.([^.]+)$/);
|
||||
const ext = extMatch ? extMatch[1].toLowerCase() : '';
|
||||
const baseTitle = matchAtt.name.replace(/\.[^/.]+$/, '');
|
||||
const recovered: DeliverableItem = {
|
||||
docId: id,
|
||||
projectId: currentProjectId,
|
||||
taskKey: 'user_requirements',
|
||||
title: baseTitle,
|
||||
stage: 'V0.5',
|
||||
category: 'private',
|
||||
creatorId: currentUser.id,
|
||||
creatorName: currentUser.name,
|
||||
isFixedWorkflow: false,
|
||||
isTemplateOnly: false,
|
||||
lifecycleState: 'drafting',
|
||||
isClaimed: true,
|
||||
author: currentUser.name,
|
||||
reviewer: '李主管',
|
||||
approver: '王总监',
|
||||
updatedAt: new Date().toISOString().substring(0, 10),
|
||||
summary: `用户上传的私密需求文档《${matchAtt.name}》,归档至个人私密文档库。`,
|
||||
space: 'my_private',
|
||||
governanceMode: 'freeform',
|
||||
sourceType: 'uploaded_file',
|
||||
fileMeta: {
|
||||
originalName: matchAtt.name,
|
||||
size: matchAtt.size,
|
||||
mimeType: matchAtt.type,
|
||||
extension: ext,
|
||||
fileRaw: matchAtt.fileRaw,
|
||||
fileUrl: matchAtt.fileUrl,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
targetDoc = recovered;
|
||||
setDeliverables((prev) => [recovered, ...prev.filter((d) => d.docId !== id)]);
|
||||
// ✅ 自愈注册时同步持久化文件内容到 IndexedDB(若 fileRaw 有效)
|
||||
if (matchAtt.fileRaw) {
|
||||
saveFile(id, matchAtt.fileRaw, matchAtt.name).catch(() => {});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetDoc) {
|
||||
setActiveCategory(targetDoc.category);
|
||||
}
|
||||
@@ -766,25 +957,40 @@ export default function App() {
|
||||
});
|
||||
setDocCache((prev) => ({ ...prev, [id]: parsed }));
|
||||
} else if (targetDoc) {
|
||||
// 健壮兜底:即使为自定义/克隆交付物,也能即时生成基础 AST 骨架,严禁卡死或回退到无关文档
|
||||
const skeleton = `# ${targetDoc.title}\n\n- 交付物阶段:${targetDoc.stage}\n- 归属分类:${targetDoc.category}\n- 编制人:${targetDoc.author || targetDoc.creatorName}\n\n## 1. 交付物摘要\n${targetDoc.summary || '暂无详细章节摘要,待智能体参谋与项目成员增补。'}\n`;
|
||||
const parsed = markdownToBlockAST(skeleton, {
|
||||
docId: targetDoc.docId,
|
||||
title: targetDoc.title,
|
||||
taskKey: targetDoc.taskKey,
|
||||
version: targetDoc.stage,
|
||||
category: targetDoc.category,
|
||||
creatorId: targetDoc.creatorId,
|
||||
creatorName: targetDoc.creatorName,
|
||||
lifecycleState: targetDoc.lifecycleState,
|
||||
isClaimed: targetDoc.isClaimed,
|
||||
author: targetDoc.author,
|
||||
reviewer: targetDoc.reviewer,
|
||||
approver: targetDoc.approver,
|
||||
});
|
||||
parsed.governanceMode = targetDoc.governanceMode;
|
||||
parsed.space = targetDoc.space;
|
||||
setDocCache((prev) => ({ ...prev, [id]: parsed }));
|
||||
// 关键升级:若为工作流标准模板,必须以工业级规格生成完备待填报模板 AST,坚决杜绝 3 行摘要残缺占位符
|
||||
const tmpl = targetDoc.taskKey ? getTemplateDefinitionByTaskKey(targetDoc.taskKey) : null;
|
||||
if (tmpl) {
|
||||
const proj = projects.find((p) => p.id === targetDoc.projectId);
|
||||
const fullAst = buildTemplateASTForProject(tmpl, {
|
||||
projectId: targetDoc.projectId || 0,
|
||||
projectName: proj?.title || '医疗器械研发项目',
|
||||
deviceType: proj?.deviceType || '通用医疗器械',
|
||||
docId: targetDoc.docId,
|
||||
creatorId: targetDoc.creatorId || 1,
|
||||
creatorName: targetDoc.creatorName || '系统',
|
||||
});
|
||||
setDocCache((prev) => ({ ...prev, [id]: fullAst }));
|
||||
} else {
|
||||
// 仅当非受控模板时才使用普通草稿骨架
|
||||
const skeleton = `# ${targetDoc.title}\n\n- 交付物阶段:${targetDoc.stage}\n- 归属分类:${targetDoc.category}\n- 编制人:${targetDoc.author || targetDoc.creatorName}\n\n## 1. 交付物摘要\n${targetDoc.summary || '暂无详细章节摘要,待智能体参谋与项目成员增补。'}\n`;
|
||||
const parsed = markdownToBlockAST(skeleton, {
|
||||
docId: targetDoc.docId,
|
||||
title: targetDoc.title,
|
||||
taskKey: targetDoc.taskKey,
|
||||
version: targetDoc.stage,
|
||||
category: targetDoc.category,
|
||||
creatorId: targetDoc.creatorId,
|
||||
creatorName: targetDoc.creatorName,
|
||||
lifecycleState: targetDoc.lifecycleState,
|
||||
isClaimed: targetDoc.isClaimed,
|
||||
author: targetDoc.author,
|
||||
reviewer: targetDoc.reviewer,
|
||||
approver: targetDoc.approver,
|
||||
});
|
||||
parsed.governanceMode = targetDoc.governanceMode;
|
||||
parsed.space = targetDoc.space;
|
||||
setDocCache((prev) => ({ ...prev, [id]: parsed }));
|
||||
}
|
||||
|
||||
if (id.startsWith('doc-free-') || targetDoc.governanceMode === 'freeform') {
|
||||
fetchFreeformDoc(id, currentUser.id)
|
||||
@@ -1759,16 +1965,23 @@ export default function App() {
|
||||
// 发送消息与对话驱动双轨逻辑
|
||||
const handleSendMessage = (
|
||||
text: string,
|
||||
attachments?: { name: string; size: string; type: string; docId?: string }[],
|
||||
attachments?: { name: string; size: string; type: string; docId?: string; fileRaw?: File | Blob; fileUrl?: string }[],
|
||||
quotes?: QuotedMessage[]
|
||||
) => {
|
||||
const trimmed = text.trim();
|
||||
// 若包含附件,则自动将用户上传的文件注册为个人私密文档 (category: 'private')
|
||||
// 若包含附件,则自动将用户上传的文件批量注册为个人私密文档 (category: 'private')
|
||||
let processedAttachments = attachments;
|
||||
if (attachments && attachments.length > 0) {
|
||||
const newDeliverablesList: DeliverableItem[] = [];
|
||||
const newDocCacheEntries: Record<string, DocumentAST> = {};
|
||||
const now = Date.now();
|
||||
|
||||
processedAttachments = attachments.map((att, idx) => {
|
||||
const fileDocId = att.docId || `doc-upload-${Date.now()}-${idx}`;
|
||||
const fileDocId = att.docId || `doc-upload-${now}-${idx}`;
|
||||
const baseTitle = att.name.replace(/\.[^/.]+$/, '');
|
||||
const extMatch = att.name.match(/\.([^.]+)$/);
|
||||
const ext = extMatch ? extMatch[1].toLowerCase() : '';
|
||||
|
||||
const newDeliverable: DeliverableItem = {
|
||||
docId: fileDocId,
|
||||
projectId: currentProjectId,
|
||||
@@ -1789,6 +2002,16 @@ export default function App() {
|
||||
summary: `用户上传的私密需求文档《${att.name}》,归档至个人私密文档库,可一键共享至项目参考库。`,
|
||||
space: 'my_private',
|
||||
governanceMode: 'freeform',
|
||||
sourceType: 'uploaded_file',
|
||||
fileMeta: {
|
||||
originalName: att.name,
|
||||
size: att.size,
|
||||
mimeType: att.type,
|
||||
extension: ext,
|
||||
fileRaw: att.fileRaw,
|
||||
fileUrl: att.fileUrl,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
const initialMd = `# ${baseTitle}\n\n> 上传人:${currentUser.name} | 上传时间:${new Date().toLocaleString()} | 文件大小:${att.size}\n\n## 1. 文档概述与背景\n\n本文件由用户上传并归档至【个人私密文档库】。原始附件格式:\`${att.type || 'document'}\`。\n\n## 2. 协作与共享说明\n\n- **当前状态**:🔒 **个人私密 (仅本人可见)**\n- 您可在底部操作栏点击「一键共享给项目组」,将该文档共享至项目协作参考库供其他成员查阅与评审。\n- 也可以随时点击「撤回为个人私密」恢复独立隔离。`;
|
||||
const newAST = markdownToBlockAST(initialMd, {
|
||||
@@ -1808,19 +2031,35 @@ export default function App() {
|
||||
newAST.governanceMode = 'freeform';
|
||||
newAST.space = 'my_private';
|
||||
|
||||
setDeliverables((prev) => [newDeliverable, ...prev.filter((d) => d.docId !== fileDocId)]);
|
||||
setDocCache((prev) => ({
|
||||
...prev,
|
||||
[fileDocId]: newAST,
|
||||
}));
|
||||
// 自动激活打开刚上传的文档
|
||||
setActiveDocId(fileDocId);
|
||||
setActiveCategory('private');
|
||||
newDeliverablesList.push(newDeliverable);
|
||||
newDocCacheEntries[fileDocId] = newAST;
|
||||
|
||||
// ✅ 将文件内容持久化到 IndexedDB,支持页面刷新后恢复预览
|
||||
// fire-and-forget:写入失败不影响正常流程
|
||||
if (att.fileRaw) {
|
||||
saveFile(fileDocId, att.fileRaw, att.name).catch((e) =>
|
||||
console.warn('[fileStore] 保存上传文件失败:', fileDocId, e)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...att,
|
||||
docId: fileDocId,
|
||||
};
|
||||
});
|
||||
|
||||
const newDocIds = new Set(newDeliverablesList.map((d) => d.docId));
|
||||
setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]);
|
||||
setDocCache((prev) => ({
|
||||
...prev,
|
||||
...newDocCacheEntries,
|
||||
}));
|
||||
|
||||
// 默认激活打开刚上传的第 1 个文档
|
||||
if (processedAttachments.length > 0 && processedAttachments[0].docId) {
|
||||
setActiveDocId(processedAttachments[0].docId);
|
||||
setActiveCategory('private');
|
||||
}
|
||||
}
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
@@ -2552,6 +2791,14 @@ export default function App() {
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
}
|
||||
setActiveSessionId(sessId);
|
||||
const targetSess = sessions.find((s) => s.sessionId === sessId);
|
||||
if (targetSess && targetSess.projectId !== currentProjectId) {
|
||||
const targetProjDocs = deliverables.filter((d) => d.projectId === targetSess.projectId);
|
||||
if (targetProjDocs.length > 0) {
|
||||
setActiveDocId(targetProjDocs[0].docId);
|
||||
setActiveCategory(targetProjDocs[0].category);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onNewSession={() => setCreateProjectModalOpen(true)}
|
||||
canCreateProject={canCreateProject}
|
||||
@@ -2792,22 +3039,48 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 块状文档渲染器 */}
|
||||
<BlockRenderer
|
||||
ast={docAST}
|
||||
currentRole={currentDocRole}
|
||||
onEditBlock={setEditingBlock}
|
||||
onInsertBlock={handleInsertBlock}
|
||||
onMoveBlock={handleMoveBlock}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onOpenComments={setCommentingBlock}
|
||||
onAskAgent={(snippet) => {
|
||||
handleSendMessage(
|
||||
`针对受控文档《${docAST.title}》中的条款内容:\n\n“${snippet.slice(0, 150)}${snippet.length > 150 ? '...' : ''}”\n\n请帮我审查其合规性、技术可行性或提出修改建议。`
|
||||
{/* 核心文档内容渲染区:用户上传文档使用高保真文件预览器,工作流及对话入库文档使用结构化 BlockRenderer */}
|
||||
{(() => {
|
||||
const activeDeliverable = deliverables.find((d) => d.docId === activeDocId);
|
||||
const isUploadedFile = Boolean(
|
||||
activeDeliverable &&
|
||||
(activeDeliverable.sourceType === 'uploaded_file' ||
|
||||
Boolean(activeDeliverable.fileMeta) ||
|
||||
activeDeliverable.docId.startsWith('doc-upload-') ||
|
||||
Boolean(activeDeliverable.title?.match(/\.(docx?|pdf|xlsx?|csv|pptx?|png|jpe?g|svg|webp|txt|md|json)$/i)) ||
|
||||
(activeDeliverable.taskKey === 'user_requirements' &&
|
||||
!activeDeliverable.isFixedWorkflow &&
|
||||
(activeDeliverable.category === 'private' || activeDeliverable.category === 'shared')))
|
||||
);
|
||||
if (isUploadedFile && activeDeliverable) {
|
||||
return (
|
||||
<FilePreviewPanel
|
||||
deliverable={activeDeliverable}
|
||||
onOpenDocVisibility={() => {
|
||||
setTargetDocForVisibility(activeDeliverable);
|
||||
setDocVisibilityModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
highlightedBlockId={highlightedBlockId}
|
||||
/>
|
||||
}
|
||||
return (
|
||||
<BlockRenderer
|
||||
ast={docAST}
|
||||
currentRole={currentDocRole}
|
||||
onEditBlock={setEditingBlock}
|
||||
onInsertBlock={handleInsertBlock}
|
||||
onMoveBlock={handleMoveBlock}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onOpenComments={setCommentingBlock}
|
||||
onAskAgent={(snippet) => {
|
||||
handleSendMessage(
|
||||
`针对受控文档《${docAST.title}》中的条款内容:\n\n“${snippet.slice(0, 150)}${snippet.length > 150 ? '...' : ''}”\n\n请帮我审查其合规性、技术可行性或提出修改建议。`
|
||||
);
|
||||
}}
|
||||
highlightedBlockId={highlightedBlockId}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 底部流转动作条 (受控流程文档使用 DocGovActionBar,自由私密/共享文档使用 FreeformDocumentActionBar) */}
|
||||
{docAST.governanceMode === 'freeform' || docAST.space === 'my_private' || docAST.space === 'project_shared' || docAST.category === 'private' || docAST.category === 'shared' ? (
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
FileSpreadsheet,
|
||||
Presentation,
|
||||
Image as ImageIcon,
|
||||
FileCode,
|
||||
Download,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Lock,
|
||||
Users,
|
||||
Info,
|
||||
Eye,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { DeliverableItem } from '../../../types/workbench';
|
||||
import { DocxViewer } from './renderers/DocxViewer';
|
||||
import { PdfViewer } from './renderers/PdfViewer';
|
||||
import { ExcelViewer } from './renderers/ExcelViewer';
|
||||
import { ImageViewer } from './renderers/ImageViewer';
|
||||
import { PptViewer } from './renderers/PptViewer';
|
||||
import { TextViewer } from './renderers/TextViewer';
|
||||
|
||||
interface FilePreviewPanelProps {
|
||||
deliverable: DeliverableItem;
|
||||
onOpenDocVisibility?: () => void;
|
||||
}
|
||||
|
||||
export const FilePreviewPanel: React.FC<FilePreviewPanelProps> = ({
|
||||
deliverable,
|
||||
onOpenDocVisibility,
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<'preview' | 'info'>('preview');
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const fileMeta = deliverable.fileMeta;
|
||||
const fileName = fileMeta?.originalName || deliverable.title;
|
||||
const fileSize = fileMeta?.size || '未知大小';
|
||||
const fileRaw = fileMeta?.fileRaw;
|
||||
const fileUrl = fileMeta?.fileUrl;
|
||||
|
||||
// 文件内容可用性判断:fileRaw(当前 session 内存)或 fileUrl(非 blob 服务端 URL)任一有效即可预览
|
||||
const hasFileContent = Boolean(fileRaw) || Boolean(fileUrl && !fileUrl.startsWith('blob:'));
|
||||
|
||||
// 多级智能提取真实文件扩展名(兼容 originalName、summary、title 中包含的 .docx, .pdf 等)
|
||||
const detectExtension = (): string => {
|
||||
if (fileMeta?.extension) return fileMeta.extension.toLowerCase();
|
||||
const sourceCandidates = [
|
||||
fileMeta?.originalName || '',
|
||||
deliverable.summary || '',
|
||||
deliverable.title || '',
|
||||
];
|
||||
for (const src of sourceCandidates) {
|
||||
const match = src.match(/\.(docx?|pdf|xlsx?|csv|pptx?|png|jpe?g|svg|webp|txt|md|json|log)(?:[》"\s]|$)/i);
|
||||
if (match) return match[1].toLowerCase();
|
||||
}
|
||||
// 降级判断:如果文件名有点
|
||||
if (fileName.includes('.')) {
|
||||
const parts = fileName.split('.');
|
||||
return (parts.pop() || '').toLowerCase();
|
||||
}
|
||||
// 默认文档兜底
|
||||
return 'docx';
|
||||
};
|
||||
|
||||
const ext = detectExtension();
|
||||
|
||||
// 根据扩展名归类文件类型
|
||||
const getFileType = () => {
|
||||
if (['docx', 'doc'].includes(ext)) return 'docx';
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
if (['xlsx', 'xls', 'csv'].includes(ext)) return 'excel';
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp'].includes(ext)) return 'image';
|
||||
if (['pptx', 'ppt'].includes(ext)) return 'ppt';
|
||||
if (['txt', 'md', 'json', 'xml', 'log', 'yaml', 'yml'].includes(ext)) return 'text';
|
||||
return 'docx'; // 优先以 Word 文档排版呈现
|
||||
};
|
||||
|
||||
const fileType = getFileType();
|
||||
|
||||
// 获取类型对应的图标与主题色
|
||||
const getTypeBadge = () => {
|
||||
switch (fileType) {
|
||||
case 'docx':
|
||||
return {
|
||||
icon: <FileText size={18} />,
|
||||
label: 'Word 文档',
|
||||
color: '#2563eb',
|
||||
bg: '#eff6ff',
|
||||
border: '#bfdbfe',
|
||||
};
|
||||
case 'excel':
|
||||
return {
|
||||
icon: <FileSpreadsheet size={18} />,
|
||||
label: 'Excel 表格',
|
||||
color: '#10b981',
|
||||
bg: '#ecfdf5',
|
||||
border: '#a7f3d0',
|
||||
};
|
||||
case 'pdf':
|
||||
return {
|
||||
icon: <FileText size={18} />,
|
||||
label: 'PDF 文档',
|
||||
color: '#ea580c',
|
||||
bg: '#fff7ed',
|
||||
border: '#fed7aa',
|
||||
};
|
||||
case 'ppt':
|
||||
return {
|
||||
icon: <Presentation size={18} />,
|
||||
label: 'PowerPoint 演示',
|
||||
color: '#e11d48',
|
||||
bg: '#fff1f2',
|
||||
border: '#fecdd3',
|
||||
};
|
||||
case 'image':
|
||||
return {
|
||||
icon: <ImageIcon size={18} />,
|
||||
label: '图像文件',
|
||||
color: '#8b5cf6',
|
||||
bg: '#f5f3ff',
|
||||
border: '#ddd6fe',
|
||||
};
|
||||
case 'text':
|
||||
return {
|
||||
icon: <FileCode size={18} />,
|
||||
label: '纯文本/代码',
|
||||
color: '#475569',
|
||||
bg: '#f8fafc',
|
||||
border: '#e2e8f0',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <FileText size={18} />,
|
||||
label: ext ? ext.toUpperCase() : '文件',
|
||||
color: '#64748b',
|
||||
bg: '#f1f5f9',
|
||||
border: '#cbd5e1',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const badge = getTypeBadge();
|
||||
|
||||
// 处理文件原件下载
|
||||
// 优先从 fileRaw(内存 File 对象)动态生成 blob URL,确保下载始终有效;
|
||||
// 退而使用 fileUrl(仅当非 blob: 协议时,刷新后 blob URL 已失效不可用)
|
||||
const handleDownload = () => {
|
||||
if (fileRaw) {
|
||||
// 实时从 File 对象创建 blob URL,避免依赖可能已 revoke 的旧 URL
|
||||
const url = URL.createObjectURL(fileRaw);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// 延迟释放,保证浏览器有足够时间启动下载
|
||||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||
} else if (fileUrl && !fileUrl.startsWith('blob:')) {
|
||||
// 服务端持久化 URL,可直接使用
|
||||
const a = document.createElement('a');
|
||||
a.href = fileUrl;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
} else {
|
||||
// 文件内容不可用(刷新后 File 对象/blob URL 均已失效)
|
||||
alert(`文件「${fileName}」的本地内容在页面刷新后已释放。请在对话框中重新上传该文件以恢复预览与下载功能。`);
|
||||
}
|
||||
};
|
||||
|
||||
// 文件内容不可用时的占位说明(刷新后 File 对象丢失,需重新上传)
|
||||
const renderNoContentPlaceholder = () => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
padding: '40px 28px',
|
||||
background: 'var(--bg-subtle, #f8fafc)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
borderRadius: '14px',
|
||||
background: '#fef9c3',
|
||||
border: '1px solid #fde047',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '28px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
⚠️
|
||||
</div>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: '15px', fontWeight: 600, color: '#1e293b' }}>
|
||||
文件内容已释放,无法预览
|
||||
</h4>
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 8px',
|
||||
maxWidth: '420px',
|
||||
fontSize: '13px',
|
||||
color: '#64748b',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
浏览器本地文件(<code>{fileName}</code>)仅在上传当次会话内保留于内存。
|
||||
<strong>页面刷新后,内存中的文件内容会被浏览器释放</strong>,无法再次预览或下载。
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 20px',
|
||||
maxWidth: '420px',
|
||||
fontSize: '12px',
|
||||
color: '#94a3b8',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
文档的元数据(文件名、大小、权限)已持久化保存。如需重新预览,请在下方对话框中再次上传同名文件。
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '6px',
|
||||
background: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
fontSize: '12px',
|
||||
color: '#64748b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<span>📁</span>
|
||||
<span>文件名:{fileName}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '6px',
|
||||
background: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
fontSize: '12px',
|
||||
color: '#64748b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<span>📏</span>
|
||||
<span>大小:{fileSize}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 渲染对应的文档预览子组件
|
||||
const renderViewer = () => {
|
||||
// 文件内容不可用(刷新后 File/Blob 丢失)
|
||||
if (!hasFileContent) {
|
||||
return renderNoContentPlaceholder();
|
||||
}
|
||||
switch (fileType) {
|
||||
case 'docx':
|
||||
return <DocxViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
|
||||
case 'pdf':
|
||||
return <PdfViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
|
||||
case 'excel':
|
||||
return <ExcelViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
|
||||
case 'image':
|
||||
return <ImageViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
|
||||
case 'ppt':
|
||||
return <PptViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} fileSize={fileSize} />;
|
||||
case 'text':
|
||||
return <TextViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
padding: '32px 24px',
|
||||
background: 'var(--bg-subtle, #f8fafc)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
borderRadius: '14px',
|
||||
background: '#f1f5f9',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#64748b',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<FileText size={28} />
|
||||
</div>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: '16px', fontWeight: 600, color: '#1e293b' }}>
|
||||
该格式暂不支持直接内嵌渲染
|
||||
</h4>
|
||||
<p style={{ margin: '0 0 20px', maxWidth: '380px', fontSize: '13px', color: '#64748b', lineHeight: 1.5 }}>
|
||||
文件「{fileName}」为 <code>.{ext}</code> 格式。您可以直接下载原始文件查看。
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '6px',
|
||||
background: 'var(--primary, #2563eb)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Download size={15} />
|
||||
下载原始文件
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
position: isFullscreen ? 'fixed' : 'relative',
|
||||
top: isFullscreen ? 0 : 'auto',
|
||||
left: isFullscreen ? 0 : 'auto',
|
||||
right: isFullscreen ? 0 : 'auto',
|
||||
bottom: isFullscreen ? 0 : 'auto',
|
||||
zIndex: isFullscreen ? 9999 : 1,
|
||||
background: '#ffffff',
|
||||
}}
|
||||
>
|
||||
{/* 顶部多功能操作与元数据横栏 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 16px',
|
||||
background: '#ffffff',
|
||||
borderBottom: '1px solid var(--border-light, #e2e8f0)',
|
||||
flexShrink: 0,
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
{/* 左侧:文件格式标与文件名 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
background: badge.bg,
|
||||
border: `1px solid ${badge.border}`,
|
||||
color: badge.color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{badge.icon}
|
||||
</div>
|
||||
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-strong, #0f172a)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={fileName}
|
||||
>
|
||||
{fileName}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '11px', color: 'var(--text-muted, #64748b)' }}>
|
||||
<span>{badge.label}</span>
|
||||
<span>·</span>
|
||||
<span>{fileSize}</span>
|
||||
<span>·</span>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
color: deliverable.space === 'project_shared' ? '#0284c7' : '#d97706',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{deliverable.space === 'project_shared' ? <Users size={11} /> : <Lock size={11} />}
|
||||
{deliverable.space === 'project_shared' ? '项目共享' : '个人私密'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:模式切换与快捷动作 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
|
||||
{/* 预览模式 / 文档信息 切换按钮 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: '#f1f5f9',
|
||||
borderRadius: '6px',
|
||||
padding: '2px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setViewMode('preview')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
fontSize: '11px',
|
||||
fontWeight: viewMode === 'preview' ? 600 : 500,
|
||||
background: viewMode === 'preview' ? '#ffffff' : 'transparent',
|
||||
color: viewMode === 'preview' ? '#0f172a' : '#64748b',
|
||||
cursor: 'pointer',
|
||||
boxShadow: viewMode === 'preview' ? '0 1px 2px rgba(0,0,0,0.08)' : 'none',
|
||||
}}
|
||||
>
|
||||
<Eye size={12} />
|
||||
在线预览
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('info')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
fontSize: '11px',
|
||||
fontWeight: viewMode === 'info' ? 600 : 500,
|
||||
background: viewMode === 'info' ? '#ffffff' : 'transparent',
|
||||
color: viewMode === 'info' ? '#0f172a' : '#64748b',
|
||||
cursor: 'pointer',
|
||||
boxShadow: viewMode === 'info' ? '0 1px 2px rgba(0,0,0,0.08)' : 'none',
|
||||
}}
|
||||
>
|
||||
<Info size={12} />
|
||||
属性与说明
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 下载原件按钮:文件内容不可用时禁用 */}
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={!hasFileContent}
|
||||
title={hasFileContent ? '下载原始文件到本地' : '文件内容已释放,请重新上传后下载'}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '5px 10px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #cbd5e1',
|
||||
background: hasFileContent ? '#ffffff' : '#f1f5f9',
|
||||
color: hasFileContent ? '#334155' : '#94a3b8',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
cursor: hasFileContent ? 'pointer' : 'not-allowed',
|
||||
opacity: hasFileContent ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
<Download size={13} />
|
||||
下载
|
||||
</button>
|
||||
|
||||
{/* 全屏放大按钮 */}
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
title={isFullscreen ? '退出全屏' : '全屏预览'}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #cbd5e1',
|
||||
background: '#ffffff',
|
||||
color: '#475569',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 size={13} /> : <Maximize2 size={13} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主展示区:依据模式渲染 在线正文预览 或 属性与说明 */}
|
||||
<div style={{ flex: 1, height: '100%', overflow: 'hidden', position: 'relative' }}>
|
||||
{viewMode === 'preview' ? (
|
||||
renderViewer()
|
||||
) : (
|
||||
/* 属性与说明模式卡片视图 */
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
padding: '32px 24px',
|
||||
background: 'var(--bg-subtle, #f8fafc)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '680px',
|
||||
margin: '0 auto',
|
||||
background: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid var(--border-light, #e2e8f0)',
|
||||
padding: '24px 28px',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.03)',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: '16px', fontWeight: 600, color: '#0f172a' }}>
|
||||
文档基本信息与权限控制
|
||||
</h3>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '16px',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ background: '#f8fafc', padding: '12px', borderRadius: '8px' }}>
|
||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>文件全名</div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 500, color: '#1e293b' }}>{fileName}</div>
|
||||
</div>
|
||||
<div style={{ background: '#f8fafc', padding: '12px', borderRadius: '8px' }}>
|
||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>文件大小</div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 500, color: '#1e293b' }}>{fileSize}</div>
|
||||
</div>
|
||||
<div style={{ background: '#f8fafc', padding: '12px', borderRadius: '8px' }}>
|
||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>上传编制人</div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 500, color: '#1e293b' }}>{deliverable.creatorName || deliverable.author}</div>
|
||||
</div>
|
||||
<div style={{ background: '#f8fafc', padding: '12px', borderRadius: '8px' }}>
|
||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>更新日期</div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 500, color: '#1e293b' }}>{deliverable.updatedAt}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 style={{ margin: '0 0 12px', fontSize: '14px', fontWeight: 600, color: '#334155' }}>
|
||||
受控流转状态与协作红线说明
|
||||
</h4>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
background: deliverable.space === 'project_shared' ? '#f0f9ff' : '#fffbeb',
|
||||
border: `1px solid ${deliverable.space === 'project_shared' ? '#bae6fd' : '#fef3c7'}`,
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px',
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.6,
|
||||
color: deliverable.space === 'project_shared' ? '#0369a1' : '#b45309',
|
||||
}}
|
||||
>
|
||||
{deliverable.space === 'project_shared' ? (
|
||||
<div>
|
||||
<strong>👥 当前状态:项目共享文档</strong>
|
||||
<div>本项目内所有已授权研发成员均可在线预览此文档,并引用作为研发输入参考。</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<strong>🔒 当前状态:个人私密文档 (仅本人可见)</strong>
|
||||
<div>该文件目前仅存储在您的个人空间,未对项目组成员开放。您可通过底部操作栏「一键共享给项目组」将其公开至项目参考库。</div>
|
||||
</div>
|
||||
)}
|
||||
{onOpenDocVisibility && (
|
||||
<div style={{ marginTop: '10px' }}>
|
||||
<button
|
||||
onClick={onOpenDocVisibility}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '4px 10px',
|
||||
background: '#ffffff',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
color: '#334155',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<ShieldCheck size={14} color="#0284c7" />
|
||||
配置文档单覆盖合规可见性
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deliverable.summary && (
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: '13px', fontWeight: 600, color: '#475569' }}>
|
||||
文档摘要记录
|
||||
</h4>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#64748b',
|
||||
lineHeight: 1.6,
|
||||
background: '#f8fafc',
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
>
|
||||
{deliverable.summary}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { renderAsync } from 'docx-preview';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface DocxViewerProps {
|
||||
fileRaw?: File | Blob;
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export const DocxViewer: React.FC<DocxViewerProps> = ({ fileRaw, fileUrl, fileName }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadDocx() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let blob: Blob | null = null;
|
||||
if (fileRaw) {
|
||||
blob = fileRaw;
|
||||
} else if (fileUrl) {
|
||||
const res = await fetch(fileUrl);
|
||||
if (!res.ok) throw new Error(`无法获取文档网络资源: ${res.statusText}`);
|
||||
blob = await res.blob();
|
||||
}
|
||||
|
||||
if (!blob) {
|
||||
if (fileName) {
|
||||
setError(
|
||||
`文件「${fileName}」的本地内容已释放(页面刷新后浏览器会清除内存中的 File 对象)。如需预览,请在对话框中重新上传该文件。`
|
||||
);
|
||||
} else {
|
||||
setError('未检测到有效的 Word (.docx) 文档二进制内容,请重新上传文件后再试。');
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!containerRef.current || !isMounted) return;
|
||||
|
||||
// 清空容器
|
||||
containerRef.current.innerHTML = '';
|
||||
|
||||
await renderAsync(blob, containerRef.current, undefined, {
|
||||
className: 'docx-preview-content',
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
ignoreFonts: false,
|
||||
breakPages: true,
|
||||
experimental: true,
|
||||
});
|
||||
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (isMounted) {
|
||||
console.error('DocxViewer 渲染失败:', err);
|
||||
setError(err?.message || 'Word 文档解析失败,请检查文件格式是否完整');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadDocx();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [fileRaw, fileUrl]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
background: 'var(--bg-subtle, #f1f5f9)',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'auto',
|
||||
padding: '24px 16px',
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(255, 255, 255, 0.85)',
|
||||
zIndex: 10,
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<Loader2 size={32} className="animate-spin" style={{ color: 'var(--primary, #2563eb)' }} />
|
||||
<span style={{ fontSize: '13px', color: 'var(--text-muted, #64748b)', fontWeight: 500 }}>
|
||||
正在安全解析 Word (.docx) 排版中...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
margin: 'auto',
|
||||
maxWidth: '480px',
|
||||
padding: '28px 24px',
|
||||
background: '#ffffff',
|
||||
border: '1px solid #fee2e2',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.04)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '50%',
|
||||
background: '#fef2f2',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 16px',
|
||||
color: '#dc2626',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={24} />
|
||||
</div>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: '15px', fontWeight: 600, color: '#1e293b' }}>
|
||||
{error?.includes('已释放') ? '文件内容已释放,无法预览' : 'Word 文档在线解析异常'}
|
||||
</h4>
|
||||
<p style={{ margin: '0 0 16px', fontSize: '13px', color: '#64748b', lineHeight: 1.5 }}>
|
||||
{error}
|
||||
</p>
|
||||
<div style={{ fontSize: '12px', color: '#94a3b8' }}>
|
||||
{error?.includes('已释放')
|
||||
? '请在下方对话框中重新上传该文件即可恢复预览。'
|
||||
: '您可以尝试直接在上方操作栏点击「下载原始文件」使用本机 Office 打开。'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="docx-render-host"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '860px',
|
||||
background: '#ffffff',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
|
||||
borderRadius: '4px',
|
||||
minHeight: '600px',
|
||||
boxSizing: 'border-box',
|
||||
display: loading || error ? 'none' : 'block',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,356 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { Loader2, AlertCircle, Table as TableIcon } from 'lucide-react';
|
||||
|
||||
interface ExcelViewerProps {
|
||||
fileRaw?: File | Blob;
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export const ExcelViewer: React.FC<ExcelViewerProps> = ({ fileRaw, fileUrl }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sheetNames, setSheetNames] = useState<string[]>([]);
|
||||
const [activeSheetIndex, setActiveSheetIndex] = useState(0);
|
||||
const [sheetData, setSheetData] = useState<Record<string, (string | number)[][]>>({});
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadExcel() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let buffer: ArrayBuffer;
|
||||
if (fileRaw) {
|
||||
buffer = await fileRaw.arrayBuffer();
|
||||
} else if (fileUrl) {
|
||||
const res = await fetch(fileUrl);
|
||||
if (!res.ok) throw new Error(`无法获取表格文件: ${res.statusText}`);
|
||||
buffer = await res.arrayBuffer();
|
||||
} else {
|
||||
throw new Error('未检测到有效的表格二进制文件');
|
||||
}
|
||||
|
||||
const workbook = XLSX.read(buffer, { type: 'array' });
|
||||
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
|
||||
throw new Error('该表格文件中未包含任何有效的工作表 (Worksheet)');
|
||||
}
|
||||
|
||||
const parsedSheets: Record<string, (string | number)[][]> = {};
|
||||
for (const name of workbook.SheetNames) {
|
||||
const ws = workbook.Sheets[name];
|
||||
if (ws) {
|
||||
const rawRows = XLSX.utils.sheet_to_json<(string | number)[]>(ws, {
|
||||
header: 1,
|
||||
defval: '',
|
||||
blankrows: false,
|
||||
});
|
||||
parsedSheets[name] = rawRows;
|
||||
} else {
|
||||
parsedSheets[name] = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (isMounted) {
|
||||
setSheetNames(workbook.SheetNames);
|
||||
setSheetData(parsedSheets);
|
||||
setActiveSheetIndex(0);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (isMounted) {
|
||||
console.error('ExcelViewer 解析失败:', err);
|
||||
setError(err?.message || 'Excel 表格解析异常,请确认格式是否正确');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadExcel();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [fileRaw, fileUrl]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
background: 'var(--bg-subtle, #f8fafc)',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<Loader2 size={32} className="animate-spin" style={{ color: 'var(--primary, #2563eb)' }} />
|
||||
<span style={{ fontSize: '13px', color: 'var(--text-muted, #64748b)' }}>
|
||||
正在安全解析 Excel 工作表结构...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
background: 'var(--bg-subtle, #f8fafc)',
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '440px',
|
||||
padding: '24px',
|
||||
background: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={36} color="#dc2626" style={{ margin: '0 auto 12px' }} />
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: '15px', fontWeight: 600 }}>Excel 表格在线解析异常</h4>
|
||||
<p style={{ margin: '0 0 12px', fontSize: '13px', color: '#64748b' }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentSheetName = sheetNames[activeSheetIndex] || '';
|
||||
const rows = sheetData[currentSheetName] || [];
|
||||
|
||||
// 计算最大列数
|
||||
const maxCols = rows.reduce((max, r) => Math.max(max, r.length), 0);
|
||||
|
||||
// 生成 Excel 列标:A, B, C ... Z, AA, AB...
|
||||
const getColLetter = (index: number): string => {
|
||||
let letter = '';
|
||||
let temp = index;
|
||||
while (temp >= 0) {
|
||||
letter = String.fromCharCode((temp % 26) + 65) + letter;
|
||||
temp = Math.floor(temp / 26) - 1;
|
||||
}
|
||||
return letter;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: '#ffffff',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 顶部工作表概要提示 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '12px',
|
||||
color: '#64748b',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<TableIcon size={14} color="#10b981" />
|
||||
<span>工作表:<strong>{currentSheetName}</strong></span>
|
||||
<span>·</span>
|
||||
<span>{rows.length} 行 × {maxCols} 列</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#94a3b8' }}>
|
||||
共 {sheetNames.length} 个工作表
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表格主体渲染区 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
background: '#ffffff',
|
||||
}}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: '#94a3b8',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
当前工作表为空
|
||||
</div>
|
||||
) : (
|
||||
<table
|
||||
style={{
|
||||
borderCollapse: 'collapse',
|
||||
width: 'max-content',
|
||||
minWidth: '100%',
|
||||
fontSize: '12px',
|
||||
color: '#1e293b',
|
||||
tableLayout: 'auto',
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ background: '#f1f5f9', position: 'sticky', top: 0, zIndex: 5 }}>
|
||||
{/* 左上角行号占位列 */}
|
||||
<th
|
||||
style={{
|
||||
width: '42px',
|
||||
minWidth: '42px',
|
||||
padding: '6px 8px',
|
||||
textAlign: 'center',
|
||||
borderRight: '1px solid #cbd5e1',
|
||||
borderBottom: '1px solid #cbd5e1',
|
||||
color: '#64748b',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
background: '#e2e8f0',
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
zIndex: 6,
|
||||
}}
|
||||
>
|
||||
#
|
||||
</th>
|
||||
{Array.from({ length: maxCols }).map((_, cIdx) => (
|
||||
<th
|
||||
key={cIdx}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
minWidth: '100px',
|
||||
textAlign: 'left',
|
||||
borderRight: '1px solid #e2e8f0',
|
||||
borderBottom: '1px solid #cbd5e1',
|
||||
color: '#475569',
|
||||
fontWeight: 600,
|
||||
background: '#f1f5f9',
|
||||
}}
|
||||
>
|
||||
{getColLetter(cIdx)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, rIdx) => (
|
||||
<tr
|
||||
key={rIdx}
|
||||
style={{
|
||||
background: rIdx % 2 === 0 ? '#ffffff' : '#f8fafc',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{/* 左侧固定行号 */}
|
||||
<td
|
||||
style={{
|
||||
width: '42px',
|
||||
minWidth: '42px',
|
||||
padding: '6px 8px',
|
||||
textAlign: 'center',
|
||||
borderRight: '1px solid #cbd5e1',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
color: '#94a3b8',
|
||||
fontSize: '11px',
|
||||
background: '#f8fafc',
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
zIndex: 3,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{rIdx + 1}
|
||||
</td>
|
||||
{Array.from({ length: maxCols }).map((_, cIdx) => {
|
||||
const cellVal = row[cIdx] !== undefined ? String(row[cIdx]) : '';
|
||||
return (
|
||||
<td
|
||||
key={cIdx}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRight: '1px solid #f1f5f9',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '300px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
title={cellVal}
|
||||
>
|
||||
{cellVal}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部 Sheet 选项卡切换条 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
background: '#f1f5f9',
|
||||
borderTop: '1px solid #e2e8f0',
|
||||
overflowX: 'auto',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{sheetNames.map((name, idx) => {
|
||||
const isActive = idx === activeSheetIndex;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => setActiveSheetIndex(idx)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '6px 12px',
|
||||
border: 'none',
|
||||
borderRadius: '6px 6px 0 0',
|
||||
background: isActive ? '#ffffff' : 'transparent',
|
||||
color: isActive ? '#0f172a' : '#64748b',
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
boxShadow: isActive ? '0 -1px 4px rgba(0,0,0,0.05)' : 'none',
|
||||
borderTop: isActive ? '2px solid #10b981' : '2px solid transparent',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<TableIcon size={12} color={isActive ? '#10b981' : '#94a3b8'} />
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ZoomIn, ZoomOut, RotateCw, Maximize2, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface ImageViewerProps {
|
||||
fileRaw?: File | Blob;
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export const ImageViewer: React.FC<ImageViewerProps> = ({ fileRaw, fileUrl }) => {
|
||||
const [src, setSrc] = useState<string>('');
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let createdUrl: string | null = null;
|
||||
if (fileUrl) {
|
||||
setSrc(fileUrl);
|
||||
} else if (fileRaw) {
|
||||
try {
|
||||
createdUrl = URL.createObjectURL(fileRaw);
|
||||
setSrc(createdUrl);
|
||||
} catch (err: any) {
|
||||
setError('无法生成图片预览对象');
|
||||
}
|
||||
} else {
|
||||
setError('未检测到有效图像内容');
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (createdUrl && typeof window !== 'undefined' && typeof window.URL?.revokeObjectURL === 'function') {
|
||||
window.URL.revokeObjectURL(createdUrl);
|
||||
}
|
||||
};
|
||||
}, [fileRaw, fileUrl]);
|
||||
|
||||
const handleZoomIn = () => setZoom((prev) => Math.min(prev + 0.25, 4));
|
||||
const handleZoomOut = () => setZoom((prev) => Math.max(prev - 0.25, 0.25));
|
||||
const handleReset = () => {
|
||||
setZoom(1);
|
||||
setRotation(0);
|
||||
};
|
||||
const handleRotate = () => setRotation((prev) => (prev + 90) % 360);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
background: 'var(--bg-subtle, #f8fafc)',
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '440px',
|
||||
padding: '24px',
|
||||
background: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={36} color="#dc2626" style={{ margin: '0 auto 12px' }} />
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: '15px', fontWeight: 600 }}>图片预览失败</h4>
|
||||
<p style={{ margin: '0', fontSize: '13px', color: '#64748b' }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#18181b',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 顶部悬浮工具栏 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
background: 'rgba(24, 24, 27, 0.85)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
borderRadius: '24px',
|
||||
padding: '6px 14px',
|
||||
zIndex: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
title="放大"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#e4e4e7',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<ZoomIn size={16} />
|
||||
</button>
|
||||
<span style={{ fontSize: '12px', color: '#a1a1aa', minWidth: '40px', textAlign: 'center' }}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
title="缩小"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#e4e4e7',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<ZoomOut size={16} />
|
||||
</button>
|
||||
<div style={{ width: '1px', height: '14px', background: 'rgba(255,255,255,0.2)' }} />
|
||||
<button
|
||||
onClick={handleRotate}
|
||||
title="旋转 90°"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#e4e4e7',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<RotateCw size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
title="适应还原"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#e4e4e7',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 图片主视口 (带透明棋盘底纹) */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'auto',
|
||||
padding: '48px 24px',
|
||||
backgroundImage:
|
||||
'linear-gradient(45deg, #27272a 25%, transparent 25%), linear-gradient(-45deg, #27272a 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #27272a 75%), linear-gradient(-45deg, transparent 75%, #27272a 75%)',
|
||||
backgroundSize: '20px 20px',
|
||||
backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px',
|
||||
}}
|
||||
>
|
||||
{src && (
|
||||
<img
|
||||
src={src}
|
||||
alt="Preview"
|
||||
style={{
|
||||
maxWidth: zoom === 1 ? '100%' : 'none',
|
||||
maxHeight: zoom === 1 ? '100%' : 'none',
|
||||
transform: `scale(${zoom}) rotate(${rotation}deg)`,
|
||||
transformOrigin: 'center center',
|
||||
transition: 'transform 0.2s ease',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 8px 30px rgba(0,0,0,0.5)',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
onError={() => setError('图片文件已损坏或不支持该编码格式')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface PdfViewerProps {
|
||||
fileRaw?: File | Blob;
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export const PdfViewer: React.FC<PdfViewerProps> = ({ fileRaw, fileUrl }) => {
|
||||
const [activeUrl, setActiveUrl] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let createdUrl: string | null = null;
|
||||
|
||||
if (fileUrl) {
|
||||
setActiveUrl(fileUrl);
|
||||
setLoading(false);
|
||||
} else if (fileRaw) {
|
||||
try {
|
||||
createdUrl = URL.createObjectURL(fileRaw);
|
||||
setActiveUrl(createdUrl);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('无法创建 PDF 预览链接:', err);
|
||||
setError('生成 PDF 预览地址失败');
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
setError('未检测到有效的 PDF 文件资源');
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (createdUrl && typeof window !== 'undefined' && typeof window.URL?.revokeObjectURL === 'function') {
|
||||
window.URL.revokeObjectURL(createdUrl);
|
||||
}
|
||||
};
|
||||
}, [fileRaw, fileUrl]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
background: 'var(--bg-subtle, #f8fafc)',
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '440px',
|
||||
padding: '24px',
|
||||
background: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={36} color="#dc2626" style={{ margin: '0 auto 12px' }} />
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: '15px', fontWeight: 600 }}>PDF 预览加载失败</h4>
|
||||
<p style={{ margin: '0 0 12px', fontSize: '13px', color: '#64748b' }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: '#525659',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#ffffff',
|
||||
zIndex: 10,
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<Loader2 size={32} className="animate-spin" style={{ color: 'var(--primary, #2563eb)' }} />
|
||||
<span style={{ fontSize: '13px', color: '#64748b' }}>正在载入 PDF 文档...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeUrl && (
|
||||
<iframe
|
||||
src={`${activeUrl}#toolbar=1&navpanes=1&statusbar=1`}
|
||||
title="PDF Document Preview"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
}}
|
||||
onLoad={() => setLoading(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
import React from 'react';
|
||||
import { Presentation, Download, MonitorPlay, Layers } from 'lucide-react';
|
||||
|
||||
interface PptViewerProps {
|
||||
fileRaw?: File | Blob;
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
fileSize?: string;
|
||||
}
|
||||
|
||||
export const PptViewer: React.FC<PptViewerProps> = ({ fileRaw, fileUrl, fileName = '演示文稿.pptx', fileSize = '未知大小' }) => {
|
||||
|
||||
const handleDownload = () => {
|
||||
if (fileUrl) {
|
||||
const a = document.createElement('a');
|
||||
a.href = fileUrl;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
} else if (fileRaw) {
|
||||
const url = URL.createObjectURL(fileRaw);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'var(--bg-subtle, #f8fafc)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 顶部简报条 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 20px',
|
||||
background: '#ffffff',
|
||||
borderBottom: '1px solid var(--border-light, #e2e8f0)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
background: '#fff1f2',
|
||||
color: '#e11d48',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Presentation size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 600, color: '#0f172a' }}>{fileName}</div>
|
||||
<div style={{ fontSize: '11px', color: '#64748b' }}>PowerPoint 演示文稿 · {fileSize}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #cbd5e1',
|
||||
background: '#ffffff',
|
||||
color: '#334155',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Download size={14} />
|
||||
下载原始幻灯片
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 幻灯片大纲与预览主视口 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '32px 24px',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '680px',
|
||||
background: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid var(--border-light, #e2e8f0)',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.06)',
|
||||
padding: '32px 28px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
borderRadius: '16px',
|
||||
background: 'linear-gradient(135deg, #f43f5e 0%, #e11d48 100%)',
|
||||
color: '#ffffff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 18px',
|
||||
boxShadow: '0 4px 12px rgba(225, 29, 72, 0.25)',
|
||||
}}
|
||||
>
|
||||
<MonitorPlay size={32} />
|
||||
</div>
|
||||
|
||||
<h3 style={{ margin: '0 0 10px', fontSize: '17px', fontWeight: 600, color: '#0f172a' }}>
|
||||
PowerPoint 幻灯片已安全就绪
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: '0 auto 24px',
|
||||
maxWidth: '480px',
|
||||
fontSize: '13px',
|
||||
color: '#64748b',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
为保护复杂的演示动画、母版排版与演讲者备注,本系统采用合规沙箱隔离。您可随时将此幻灯片一键共享至项目参考库,或下载原件进行放映。
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 16px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
fontSize: '12px',
|
||||
color: '#475569',
|
||||
}}
|
||||
>
|
||||
<Layers size={14} color="#64748b" />
|
||||
<span>标准 16:9 宽屏演示文稿格式</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: 'var(--primary, #2563eb)',
|
||||
color: '#ffffff',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(37, 99, 235, 0.25)',
|
||||
}}
|
||||
>
|
||||
<Download size={16} />
|
||||
获取 PPTX 原件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Loader2, AlertCircle, Copy, Check } from 'lucide-react';
|
||||
|
||||
interface TextViewerProps {
|
||||
fileRaw?: File | Blob;
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export const TextViewer: React.FC<TextViewerProps> = ({ fileRaw, fileUrl }) => {
|
||||
const [content, setContent] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadText() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let text = '';
|
||||
if (fileRaw) {
|
||||
text = await fileRaw.text();
|
||||
} else if (fileUrl) {
|
||||
const res = await fetch(fileUrl);
|
||||
if (!res.ok) throw new Error(`无法获取文本内容: ${res.statusText}`);
|
||||
text = await res.text();
|
||||
} else {
|
||||
throw new Error('未检测到有效文本资源');
|
||||
}
|
||||
|
||||
if (isMounted) {
|
||||
setContent(text);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (isMounted) {
|
||||
console.error('TextViewer 读取失败:', err);
|
||||
setError(err?.message || '文本解析失败');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadText();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [fileRaw, fileUrl]);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
background: 'var(--bg-subtle, #f8fafc)',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<Loader2 size={32} className="animate-spin" style={{ color: 'var(--primary, #2563eb)' }} />
|
||||
<span style={{ fontSize: '13px', color: '#64748b' }}>正在载入文本内容...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: '400px', textAlign: 'center' }}>
|
||||
<AlertCircle size={32} color="#dc2626" style={{ margin: '0 auto 8px' }} />
|
||||
<p style={{ color: '#dc2626', fontSize: '14px' }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: '#ffffff',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 顶部工具栏 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '12px',
|
||||
color: '#64748b',
|
||||
}}
|
||||
>
|
||||
<span>共 {lines.length} 行 · {content.length} 字符</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
background: '#ffffff',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
color: copied ? '#10b981' : '#475569',
|
||||
}}
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
{copied ? '已复制' : '复制全文'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 文本内容视口 (带行号) */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '16px',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1.6,
|
||||
background: '#ffffff',
|
||||
color: '#1e293b',
|
||||
}}
|
||||
>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||
<tbody>
|
||||
{lines.map((line, idx) => (
|
||||
<tr key={idx} style={{ hover: { background: '#f1f5f9' } } as any}>
|
||||
<td
|
||||
style={{
|
||||
width: '40px',
|
||||
paddingRight: '16px',
|
||||
textAlign: 'right',
|
||||
color: '#94a3b8',
|
||||
userSelect: 'none',
|
||||
verticalAlign: 'top',
|
||||
}}
|
||||
>
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all', verticalAlign: 'top' }}>
|
||||
{line || ' '}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* fileStore.ts
|
||||
* 基于 IndexedDB 的用户上传文件二进制内容持久化存储。
|
||||
*
|
||||
* 解决的问题:
|
||||
* 浏览器 localStorage 只能存储字符串(JSON),File/Blob 对象无法序列化。
|
||||
* 用户上传文档后若刷新页面,内存中的 File 对象会被释放,导致预览和下载失效。
|
||||
*
|
||||
* 方案:
|
||||
* 使用 IndexedDB 以 docId 为 key 存储文件的 ArrayBuffer(二进制内容)及元数据。
|
||||
* 刷新后读取 IndexedDB,重建 Blob 对象,恢复 fileMeta.fileRaw。
|
||||
*
|
||||
* API:
|
||||
* saveFile(docId, file) — 上传时保存文件内容
|
||||
* getFile(docId) — 获取文件,返回 Blob | null
|
||||
* deleteFile(docId) — 删除已归档文件(可选清理)
|
||||
* restoreFileRawToDeliverables — 启动时批量恢复所有 uploaded_file 文档的 fileRaw
|
||||
*/
|
||||
|
||||
import { DeliverableItem } from '../types/workbench';
|
||||
|
||||
const DB_NAME = 'smdf_file_store';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'uploaded_files';
|
||||
|
||||
interface StoredFileEntry {
|
||||
docId: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
buffer: ArrayBuffer;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 内部:打开/初始化 IndexedDB
|
||||
// ──────────────────────────────────────────────────────────
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
reject(new Error('IndexedDB not available'));
|
||||
return;
|
||||
}
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = (e) => {
|
||||
const db = (e.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'docId' });
|
||||
}
|
||||
};
|
||||
req.onsuccess = (e) => resolve((e.target as IDBOpenDBRequest).result);
|
||||
req.onerror = (e) => reject((e.target as IDBOpenDBRequest).error);
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 公共 API
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 将用户上传的文件保存到 IndexedDB。
|
||||
* 在 handleSendMessage 处理附件时调用(与注册 deliverable 同步进行)。
|
||||
*/
|
||||
export async function saveFile(docId: string, file: File | Blob, name?: string): Promise<void> {
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const entry: StoredFileEntry = {
|
||||
docId,
|
||||
name: name || (file instanceof File ? file.name : ''),
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
buffer,
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
const req = store.put(entry);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = (e) => reject((e.target as IDBRequest).error);
|
||||
tx.oncomplete = () => db.close();
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[fileStore] saveFile 失败:', docId, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 IndexedDB 读取文件,返回 Blob(携带 mimeType)。
|
||||
* 返回 null 表示文件未存储或读取失败。
|
||||
*/
|
||||
export async function getFile(docId: string): Promise<Blob | null> {
|
||||
try {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.get(docId);
|
||||
req.onsuccess = (e) => {
|
||||
const entry = (e.target as IDBRequest<StoredFileEntry | undefined>).result;
|
||||
if (!entry) {
|
||||
resolve(null);
|
||||
} else {
|
||||
resolve(new Blob([entry.buffer], { type: entry.mimeType }));
|
||||
}
|
||||
db.close();
|
||||
};
|
||||
req.onerror = (e) => {
|
||||
reject((e.target as IDBRequest).error);
|
||||
db.close();
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[fileStore] getFile 失败:', docId, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定 docId 的文件(用于用户手动删除文档时的清理)。
|
||||
*/
|
||||
export async function deleteFile(docId: string): Promise<void> {
|
||||
try {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).delete(docId);
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[fileStore] deleteFile 失败:', docId, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量从 IndexedDB 恢复 fileRaw:
|
||||
* 找出所有 sourceType === 'uploaded_file' 且 fileMeta 存在但 fileRaw 缺失的 deliverables,
|
||||
* 逐一从 IndexedDB 取回 Blob,填入 fileMeta.fileRaw。
|
||||
*
|
||||
* 用法:App 启动时(loadDeliverables 之后)调用,返回修复后的数组。
|
||||
* 若无任何文件需要恢复,直接返回原数组(引用不变,不触发 re-render)。
|
||||
*/
|
||||
export async function restoreFileRawToDeliverables(
|
||||
deliverables: DeliverableItem[]
|
||||
): Promise<DeliverableItem[]> {
|
||||
// 找出需要恢复的文档
|
||||
const toRestore = deliverables.filter(
|
||||
(d) =>
|
||||
d.sourceType === 'uploaded_file' &&
|
||||
d.fileMeta &&
|
||||
!d.fileMeta.fileRaw
|
||||
);
|
||||
|
||||
if (toRestore.length === 0) return deliverables;
|
||||
|
||||
// 并发读取 IndexedDB
|
||||
const results = await Promise.all(
|
||||
toRestore.map(async (d) => ({
|
||||
docId: d.docId,
|
||||
blob: await getFile(d.docId),
|
||||
}))
|
||||
);
|
||||
|
||||
// 检查是否有任何恢复成功
|
||||
const anyRestored = results.some((r) => r.blob !== null);
|
||||
if (!anyRestored) return deliverables;
|
||||
|
||||
// 构建修复后的数组
|
||||
const blobMap = new Map(results.map((r) => [r.docId, r.blob]));
|
||||
return deliverables.map((d) => {
|
||||
const blob = blobMap.get(d.docId);
|
||||
if (!blob || !d.fileMeta) return d;
|
||||
return {
|
||||
...d,
|
||||
fileMeta: {
|
||||
...d.fileMeta,
|
||||
fileRaw: blob,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
+14
-1
@@ -626,7 +626,20 @@ export function loadDeliverables(fallback: DeliverableItem[]): DeliverableItem[]
|
||||
}
|
||||
|
||||
export function saveDeliverables(deliverables: DeliverableItem[]): void {
|
||||
safeSetItem(STORAGE_KEYS.DELIVERABLES, deliverables);
|
||||
// File/Blob 对象无法 JSON 序列化(会静默变成 {}),blob: URL 刷新后失效。
|
||||
// 持久化时剔除这两项,避免 localStorage 存储损坏数据。
|
||||
// 内存中的 deliverables react state 不受影响,当前会话内仍可正常预览。
|
||||
const serializableDocs = deliverables.map((d) => {
|
||||
if (!d.fileMeta) return d;
|
||||
const { fileRaw, fileUrl, ...restFileMeta } = d.fileMeta;
|
||||
const cleanedFileMeta = {
|
||||
...restFileMeta,
|
||||
// 仅保留非 blob: URL(如 /api/... 服务端 URL),页面刷新后依然有效
|
||||
...(fileUrl && !fileUrl.startsWith('blob:') ? { fileUrl } : {}),
|
||||
};
|
||||
return { ...d, fileMeta: cleanedFileMeta };
|
||||
});
|
||||
safeSetItem(STORAGE_KEYS.DELIVERABLES, serializableDocs);
|
||||
}
|
||||
|
||||
export function loadDocCache(): Record<string, string> {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { DeliverableItem } from '../types/workbench';
|
||||
|
||||
describe('用户上传文档多格式在线预览体系与渲染分流测试', () => {
|
||||
it('1. 上传附件时正确注入 sourceType: "uploaded_file" 与完整 fileMeta 元数据', () => {
|
||||
const mockFileRaw = new Blob(['mock binary docx content'], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
});
|
||||
const mockFileUrl = 'blob:http://localhost:5173/mock-uuid-docx';
|
||||
|
||||
const uploadedDeliverable: DeliverableItem = {
|
||||
docId: 'doc-upload-1726000000-0',
|
||||
projectId: 1,
|
||||
taskKey: 'user_requirements',
|
||||
title: '高频空化钛合金刀头生物相容性验证报告',
|
||||
stage: 'V0.5',
|
||||
category: 'private',
|
||||
creatorId: 101,
|
||||
creatorName: '张研发',
|
||||
isFixedWorkflow: false,
|
||||
isTemplateOnly: false,
|
||||
lifecycleState: 'drafting',
|
||||
isClaimed: true,
|
||||
author: '张研发',
|
||||
reviewer: '李主管',
|
||||
approver: '王总监',
|
||||
updatedAt: '2026-09-11',
|
||||
space: 'my_private',
|
||||
governanceMode: 'freeform',
|
||||
sourceType: 'uploaded_file',
|
||||
fileMeta: {
|
||||
originalName: '高频空化钛合金刀头生物相容性验证报告.docx',
|
||||
size: '148 KB',
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
extension: 'docx',
|
||||
fileRaw: mockFileRaw,
|
||||
fileUrl: mockFileUrl,
|
||||
uploadedAt: '2026-09-11T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
assert.strictEqual(uploadedDeliverable.sourceType, 'uploaded_file');
|
||||
assert.strictEqual(uploadedDeliverable.fileMeta?.extension, 'docx');
|
||||
assert.strictEqual(uploadedDeliverable.fileMeta?.originalName, '高频空化钛合金刀头生物相容性验证报告.docx');
|
||||
assert.ok(uploadedDeliverable.fileMeta?.fileRaw);
|
||||
assert.strictEqual(uploadedDeliverable.fileMeta?.fileUrl, mockFileUrl);
|
||||
});
|
||||
|
||||
it('2. 扩展名分类规则准确分发至对应的高保真渲染器类型', () => {
|
||||
const classifyFileType = (ext: string): string => {
|
||||
const lower = ext.toLowerCase();
|
||||
if (['docx', 'doc'].includes(lower)) return 'docx';
|
||||
if (lower === 'pdf') return 'pdf';
|
||||
if (['xlsx', 'xls', 'csv'].includes(lower)) return 'excel';
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp'].includes(lower)) return 'image';
|
||||
if (['pptx', 'ppt'].includes(lower)) return 'ppt';
|
||||
if (['txt', 'md', 'json', 'xml', 'log', 'yaml', 'yml'].includes(lower)) return 'text';
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
// Word 家族
|
||||
assert.strictEqual(classifyFileType('docx'), 'docx');
|
||||
assert.strictEqual(classifyFileType('DOCX'), 'docx');
|
||||
assert.strictEqual(classifyFileType('doc'), 'docx');
|
||||
|
||||
// PDF
|
||||
assert.strictEqual(classifyFileType('pdf'), 'pdf');
|
||||
assert.strictEqual(classifyFileType('PDF'), 'pdf');
|
||||
|
||||
// Excel 家族
|
||||
assert.strictEqual(classifyFileType('xlsx'), 'excel');
|
||||
assert.strictEqual(classifyFileType('xls'), 'excel');
|
||||
assert.strictEqual(classifyFileType('csv'), 'excel');
|
||||
|
||||
// 图像家族
|
||||
assert.strictEqual(classifyFileType('png'), 'image');
|
||||
assert.strictEqual(classifyFileType('jpg'), 'image');
|
||||
assert.strictEqual(classifyFileType('jpeg'), 'image');
|
||||
assert.strictEqual(classifyFileType('svg'), 'image');
|
||||
assert.strictEqual(classifyFileType('webp'), 'image');
|
||||
|
||||
// PPT 家族
|
||||
assert.strictEqual(classifyFileType('pptx'), 'ppt');
|
||||
assert.strictEqual(classifyFileType('ppt'), 'ppt');
|
||||
|
||||
// 通用文本/代码家族
|
||||
assert.strictEqual(classifyFileType('txt'), 'text');
|
||||
assert.strictEqual(classifyFileType('json'), 'text');
|
||||
assert.strictEqual(classifyFileType('md'), 'text');
|
||||
|
||||
// 未知格式兜底
|
||||
assert.strictEqual(classifyFileType('bin'), 'unknown');
|
||||
});
|
||||
|
||||
it('3. 受控工作流交付物严格隔离:绝不命中 FilePreviewPanel,保持 BlockRenderer 纯净', () => {
|
||||
const workflowDeliverable: DeliverableItem = {
|
||||
docId: 'doc-workflow-urs-1',
|
||||
projectId: 1,
|
||||
taskKey: 'urs_spec',
|
||||
title: '产品用户需求规范 (URS)',
|
||||
stage: 'V0.1',
|
||||
category: 'workflow',
|
||||
creatorId: 101,
|
||||
creatorName: '张研发',
|
||||
isFixedWorkflow: true,
|
||||
isTemplateOnly: false,
|
||||
lifecycleState: 'drafting',
|
||||
isClaimed: true,
|
||||
author: '张研发',
|
||||
reviewer: '李主管',
|
||||
approver: '王总监',
|
||||
updatedAt: '2026-09-11',
|
||||
space: 'project_shared',
|
||||
governanceMode: 'controlled',
|
||||
sourceType: 'workflow',
|
||||
};
|
||||
|
||||
const isUploadedFile =
|
||||
workflowDeliverable.sourceType === 'uploaded_file' || Boolean(workflowDeliverable.fileMeta);
|
||||
|
||||
assert.strictEqual(isUploadedFile, false, '工作流受控文档绝对不可被判定为上传文件');
|
||||
assert.strictEqual(workflowDeliverable.category, 'workflow');
|
||||
assert.strictEqual(workflowDeliverable.isFixedWorkflow, true);
|
||||
});
|
||||
|
||||
it('4. 上传文档支持受控流转:从个人私密一键共享至项目参考库后,在线预览依然可用且权限标态正确', () => {
|
||||
let doc: DeliverableItem = {
|
||||
docId: 'doc-upload-2026',
|
||||
projectId: 1,
|
||||
taskKey: 'user_requirements',
|
||||
title: '内窥镜镜体光学衰减测试数据',
|
||||
stage: 'V0.5',
|
||||
category: 'private',
|
||||
creatorId: 101,
|
||||
creatorName: '张研发',
|
||||
isFixedWorkflow: false,
|
||||
isTemplateOnly: false,
|
||||
lifecycleState: 'drafting',
|
||||
isClaimed: true,
|
||||
author: '张研发',
|
||||
reviewer: '李主管',
|
||||
approver: '王总监',
|
||||
updatedAt: '2026-09-11',
|
||||
space: 'my_private',
|
||||
governanceMode: 'freeform',
|
||||
sourceType: 'uploaded_file',
|
||||
fileMeta: {
|
||||
originalName: '内窥镜镜体光学衰减测试数据.xlsx',
|
||||
size: '52 KB',
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
extension: 'xlsx',
|
||||
uploadedAt: '2026-09-11T12:10:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
// 初始状态:个人私密
|
||||
assert.strictEqual(doc.space, 'my_private');
|
||||
assert.strictEqual(doc.category, 'private');
|
||||
assert.strictEqual(doc.sourceType, 'uploaded_file');
|
||||
|
||||
// 模拟一键共享至项目组
|
||||
doc = {
|
||||
...doc,
|
||||
space: 'project_shared',
|
||||
category: 'shared',
|
||||
sharedAt: '2026-09-11T12:15:00Z',
|
||||
};
|
||||
|
||||
assert.strictEqual(doc.space, 'project_shared');
|
||||
assert.strictEqual(doc.category, 'shared');
|
||||
assert.strictEqual(doc.sourceType, 'uploaded_file');
|
||||
assert.strictEqual(doc.fileMeta?.extension, 'xlsx');
|
||||
|
||||
// 模拟撤回为个人私密
|
||||
doc = {
|
||||
...doc,
|
||||
space: 'my_private',
|
||||
category: 'private',
|
||||
sharedAt: undefined,
|
||||
};
|
||||
|
||||
assert.strictEqual(doc.space, 'my_private');
|
||||
assert.strictEqual(doc.category, 'private');
|
||||
assert.strictEqual(doc.sharedAt, undefined);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user