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:
WeiCHEN
2026-09-13 02:50:21 +00:00
committed by weichen
parent 24478ec383
commit 222b0c2dd6
13 changed files with 2633 additions and 57 deletions
+10
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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>
);
};
+183
View File
@@ -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
View File
@@ -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> {
+187
View File
@@ -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);
});
});