From 222b0c2dd6e1df46c49a27b86bfc5a2df8828983 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Fri, 11 Sep 2026 12:58:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(doc-preview):=20=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=A4=9A=E6=A0=BC=E5=BC=8F=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E9=A2=84=E8=A7=88=20+=20IndexedDB=20=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 新增功能 - 支持用户上传的 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 — 预览功能单元测试 --- web/package-lock.json | 10 + web/package.json | 1 + web/src/App.tsx | 385 ++++++++-- .../doc/preview/FilePreviewPanel.tsx | 662 ++++++++++++++++++ .../doc/preview/renderers/DocxViewer.tsx | 176 +++++ .../doc/preview/renderers/ExcelViewer.tsx | 356 ++++++++++ .../doc/preview/renderers/ImageViewer.tsx | 204 ++++++ .../doc/preview/renderers/PdfViewer.tsx | 115 +++ .../doc/preview/renderers/PptViewer.tsx | 212 ++++++ .../doc/preview/renderers/TextViewer.tsx | 184 +++++ web/src/data/fileStore.ts | 183 +++++ web/src/data/storage.ts | 15 +- web/src/tests/file-preview.test.ts | 187 +++++ 13 files changed, 2633 insertions(+), 57 deletions(-) create mode 100644 web/src/components/doc/preview/FilePreviewPanel.tsx create mode 100644 web/src/components/doc/preview/renderers/DocxViewer.tsx create mode 100644 web/src/components/doc/preview/renderers/ExcelViewer.tsx create mode 100644 web/src/components/doc/preview/renderers/ImageViewer.tsx create mode 100644 web/src/components/doc/preview/renderers/PdfViewer.tsx create mode 100644 web/src/components/doc/preview/renderers/PptViewer.tsx create mode 100644 web/src/components/doc/preview/renderers/TextViewer.tsx create mode 100644 web/src/data/fileStore.ts create mode 100644 web/src/tests/file-preview.test.ts diff --git a/web/package-lock.json b/web/package-lock.json index 05383152..9a695a65 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index e1a8d467..1535aa50 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/App.tsx b/web/src/App.tsx index f9477eb0..b4aa734c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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('workflow'); // 项目与成员体系状态 (支持持久化) @@ -541,8 +561,13 @@ export default function App() { const [activeDocId, setActiveDocId] = useState(() => { 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(() => { + // 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 = {}; + 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() { }} /> - {/* 块状文档渲染器 */} - { - 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 ( + { + setTargetDocForVisibility(activeDeliverable); + setDocVisibilityModalOpen(true); + }} + /> ); - }} - highlightedBlockId={highlightedBlockId} - /> + } + return ( + { + 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' ? ( diff --git a/web/src/components/doc/preview/FilePreviewPanel.tsx b/web/src/components/doc/preview/FilePreviewPanel.tsx new file mode 100644 index 00000000..71d4b914 --- /dev/null +++ b/web/src/components/doc/preview/FilePreviewPanel.tsx @@ -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 = ({ + 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: , + label: 'Word 文档', + color: '#2563eb', + bg: '#eff6ff', + border: '#bfdbfe', + }; + case 'excel': + return { + icon: , + label: 'Excel 表格', + color: '#10b981', + bg: '#ecfdf5', + border: '#a7f3d0', + }; + case 'pdf': + return { + icon: , + label: 'PDF 文档', + color: '#ea580c', + bg: '#fff7ed', + border: '#fed7aa', + }; + case 'ppt': + return { + icon: , + label: 'PowerPoint 演示', + color: '#e11d48', + bg: '#fff1f2', + border: '#fecdd3', + }; + case 'image': + return { + icon: , + label: '图像文件', + color: '#8b5cf6', + bg: '#f5f3ff', + border: '#ddd6fe', + }; + case 'text': + return { + icon: , + label: '纯文本/代码', + color: '#475569', + bg: '#f8fafc', + border: '#e2e8f0', + }; + default: + return { + icon: , + 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 = () => ( +
+
+ ⚠️ +
+

