feat(web): load export, preview, and charts only when opened
The login shell was downloading the document engines with the workbench. Those modules now load on click or when a matching block appears, and a failed chunk can be retried without submitting an edit. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-1
@@ -18,7 +18,7 @@
|
||||
| Cursor | audit-b2-authz-hardening | fix/audit-b2-authz-hardening | - | authz | done |
|
||||
| Cursor | audit-b3-sse-events | fix/audit-b3-sse-events | - | chat+events | done |
|
||||
| Cursor | audit-b4-frontend | fix/audit-b4-frontend | - | web | done |
|
||||
| Cursor | audit-remainder-backlog | docs/audit-remediation-backlog | - | audit | done |
|
||||
| Cursor-Auto | 2026-09-19-audit-remainder-closeout | fix/audit-remainder-closeout | - | audit-remainder | in_progress |
|
||||
| Cursor-Auto | 2026-09-19-remove-control-center | fix/remove-control-center | - | controlcenter | done |
|
||||
| Cursor-Auto | 2026-09-17-control-center-server | feature/control-center-server | - | controlcenter | cancelled |
|
||||
| Kimi | 2026-09-18-user-manual | feature/user-manual | .worktrees/feature-user-manual | docs | done |
|
||||
|
||||
@@ -46,10 +46,10 @@ updated: 2026-09-19
|
||||
| FE 缓存 | done(版本字段)。窗口缓存写入 `{schema,data}`,无 schema 的旧载荷丢弃。`window cache: 无 schema` |
|
||||
| FE lucide | 关闭理由:`package-lock.json` 锁定 `lucide-react@1.42.0`,来源 `registry.npmjs.org`,带 integrity。`^1.16.0` 是合法的 1.x 范围,不是来源不明的包 |
|
||||
| M7 | done(出站意图 + 同键重放)。`document_content_projections` 与账本同一事务写入;投影成功才销账。`TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit`。账本库与正文库仍不是一个 SQL 事务,进程退出后靠未投影行重放,不靠客户端记忆 |
|
||||
| FE App.tsx | 仍开放。4600 行拆 hook 是独立重构,本轮未拆,避免在没有行为变更需求时改写工作台 |
|
||||
| FE App.tsx | 部分。登录壳不再静态引用导出引擎、文件预览、管理台、块编辑器和图表。入口包 2.83 MB → 1.00 MB(br 626 KB → 235 KB)。`loginShellImports.test.ts`。工作台主体仍在 `App.tsx`,未按 hook 拆文件 |
|
||||
| H1-del | 关闭理由:`allowLegacyTestPrincipal` 仍被 `AllowInMemory && ContextDbHost==""` 的 dev/test 内存测试使用。删除会把这批测试变成未登录 401。保留告警,删除另开、先改测试夹具 |
|
||||
|
||||
仍开放:FE `App.tsx` 拆分、rdev S2/S3 与 P2–P4、首次加载 U1/U3–U7、dev 容器重建与治理库 PG。本分支已跑通 `cd web && npm test`(tsx 407 + vitest 32,全过)和 `npm run build`(主包 `index-*.js` 仍约 2.83 MB,U3 拆包未做)。H2 批注/审计的项目成员检查在 Postgres 上走 `RequireProjectAccess`;内存授权器在无库时把任意正用户 id 视为成员,这是 `TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite` 的隔离前提,不能改成默认拒绝。H2 的 HTTP 负例因此要在 `TEST_DATABASE_URL` 上补,本机未设置该变量。M7 的投影意图已随账本提交(goose `00053`);容器 schema 仍是 52,要等占槽重建后才升到 53。
|
||||
仍开放:rdev S2/S3 与 P2–P4、首次加载 U1/U4–U7、dev 容器重建与治理库 PG。U3 只完成登录壳拆包(入口包 1.00 MB / br 235 KB),消息公式引擎仍在入口图里。本分支已跑通 `cd web && npm test` 与 `npm run build`。H2 批注/审计的项目成员检查在 Postgres 上走 `RequireProjectAccess`;内存授权器在无库时把任意正用户 id 视为成员,这是 `TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite` 的隔离前提,不能改成默认拒绝。H2 的 HTTP 负例因此要在 `TEST_DATABASE_URL` 上补,本机未设置该变量。M7 的投影意图已随账本提交(goose `00053`);容器 schema 仍是 52,要等占槽重建后才升到 53。
|
||||
|
||||
红线关闭(不是实现):
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
|
||||
## 2026-09-17 首次加载性能专项
|
||||
|
||||
首次加载与重启首访性能优化:**partial**(U2 静态交付已验收;不改变现有功能 done/发布门禁结论)。[审计](../audit/2026-09-17-initial-load-performance-audit.md) · [方案](../remediation/2026-09-17-initial-load-performance-plan.md) · [U2 报告](../test-reports/2026-09-17-initial-load-perf-u2-test-report.md)。U2:自托管字体 + br/gzip + assets 404;主 JS 传输 2.82 MB → ~624 KB。U1/U3–U7 未完成。mutex `web + static-delivery`(U2 done)/ `loading-performance-audit`。
|
||||
首次加载与重启首访性能优化:**partial**(U2 静态交付已验收;U3 只做了登录壳拆包,未关闭)。[审计](../audit/2026-09-17-initial-load-performance-audit.md) · [方案](../remediation/2026-09-17-initial-load-performance-plan.md) · [U2 报告](../test-reports/2026-09-17-initial-load-perf-u2-test-report.md)。U2:自托管字体 + br/gzip。U3 进展(`fix/audit-remainder-closeout`):导出、预览、管理台、块编辑器、Mermaid/公式改为点击或出现时再加载。入口包 `index-*.js` 由 2.83 MB / br 626 KB 降到 1.00 MB / br 235 KB。消息气泡里的公式引擎仍在入口图里。U1、U4–U7 未做。mutex `web + static-delivery`(U2 done)/ `loading-performance-audit`。
|
||||
|
||||
## 2026-09-17 文档编制人兼任方案
|
||||
|
||||
|
||||
+53
-33
@@ -5,7 +5,6 @@ import { InputArea } from './components/chat/InputArea';
|
||||
import { SlimTabBar } from './components/doc/SlimTabBar';
|
||||
import { DeckDrawer } from './components/doc/DeckDrawer';
|
||||
import { BlockRenderer } from './components/doc/BlockRenderer';
|
||||
import { BlockEditorModal } from './components/doc/BlockEditorModal';
|
||||
import { DocumentRevisionToolbar } from './components/doc/DocumentRevisionToolbar';
|
||||
import { CommentSidePanel } from './components/doc/CommentSidePanel';
|
||||
import { DocGovActionBar } from './components/doc/DocGovActionBar';
|
||||
@@ -106,17 +105,16 @@ import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice';
|
||||
import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner';
|
||||
import { deriveReplyTitle } from './features/replyExport/title';
|
||||
import { copyRichReplyToClipboard, copyMarkdownReply } from './features/replyExport/clipboard';
|
||||
import { downloadMarkdownReply, downloadDocxReply } from './features/replyExport/download';
|
||||
import { downloadMarkdownReply } from './features/replyExport/download';
|
||||
import { ProposalConfirmCard } from './components/doc/ProposalConfirmCard';
|
||||
import { UserProjectRightsModal } from './components/user/UserProjectRightsModal';
|
||||
import { UserSettingsModal } from './components/user/UserSettingsModal';
|
||||
import { AdminConsoleView } from './components/admin/console/AdminConsoleView';
|
||||
import { OnDemand } from './components/lazy/OnDemand';
|
||||
import { DocVisibilityModal } from './components/doc/DocVisibilityModal';
|
||||
import { CreateProjectModal, type CreateProjectConfirmData } from './components/project/CreateProjectModal';
|
||||
import { ProjectMembersModal } from './components/project/ProjectMembersModal';
|
||||
import { ProjectSettingsModal } from './components/project/ProjectSettingsModal';
|
||||
import { fetchProjectSettings } from './api/projectSettings';
|
||||
import { FilePreviewPanel } from './components/doc/preview/FilePreviewPanel';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { LoginView } from './components/auth/LoginView';
|
||||
import { FirstLoginPasswordModal } from './components/auth/FirstLoginPasswordModal';
|
||||
@@ -153,8 +151,6 @@ import {
|
||||
hasPermission,
|
||||
filterVisibleDeliverablesWithRBAC,
|
||||
} from './engine/rbac';
|
||||
import { exportDocumentToDocx } from './engine/docxRenderer';
|
||||
import { exportDocumentToXlsx } from './engine/xlsxRenderer';
|
||||
import {
|
||||
findMatchingBlockForReview,
|
||||
createRejectConfirmationCard,
|
||||
@@ -225,7 +221,6 @@ import { HistoryCalendar } from './components/history/HistoryCalendar';
|
||||
import { ChatSearchModal } from './components/history/ChatSearchModal';
|
||||
import { DigestTimelineModal } from './components/history/DigestTimelineModal';
|
||||
import { navigateToAnchor, parseAnchorHash, resolveTurnAnchorId } from './engine/anchorNavigator';
|
||||
import { ChatExportModal } from './components/history/ChatExportModal';
|
||||
import { useUnreadMarks } from './hooks/useUnreadMarks';
|
||||
import { CategoryDock } from './components/chat/CategoryDock';
|
||||
import { fetchChatCategories, fetchChatDates, mapTaskStatusToMessageStatus } from './api/chatWindow';
|
||||
@@ -284,6 +279,15 @@ export {
|
||||
MOCK_USERS,
|
||||
} from '@demo-seeds';
|
||||
|
||||
const loadFilePreviewPanel = () =>
|
||||
import('./components/doc/preview/FilePreviewPanel').then((m) => ({ default: m.FilePreviewPanel }));
|
||||
const loadAdminConsoleView = () =>
|
||||
import('./components/admin/console/AdminConsoleView').then((m) => ({ default: m.AdminConsoleView }));
|
||||
const loadChatExportModal = () =>
|
||||
import('./components/history/ChatExportModal').then((m) => ({ default: m.ChatExportModal }));
|
||||
const loadBlockEditorModal = () =>
|
||||
import('./components/doc/BlockEditorModal').then((m) => ({ default: m.BlockEditorModal }));
|
||||
|
||||
export default function App() {
|
||||
// Wave 2:启动时幂等剔除 localStorage 中的 demo seed 残留(须先于 load*)
|
||||
purgeDemoSeedsFromLocalStorage();
|
||||
@@ -3653,15 +3657,16 @@ export default function App() {
|
||||
// 导出 DOCX (Word 渲染器)
|
||||
const handleExportDocx = async () => {
|
||||
try {
|
||||
const { exportDocumentToDocx } = await import('./engine/docxRenderer');
|
||||
await exportDocumentToDocx(docAST);
|
||||
} catch (err) {
|
||||
console.error('导出 DOCX 失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出单条智能体回复 DOCX (轻量专属 Word 导出)
|
||||
const handleExportReplyDocx = async (message: ChatMessage) => {
|
||||
try {
|
||||
const { downloadDocxReply } = await import('./features/replyExport/downloadDocx');
|
||||
await downloadDocxReply(message.content || '', undefined, {
|
||||
createdAt: message.createdAt || message.timestamp,
|
||||
});
|
||||
@@ -3670,10 +3675,9 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 导出 XLSX (Excel 渲染器)
|
||||
const handleExportXlsx = async () => {
|
||||
try {
|
||||
const { exportDocumentToXlsx } = await import('./engine/xlsxRenderer');
|
||||
await exportDocumentToXlsx(docAST);
|
||||
} catch (err) {
|
||||
console.error('导出 XLSX 失败:', err);
|
||||
@@ -3996,15 +4000,19 @@ export default function App() {
|
||||
// 全屏系统管理台:与工作台并列的顶层视图,不经过 AppShell
|
||||
if (view === 'admin') {
|
||||
return (
|
||||
<AdminConsoleView
|
||||
currentUser={currentUser}
|
||||
env={systemEnv}
|
||||
onExit={() => setView('workbench')}
|
||||
onUsersChanged={async () => {
|
||||
const users = await authClient.getAdminUsers();
|
||||
setAllUsers(users);
|
||||
<OnDemand
|
||||
label="管理台"
|
||||
load={loadAdminConsoleView}
|
||||
componentProps={{
|
||||
currentUser,
|
||||
env: systemEnv,
|
||||
onExit: () => setView('workbench'),
|
||||
onUsersChanged: async () => {
|
||||
const users = await authClient.getAdminUsers();
|
||||
setAllUsers(users);
|
||||
},
|
||||
onRolesChanged: (newRoles: RoleDefinition[]) => setRoles(newRoles),
|
||||
}}
|
||||
onRolesChanged={(newRoles) => setRoles(newRoles)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4117,11 +4125,15 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
{isExportOpen && (
|
||||
<ChatExportModal
|
||||
isOpen={isExportOpen}
|
||||
onClose={() => setIsExportOpen(false)}
|
||||
sessionId={activeSessionId}
|
||||
dayStack={chatWindow.displayDayStack}
|
||||
<OnDemand
|
||||
label="聊天导出"
|
||||
load={loadChatExportModal}
|
||||
componentProps={{
|
||||
isOpen: isExportOpen,
|
||||
onClose: () => setIsExportOpen(false),
|
||||
sessionId: activeSessionId,
|
||||
dayStack: chatWindow.displayDayStack,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
@@ -4356,11 +4368,15 @@ export default function App() {
|
||||
);
|
||||
if (isUploadedFile && activeDeliverable) {
|
||||
return (
|
||||
<FilePreviewPanel
|
||||
deliverable={activeDeliverable}
|
||||
onOpenDocVisibility={() => {
|
||||
setTargetDocForVisibility(activeDeliverable);
|
||||
setDocVisibilityModalOpen(true);
|
||||
<OnDemand
|
||||
label="文件预览"
|
||||
load={loadFilePreviewPanel}
|
||||
componentProps={{
|
||||
deliverable: activeDeliverable,
|
||||
onOpenDocVisibility: () => {
|
||||
setTargetDocForVisibility(activeDeliverable);
|
||||
setDocVisibilityModalOpen(true);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -4466,11 +4482,15 @@ export default function App() {
|
||||
|
||||
{/* 块就地编辑弹窗 (支持富文本工具栏、多维表格与未保存拦截) */}
|
||||
{editorSession && (
|
||||
<BlockEditorModal
|
||||
block={editorSession.block}
|
||||
actorLabel={`${currentUser.name} (${currentUser.position || '成员'})`}
|
||||
onSave={handleSaveBlockEdit}
|
||||
onClose={closeBlockEditor}
|
||||
<OnDemand
|
||||
label="块编辑器"
|
||||
load={loadBlockEditorModal}
|
||||
componentProps={{
|
||||
block: editorSession.block,
|
||||
actorLabel: `${currentUser.name} (${currentUser.position || '成员'})`,
|
||||
onSave: handleSaveBlockEdit,
|
||||
onClose: closeBlockEditor,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Copy, Check, Bookmark, Download, ChevronDown, FileText, FileCode } from 'lucide-react';
|
||||
import { ChatMessage } from '../../types/workbench';
|
||||
import { copyRichReplyToClipboard, copyMarkdownReply } from '../../features/replyExport/clipboard';
|
||||
import { downloadMarkdownReply, downloadDocxReply } from '../../features/replyExport/download';
|
||||
import { downloadMarkdownReply } from '../../features/replyExport/download';
|
||||
|
||||
export interface ReplyActionBarProps {
|
||||
message: ChatMessage;
|
||||
@@ -334,6 +334,7 @@ export const ReplyActionBar: React.FC<ReplyActionBarProps> = ({
|
||||
if (onExportDocx) {
|
||||
await onExportDocx(message);
|
||||
} else {
|
||||
const { downloadDocxReply } = await import('../../features/replyExport/downloadDocx');
|
||||
await downloadDocxReply(message.content || '', undefined, {
|
||||
createdAt: message.createdAt || message.timestamp,
|
||||
});
|
||||
|
||||
@@ -7,14 +7,18 @@ import { HeadingBlockView } from './blocks/HeadingBlockView';
|
||||
import { ParagraphBlockView } from './blocks/ParagraphBlockView';
|
||||
import { TableBlockView } from './blocks/TableBlockView';
|
||||
import { CalloutBlockView } from './blocks/CalloutBlockView';
|
||||
import { MermaidBlockView } from './blocks/MermaidBlockView';
|
||||
import { MathBlockView } from './blocks/MathBlockView';
|
||||
import { ListBlockView } from './blocks/ListBlockView';
|
||||
import { OnDemand } from '../lazy/OnDemand';
|
||||
import { BlockContextMenu } from './BlockContextMenu';
|
||||
import { canAddBlockComment } from '../../lib/commentPolicy';
|
||||
import { documentBlockDomAttrs } from '../../lib/documentBlockDom';
|
||||
import { Plus, ArrowUp, ArrowDown, Trash2, MessageSquare } from 'lucide-react';
|
||||
|
||||
const loadMermaidBlock = () =>
|
||||
import('./blocks/MermaidBlockView').then((m) => ({ default: m.MermaidBlockView }));
|
||||
const loadMathBlock = () =>
|
||||
import('./blocks/MathBlockView').then((m) => ({ default: m.MathBlockView }));
|
||||
|
||||
export interface BlockRendererProps {
|
||||
ast: DocumentAST;
|
||||
currentRole: UserRole;
|
||||
@@ -75,9 +79,21 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
|
||||
case 'callout':
|
||||
return <CalloutBlockView block={block} />;
|
||||
case 'mermaid':
|
||||
return <MermaidBlockView block={block} />;
|
||||
return (
|
||||
<OnDemand
|
||||
label="图表"
|
||||
load={loadMermaidBlock}
|
||||
componentProps={{ block }}
|
||||
/>
|
||||
);
|
||||
case 'math':
|
||||
return <MathBlockView block={block} />;
|
||||
return (
|
||||
<OnDemand
|
||||
label="公式"
|
||||
load={loadMathBlock}
|
||||
componentProps={{ block }}
|
||||
/>
|
||||
);
|
||||
case 'list':
|
||||
return <ListBlockView block={block} />;
|
||||
case 'divider':
|
||||
|
||||
@@ -15,12 +15,14 @@ import {
|
||||
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 { PdfViewer } from './renderers/PdfViewer';
|
||||
import { PptViewer } from './renderers/PptViewer';
|
||||
import { TextViewer } from './renderers/TextViewer';
|
||||
import { OnDemand } from '../../lazy/OnDemand';
|
||||
|
||||
const loadDocxViewer = () => import('./renderers/DocxViewer').then((m) => ({ default: m.DocxViewer }));
|
||||
const loadExcelViewer = () => import('./renderers/ExcelViewer').then((m) => ({ default: m.ExcelViewer }));
|
||||
|
||||
interface FilePreviewPanelProps {
|
||||
deliverable: DeliverableItem;
|
||||
@@ -273,11 +275,23 @@ export const FilePreviewPanel: React.FC<FilePreviewPanelProps> = ({
|
||||
}
|
||||
switch (fileType) {
|
||||
case 'docx':
|
||||
return <DocxViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
|
||||
return (
|
||||
<OnDemand
|
||||
label="Word 预览"
|
||||
load={loadDocxViewer}
|
||||
componentProps={{ fileRaw, fileUrl, fileName }}
|
||||
/>
|
||||
);
|
||||
case 'pdf':
|
||||
return <PdfViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
|
||||
case 'excel':
|
||||
return <ExcelViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
|
||||
return (
|
||||
<OnDemand
|
||||
label="表格预览"
|
||||
load={loadExcelViewer}
|
||||
componentProps={{ fileRaw, fileUrl, fileName }}
|
||||
/>
|
||||
);
|
||||
case 'image':
|
||||
return <ImageViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
|
||||
case 'ppt':
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Component, lazy, Suspense, useMemo, useState, type ComponentType, type ReactNode } from 'react';
|
||||
|
||||
type BoundaryProps = {
|
||||
label: string;
|
||||
onRetry: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type BoundaryState = { error: Error | null };
|
||||
|
||||
/** Catches a failed dynamic import. Retry builds a new lazy() so a rejected chunk is not sticky. */
|
||||
export class LazyBoundary extends Component<BoundaryProps, BoundaryState> {
|
||||
state: BoundaryState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): BoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div role="alert" style={{ padding: 12, fontSize: 13 }}>
|
||||
<p style={{ margin: '0 0 8px' }}>
|
||||
{this.props.label}加载失败,可重试。这次失败不会提交文档或聊天动作。
|
||||
</p>
|
||||
<button type="button" onClick={this.props.onRetry}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Suspense fallback={<div style={{ padding: 12, fontSize: 13 }}>{this.props.label}加载中…</div>}>
|
||||
{this.props.children}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function OnDemand<P extends object>(props: {
|
||||
label: string;
|
||||
load: () => Promise<{ default: ComponentType<P> }>;
|
||||
componentProps: P;
|
||||
}) {
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const Comp = useMemo(() => lazy(props.load), [attempt, props.load]);
|
||||
return (
|
||||
<LazyBoundary key={attempt} label={props.label} onRetry={() => setAttempt((n) => n + 1)}>
|
||||
<Comp {...props.componentProps} />
|
||||
</LazyBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
import { Packer } from 'docx';
|
||||
import { deriveReplyTitle, sanitizeFilename } from './title';
|
||||
import { markdownToBlockAST } from '../../engine/markdownToBlock';
|
||||
import { buildDocxModel } from '../../engine/docxRenderer';
|
||||
|
||||
export interface DownloadOptions {
|
||||
includeSource?: boolean;
|
||||
@@ -42,53 +39,3 @@ export function downloadMarkdownReply(
|
||||
return { filename };
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发 Word (.docx) 专用轻量排版导出并直接下载
|
||||
*/
|
||||
export async function downloadDocxReply(
|
||||
markdown: string,
|
||||
title?: string,
|
||||
options: DownloadOptions = {}
|
||||
): Promise<{ filename: string; blob: Blob }> {
|
||||
const effectiveTitle = title || deriveReplyTitle(markdown, options.createdAt);
|
||||
const dateStr = new Date().toISOString().substring(0, 10);
|
||||
const baseName = sanitizeFilename(effectiveTitle);
|
||||
const filename = options.filenameOverride || `${baseName}_${dateStr}.docx`;
|
||||
|
||||
let exportContent = (markdown || '').trim();
|
||||
if (options.includeSource) {
|
||||
const metaTime = options.createdAt || new Date().toLocaleString();
|
||||
exportContent += `\n\n---\n*来源:水木东方项目助手回复 · 生成时间:${metaTime}*`;
|
||||
}
|
||||
|
||||
const ast = markdownToBlockAST(exportContent, {
|
||||
docId: `reply-docx-${Date.now()}`,
|
||||
title: effectiveTitle,
|
||||
taskKey: 'reply_export',
|
||||
version: 'V1.0',
|
||||
category: 'private',
|
||||
lifecycleState: 'drafting',
|
||||
isClaimed: true,
|
||||
author: '智能体助手回复',
|
||||
reviewer: '',
|
||||
approver: '',
|
||||
updatedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
|
||||
});
|
||||
|
||||
const doc = await buildDocxModel(ast);
|
||||
const blob = await Packer.toBlob(doc);
|
||||
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
const url = URL.createObjectURL(blob);
|
||||
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), 1000);
|
||||
}
|
||||
|
||||
return { filename, blob };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Packer } from 'docx';
|
||||
import { deriveReplyTitle, sanitizeFilename } from './title';
|
||||
import { markdownToBlockAST } from '../../engine/markdownToBlock';
|
||||
import { buildDocxModel } from '../../engine/docxRenderer';
|
||||
import type { DownloadOptions } from './download';
|
||||
|
||||
/** Word 导出。只在点击时动态引入,避免 docx 进入登录壳。 */
|
||||
export async function downloadDocxReply(
|
||||
markdown: string,
|
||||
title?: string,
|
||||
options: DownloadOptions = {}
|
||||
): Promise<{ filename: string; blob: Blob }> {
|
||||
const effectiveTitle = title || deriveReplyTitle(markdown, options.createdAt);
|
||||
const dateStr = new Date().toISOString().substring(0, 10);
|
||||
const baseName = sanitizeFilename(effectiveTitle);
|
||||
const filename = options.filenameOverride || `${baseName}_${dateStr}.docx`;
|
||||
|
||||
let exportContent = (markdown || '').trim();
|
||||
if (options.includeSource) {
|
||||
const metaTime = options.createdAt || new Date().toLocaleString();
|
||||
exportContent += `\n\n---\n*来源:水木东方项目助手回复 · 生成时间:${metaTime}*`;
|
||||
}
|
||||
|
||||
const ast = markdownToBlockAST(exportContent, {
|
||||
docId: `reply-docx-${Date.now()}`,
|
||||
title: effectiveTitle,
|
||||
taskKey: 'reply_export',
|
||||
version: 'V1.0',
|
||||
category: 'private',
|
||||
lifecycleState: 'drafting',
|
||||
isClaimed: true,
|
||||
author: '智能体助手回复',
|
||||
reviewer: '',
|
||||
approver: '',
|
||||
updatedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
|
||||
});
|
||||
|
||||
const doc = await buildDocxModel(ast);
|
||||
const blob = await Packer.toBlob(doc);
|
||||
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
const url = URL.createObjectURL(blob);
|
||||
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), 1000);
|
||||
}
|
||||
|
||||
return { filename, blob };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
|
||||
function staticImports(rel: string): string[] {
|
||||
const text = readFileSync(new URL(rel, import.meta.url), 'utf8');
|
||||
return text
|
||||
.split('\n')
|
||||
.filter((line) => /^\s*import\s/.test(line) && !line.includes('import('));
|
||||
}
|
||||
|
||||
test('login shell does not statically import export, preview, admin, or chart engines', () => {
|
||||
const app = staticImports('./App.tsx').join('\n');
|
||||
for (const banned of [
|
||||
'docxRenderer',
|
||||
'xlsxRenderer',
|
||||
'downloadDocx',
|
||||
'FilePreviewPanel',
|
||||
'AdminConsoleView',
|
||||
'ChatExportModal',
|
||||
'BlockEditorModal',
|
||||
"from 'docx'",
|
||||
"from 'mermaid'",
|
||||
"from 'xlsx'",
|
||||
]) {
|
||||
assert.equal(app.includes(banned), false, `App.tsx static import still mentions ${banned}`);
|
||||
}
|
||||
const appText = readFileSync(new URL('./App.tsx', import.meta.url), 'utf8');
|
||||
assert.match(appText, /import\('\.\/engine\/docxRenderer'\)/);
|
||||
assert.match(appText, /import\('\.\/engine\/xlsxRenderer'\)/);
|
||||
assert.match(appText, /import\('\.\/features\/replyExport\/downloadDocx'\)/);
|
||||
|
||||
const blocks = staticImports('./components/doc/BlockRenderer.tsx').join('\n');
|
||||
assert.equal(blocks.includes('MermaidBlockView'), false);
|
||||
assert.equal(blocks.includes('MathBlockView'), false);
|
||||
|
||||
const markdownDownload = readFileSync(new URL('./features/replyExport/download.ts', import.meta.url), 'utf8');
|
||||
assert.equal(markdownDownload.includes("from 'docx'"), false);
|
||||
});
|
||||
Reference in New Issue
Block a user