+ 文件内容已释放,无法预览 +

+

+ 浏览器本地文件({fileName})仅在上传当次会话内保留于内存。 + 页面刷新后,内存中的文件内容会被浏览器释放,无法再次预览或下载。 +

+

+ 文档的元数据(文件名、大小、权限)已持久化保存。如需重新预览,请在下方对话框中再次上传同名文件。 +

+
+
+ 📁 + 文件名:{fileName} +
+
+ 📏 + 大小:{fileSize} +
+
+
+ ); + + // 渲染对应的文档预览子组件 + const renderViewer = () => { + // 文件内容不可用(刷新后 File/Blob 丢失) + if (!hasFileContent) { + return renderNoContentPlaceholder(); + } + switch (fileType) { + case 'docx': + return ; + case 'pdf': + return ; + case 'excel': + return ; + case 'image': + return ; + case 'ppt': + return ; + case 'text': + return ; + default: + return ( +
+
+ +
+

+ 该格式暂不支持直接内嵌渲染 +

+

+ 文件「{fileName}」为 .{ext} 格式。您可以直接下载原始文件查看。 +

+ +
+ ); + } + }; + + return ( +
+ {/* 顶部多功能操作与元数据横栏 */} +
+ {/* 左侧:文件格式标与文件名 */} +
+
+ {badge.icon} +
+ +
+
+ {fileName} +
+
+ {badge.label} + · + {fileSize} + · + + {deliverable.space === 'project_shared' ? : } + {deliverable.space === 'project_shared' ? '项目共享' : '个人私密'} + +
+
+
+ + {/* 右侧:模式切换与快捷动作 */} +
+ {/* 预览模式 / 文档信息 切换按钮 */} +
+ + +
+ + {/* 下载原件按钮:文件内容不可用时禁用 */} + + + {/* 全屏放大按钮 */} + +
+
+ + {/* 主展示区:依据模式渲染 在线正文预览 或 属性与说明 */} +
+ {viewMode === 'preview' ? ( + renderViewer() + ) : ( + /* 属性与说明模式卡片视图 */ +
+
+

+ 文档基本信息与权限控制 +

+ +
+
+
文件全名
+
{fileName}
+
+
+
文件大小
+
{fileSize}
+
+
+
上传编制人
+
{deliverable.creatorName || deliverable.author}
+
+
+
更新日期
+
{deliverable.updatedAt}
+
+
+ +

+ 受控流转状态与协作红线说明 +

+ +
+ {deliverable.space === 'project_shared' ? ( +
+ 👥 当前状态:项目共享文档 +
本项目内所有已授权研发成员均可在线预览此文档,并引用作为研发输入参考。
+
+ ) : ( +
+ 🔒 当前状态:个人私密文档 (仅本人可见) +
该文件目前仅存储在您的个人空间,未对项目组成员开放。您可通过底部操作栏「一键共享给项目组」将其公开至项目参考库。
+
+ )} + {onOpenDocVisibility && ( +
+ +
+ )} +
+ + {deliverable.summary && ( +
+

+ 文档摘要记录 +

+
+ {deliverable.summary} +
+
+ )} +
+
+ )} +
+
+ ); +}; diff --git a/web/src/components/doc/preview/renderers/DocxViewer.tsx b/web/src/components/doc/preview/renderers/DocxViewer.tsx new file mode 100644 index 00000000..7490d0bc --- /dev/null +++ b/web/src/components/doc/preview/renderers/DocxViewer.tsx @@ -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 = ({ fileRaw, fileUrl, fileName }) => { + const containerRef = useRef(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ {loading && ( +
+ + + 正在安全解析 Word (.docx) 排版中... + +
+ )} + + {error && ( +
+
+ +
+

+ {error?.includes('已释放') ? '文件内容已释放,无法预览' : 'Word 文档在线解析异常'} +

+

+ {error} +

+
+ {error?.includes('已释放') + ? '请在下方对话框中重新上传该文件即可恢复预览。' + : '您可以尝试直接在上方操作栏点击「下载原始文件」使用本机 Office 打开。'} +
+
+ )} + +
+
+ ); +}; diff --git a/web/src/components/doc/preview/renderers/ExcelViewer.tsx b/web/src/components/doc/preview/renderers/ExcelViewer.tsx new file mode 100644 index 00000000..272d45ff --- /dev/null +++ b/web/src/components/doc/preview/renderers/ExcelViewer.tsx @@ -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 = ({ fileRaw, fileUrl }) => { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [sheetNames, setSheetNames] = useState([]); + const [activeSheetIndex, setActiveSheetIndex] = useState(0); + const [sheetData, setSheetData] = useState>({}); + + 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 = {}; + 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 ( +
+ + + 正在安全解析 Excel 工作表结构... + +
+ ); + } + + if (error) { + return ( +
+
+ +

Excel 表格在线解析异常

+

{error}

+
+
+ ); + } + + 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 ( +
+ {/* 顶部工作表概要提示 */} +
+
+ + 工作表:{currentSheetName} + · + {rows.length} 行 × {maxCols} 列 +
+
+ 共 {sheetNames.length} 个工作表 +
+
+ + {/* 表格主体渲染区 */} +
+ {rows.length === 0 ? ( +
+ 当前工作表为空 +
+ ) : ( + + + + {/* 左上角行号占位列 */} + + {Array.from({ length: maxCols }).map((_, cIdx) => ( + + ))} + + + + {rows.map((row, rIdx) => ( + + {/* 左侧固定行号 */} + + {Array.from({ length: maxCols }).map((_, cIdx) => { + const cellVal = row[cIdx] !== undefined ? String(row[cIdx]) : ''; + return ( + + ); + })} + + ))} + +
+ # + + {getColLetter(cIdx)} +
+ {rIdx + 1} + + {cellVal} +
+ )} +
+ + {/* 底部 Sheet 选项卡切换条 */} +
+ {sheetNames.map((name, idx) => { + const isActive = idx === activeSheetIndex; + return ( + + ); + })} +
+
+ ); +}; diff --git a/web/src/components/doc/preview/renderers/ImageViewer.tsx b/web/src/components/doc/preview/renderers/ImageViewer.tsx new file mode 100644 index 00000000..5e3d644a --- /dev/null +++ b/web/src/components/doc/preview/renderers/ImageViewer.tsx @@ -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 = ({ fileRaw, fileUrl }) => { + const [src, setSrc] = useState(''); + const [zoom, setZoom] = useState(1); + const [rotation, setRotation] = useState(0); + const [error, setError] = useState(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 ( +
+
+ +

图片预览失败

+

{error}

+
+
+ ); + } + + return ( +
+ {/* 顶部悬浮工具栏 */} +
+ + + {Math.round(zoom * 100)}% + + +
+ + +
+ + {/* 图片主视口 (带透明棋盘底纹) */} +
+ {src && ( + Preview setError('图片文件已损坏或不支持该编码格式')} + /> + )} +
+
+ ); +}; diff --git a/web/src/components/doc/preview/renderers/PdfViewer.tsx b/web/src/components/doc/preview/renderers/PdfViewer.tsx new file mode 100644 index 00000000..c8bec3b2 --- /dev/null +++ b/web/src/components/doc/preview/renderers/PdfViewer.tsx @@ -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 = ({ fileRaw, fileUrl }) => { + const [activeUrl, setActiveUrl] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+ +

PDF 预览加载失败

+

{error}

+
+
+ ); + } + + return ( +
+ {loading && ( +
+ + 正在载入 PDF 文档... +
+ )} + + {activeUrl && ( +