Add reusable HTML template export

- Add template export utilities for standard JSON packages and standalone HTML template packages.

- Make the top-level JSON export use the standard surclaw template package format so it can be imported again.

- Add an HTML template package export that embeds A4/print styling and template metadata for visual preview and round-trip import.

- Extend template import to accept both JSON and HTML template package files while keeping old raw template JSON compatible.

- Add tests for package creation, HTML round-trip import, legacy JSON import, and file name cleanup.

- Update template management, feature, progress, testing, and AGENTS documentation for the new export formats.
This commit is contained in:
2026-05-02 03:49:21 +08:00
parent d61ee4f83a
commit 5a4056d899
8 changed files with 235 additions and 40 deletions

View File

@@ -350,6 +350,7 @@ PostgreSQL 数据模型。当前覆盖 `Tenant`、`Department`、`User`、`UserS
- 后端报告 schema 区分草稿和完成状态,草稿可暂缺患者姓名/住院号,完成报告必须填写
- 后端模板 DTO 和权限资源映射
- 模板列表合并工具,防止新增模板被旧 `localStorage.templates` 覆盖
- 模板导入导出工具,覆盖 JSON/HTML 模板包生成、回导和旧 JSON 兼容
- 后端用户 DTO 和部门模板授权映射
- 后端系统设置 schema 校验
- 后端 AI 入参和讯飞语音代理帧处理

View File

@@ -31,8 +31,8 @@
| 报告管理筛选 | 真实集成 | `ReportManage` 优先调用 `GET /api/reports`,后端按超级管理员/管理员/医生过滤;前端继续支持搜索、状态和时间筛选;只有开发/显式回退模式下 API 不可用才回退本地报告。 |
| 报告查看 | 真实集成 | `ReportView` 优先调用 `GET /api/reports/:id`,后端校验查看权限;页面渲染报告 HTML只有开发/显式回退模式下 API 不可用才回退本地权限检查。 |
| PDF 导出 | 真实集成 | 通过隐藏 iframe 或 `window.print()` 调用浏览器打印,用户手动保存为 PDF不是后端 PDF 生成。 |
| JSON 导出 | 真实可用 | Blob 下载结构化报告字段或模板包。 |
| 模板管理 | 真实集成 | `TemplateManage` 优先调用 `/api/templates?access=manage`,新增/编辑/删除会写后端并清洗 HTML新增后保存内容以当前页面模板列表为准并同步兼容缓存避免旧本地缓存覆盖新模板字段库优先调用 `/api/library/fields`,模板图片资源优先调用 `/api/files`。导入导出仍主要在前端处理。 |
| JSON 导出 | 真实可用 | Blob 下载结构化报告字段或模板包;模板 JSON 包适合系统内迁移。 |
| 模板管理 | 真实集成 | `TemplateManage` 优先调用 `/api/templates?access=manage`,新增/编辑/删除会写后端并清洗 HTML新增后保存内容以当前页面模板列表为准并同步兼容缓存避免旧本地缓存覆盖新模板字段库优先调用 `/api/library/fields`,模板图片资源优先调用 `/api/files`。导入导出支持 JSON 模板包、可再导入的 HTML 模板包和 PDF 打印预览,仍主要在前端处理。 |
| 模板权限 | 真实集成 | 后端按部门模板、部门授权和个人模板过滤 `access=use/manage`;迁移期仍同步 `localStorage.templates`,仅在开发/显式回退模式下作为回退。 |
| 我的个人模板 | 真实集成 | 医生在报告编辑器中保存个人模板时优先调用 `POST /api/templates`,后端把模板归属当前用户;只有开发/显式回退模式下 API 不可用才回退本地模板。 |
| 用户管理 | 真实集成 | `UserManage` 优先调用 `/api/users` 增删改查,后端校验超级管理员/管理员范围、管理员唯一性和医生创建约束;只有开发/显式回退模式下 API 不可用才保留本地回退。 |
@@ -67,6 +67,7 @@
| `src/pages/ReportManage.test.tsx` | 医生/管理员报告可见范围。 |
| `src/utils/permissions.test.ts` | 报告权限、管理员本部门范围、医生个人模板和部门模板范围。 |
| `src/utils/templateList.test.ts` | 模板列表合并,覆盖新增模板不被旧缓存覆盖。 |
| `src/utils/templateExport.test.ts` | JSON/HTML 模板包生成、HTML 回导、旧 JSON 兼容和文件名清理。 |
| `src/utils/storage.test.ts` | 本地存储、系统设置混淆兼容、会话恢复键、默认 Provider 不携带内置 Key 的契约。 |
| `src/utils/defaultContent.test.ts` | 默认模板结构、智能字段、图片占位符、AI 区域、字段和 Provider 配置。 |
| `src/utils/print.test.ts` | 浏览器打印导出入口。 |

View File

@@ -34,8 +34,8 @@
- 删除或批量删除模板。
- 保存当前模板内容。
- 打印模板预览。
- 单个导出批量导出 JSON。
- 从 JSON 模板包导入。
- 单个导出 JSON 模板包、HTML 模板包和 PDF 打印预览,批量导出 JSON。
- 从 JSON 模板包或 HTML 模板包导入。
新增、编辑、保存内容和删除模板会优先调用后端 `POST/PATCH/DELETE /api/templates`,后端会对模板 HTML 做白名单清洗;成功后同步 `localStorage.templates` 作为兼容缓存。只有本地回退开启时API 失败才允许写本地模板。
@@ -72,7 +72,7 @@
## 导入导出格式
单模板导出大致结构:
单模板 JSON 模板包大致结构:
```json
{
@@ -85,4 +85,8 @@
}
```
批量导出使用 `type: "surclaw_template_package_batch"`,包含 `templates` 数组。当前导入逻辑只接受单模板包。
HTML 模板包是一个可直接用浏览器打开的完整 HTML 文件,包含 A4 页面样式、打印样式和内嵌的 `surclaw_template_package` 元数据。它比 JSON 更适合保留“报告整体观感”,也可以重新导入系统恢复模板 HTML 和字段定义。
PDF 导出走浏览器打印,适合归档和人工查看,不适合再次导入编辑。
批量导出使用 `type: "surclaw_template_package_batch"`,包含 `templates` 数组。当前导入逻辑只接受单模板 JSON/HTML 包。

View File

@@ -14,6 +14,7 @@
- 报告 API 已实现列表、详情、创建、保存、完成修订、历史记录和软删除;`ReportManage``ReportView``ReportEditor` 已优先调用后端,只有开发/显式回退模式下才保留本地回退。
- 后端报告校验已区分草稿和完成状态:草稿允许患者姓名/住院号暂空,完成报告仍强制要求。
- 模板管理、字段库、模板导入导出已实现;模板 API 已支持可用/可管理列表、详情、创建、更新、删除和个人模板;字段库已优先接入 `/api/library/fields`
- 模板导出已增加可重新导入的 HTML 模板包,用于比 JSON 更完整地保留报告版式观感。
- 模板管理新增后保存内容已改为基于当前页面 state 更新,并与本地兼容缓存合并,避免旧缓存把新建模板从列表中冲掉。
- 用户管理、部门管理员约束和部门模板授权已优先接入后端 Users/Departments API签名上传和模板图片资源已通过 Files API 写入后端文件资源。
- 系统设置、抽帧策略、AI Provider、语音参数和默认模板已优先接入 Settings API只有开发/显式回退模式下才保留本地缓存回退。
@@ -83,3 +84,4 @@
| 2026-05-02 | 增加 Nginx 和 NestJS 请求体上限配置,修复大图文报告保存 `request entity too large`。 |
| 2026-05-02 | 新增 Docker HTTPS 演示入口和麦克风访问说明,解决非安全上下文下语音听写不可启动的问题。 |
| 2026-05-02 | 修复模板管理中新建模板后点击保存内容导致模板从列表消失的问题,并补充单元测试和 E2E。 |
| 2026-05-02 | 模板管理新增 HTML 模板包导出/导入,修正右上角 JSON 导出为标准模板包。 |

View File

@@ -83,6 +83,7 @@ AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视
| 默认字段和 AI Provider | 已覆盖 | `defaultContent.test.ts` |
| 打印导出入口 | 已覆盖 | `print.test.ts` |
| 模板列表合并工具 | 已覆盖 | `templateList.test.ts`,防止新增模板被旧本地缓存覆盖。 |
| 模板导入导出工具 | 已覆盖 | `templateExport.test.ts`,覆盖 JSON/HTML 模板包、HTML 回导、旧 JSON 兼容和文件名清理。 |
| 默认快捷登录 | 已覆盖 | `e2e/login.spec.ts` |
| 报告权限 E2E | 已覆盖 | `e2e/report-permissions.spec.ts` |
| 报告修订版本 E2E | 已覆盖 | `e2e/report-revision.spec.ts` |

View File

@@ -12,6 +12,13 @@ import { getFieldLibrary, updateFieldLibrary } from '../api/library';
import { deleteFileResource, listFiles, uploadFileResource } from '../api/files';
import { isLocalFallbackEnabled } from '../config/runtime';
import { mergeTemplatesById } from '../utils/templateList';
import {
createTemplateHtmlDocument,
createTemplatePackage,
getExportTimestamp,
parseTemplatePackageFile,
safeFileName,
} from '../utils/templateExport';
export default function TemplateManage() {
const navigate = useNavigate();
@@ -759,7 +766,7 @@ export default function TemplateManage() {
const handleBatchExport = () => {
if (selectedIds.length === 0) return;
const targets = templates.filter(t => selectedIds.includes(t.id));
const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
const ts = getExportTimestamp();
const exportData = {
version: '1.0',
type: 'surclaw_template_package_batch',
@@ -780,18 +787,14 @@ export default function TemplateManage() {
const reader = new FileReader();
reader.onload = (event) => {
try {
const json = JSON.parse(event.target?.result as string);
if (json.type !== 'surclaw_template_package') {
alert('无效的模板包文件');
return;
}
setFormData({ name: json.title || '', desc: json.description || '' });
const templatePackage = parseTemplatePackageFile(file.name, event.target?.result as string);
setFormData({ name: templatePackage.title || '', desc: templatePackage.description || '' });
setImportedContent({
content: json.content || '',
fields: Array.isArray(json.fields) ? json.fields : []
content: templatePackage.content || '',
fields: templatePackage.fields
});
} catch {
alert('文件解析失败,请检查 JSON 格式');
} catch (error) {
alert(error instanceof Error ? error.message : '文件解析失败,请检查 JSON/HTML 格式');
}
};
reader.readAsText(file);
@@ -799,20 +802,31 @@ export default function TemplateManage() {
};
const handleExportTemplate = (template: Template) => {
const exportData = {
version: '1.0',
type: 'surclaw_template_package',
title: template.name,
description: template.desc || '',
content: template.content,
fields: template.fields || formFields
};
const exportData = createTemplatePackage(template, template.content, template.fields || formFields);
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
a.download = `模板导出-${template.name}-${ts}.json`;
const ts = getExportTimestamp();
a.download = `模板导出-${safeFileName(template.name)}-${ts}.json`;
a.click();
URL.revokeObjectURL(url);
};
const downloadCurrentTemplatePackage = (format: 'json' | 'html') => {
const content = editorRef.current?.innerHTML || currentTemplate?.content || '';
const name = currentTemplate?.name || '模板';
const templatePackage = createTemplatePackage(currentTemplate, content, currentTemplate?.fields || formFields);
const ts = getExportTimestamp();
const safeName = safeFileName(name);
const body = format === 'html'
? createTemplateHtmlDocument(templatePackage)
: JSON.stringify(templatePackage, null, 2);
const blob = new Blob([body], { type: format === 'html' ? 'text/html' : 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${safeName}-${ts}.${format}`;
a.click();
URL.revokeObjectURL(url);
};
@@ -1543,7 +1557,7 @@ export default function TemplateManage() {
<div className="space-y-3">
<button
onClick={() => {
const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
const ts = getExportTimestamp();
const name = currentTemplate?.name || '模板';
printDocument(editorRef.current?.innerHTML || '', `${name}-${ts}`);
setExportModalOpen(false);
@@ -1552,20 +1566,18 @@ export default function TemplateManage() {
> PDF</button>
<button
onClick={() => {
const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
const name = currentTemplate?.name || '模板';
const data = currentTemplate ? { ...currentTemplate, content: editorRef.current?.innerHTML } : { content: editorRef.current?.innerHTML };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${name}-${ts}.json`;
a.click();
URL.revokeObjectURL(url);
downloadCurrentTemplatePackage('html');
setExportModalOpen(false);
}}
className="w-full py-2.5 bg-slate-900 text-white rounded text-sm font-semibold hover:bg-slate-800 transition-colors"
> HTML </button>
<button
onClick={() => {
downloadCurrentTemplatePackage('json');
setExportModalOpen(false);
}}
className="w-full py-2.5 bg-slate-100 text-slate-700 rounded text-sm font-semibold hover:bg-slate-200 transition-colors"
> JSON</button>
> JSON </button>
<button
onClick={() => setExportModalOpen(false)}
className="w-full py-2.5 border border-border text-text-main rounded text-sm font-semibold hover:bg-slate-50 transition-colors"
@@ -1590,7 +1602,7 @@ export default function TemplateManage() {
>
<Upload size={16} />
</button>
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImportFile} />
<input ref={fileInputRef} type="file" accept=".json,.html,text/html,application/json" className="hidden" onChange={handleImportFile} />
</div>
)}
<form onSubmit={handleModalSubmit} className="space-y-6">

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import {
createTemplateHtmlDocument,
createTemplatePackage,
parseTemplatePackageFile,
safeFileName,
TEMPLATE_HTML_META_ID,
} from './templateExport';
describe('template export utilities', () => {
it('creates JSON-compatible template packages', () => {
const pkg = createTemplatePackage(
{ name: '腹腔镜模板', desc: '保留字段' },
'<h1>标题</h1>',
[{
key: 'patientName',
label: '患者姓名',
category: '填空',
type: 'text',
visibleInForm: true,
isSystemLocked: true,
}],
);
expect(pkg.type).toBe('surclaw_template_package');
expect(pkg.title).toBe('腹腔镜模板');
expect(pkg.fields[0].key).toBe('patientName');
});
it('round-trips standalone HTML template packages', () => {
const pkg = createTemplatePackage({ name: '整体版式', desc: '' }, '<h1>报告</h1>', []);
const html = createTemplateHtmlDocument(pkg);
const parsed = parseTemplatePackageFile('整体版式.html', html);
expect(html).toContain(`id="${TEMPLATE_HTML_META_ID}"`);
expect(parsed.title).toBe('整体版式');
expect(parsed.content).toContain('<h1>报告</h1>');
});
it('accepts existing raw template JSON for compatibility', () => {
const parsed = parseTemplatePackageFile('old.json', JSON.stringify({
name: '旧导出',
desc: '旧结构',
content: '<p>旧内容</p>',
fields: [],
}));
expect(parsed.title).toBe('旧导出');
expect(parsed.description).toBe('旧结构');
});
it('sanitizes file names', () => {
expect(safeFileName('A/B:*模板')).toBe('A_B__模板');
});
});

119
src/utils/templateExport.ts Normal file
View File

@@ -0,0 +1,119 @@
import { FormField, Template } from '../types';
export const TEMPLATE_PACKAGE_TYPE = 'surclaw_template_package';
export const TEMPLATE_HTML_META_ID = 'surclaw-template-package';
export interface TemplatePackage {
version: string;
type: typeof TEMPLATE_PACKAGE_TYPE;
title: string;
description: string;
content: string;
fields: FormField[];
}
export const getExportTimestamp = () =>
new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
export const safeFileName = (name: string) =>
(name || '模板').replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, ' ').trim() || '模板';
export const createTemplatePackage = (
template: Pick<Template, 'name' | 'desc'> | null | undefined,
content: string,
fields: FormField[],
): TemplatePackage => ({
version: '1.0',
type: TEMPLATE_PACKAGE_TYPE,
title: template?.name || '模板',
description: template?.desc || '',
content,
fields,
});
const escapeHtml = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const escapeScriptJson = (value: unknown) =>
JSON.stringify(value, null, 2).replace(/</g, '\\u003c');
export const createTemplateHtmlDocument = (templatePackage: TemplatePackage) => `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="generator" content="SurClaw Template Export">
<title>${escapeHtml(templatePackage.title)}</title>
<style>
@page { size: A4; margin: 15mm 10mm; }
* { box-sizing: border-box; }
body { margin: 0; padding: 24px; font-family: SimSun, "Microsoft YaHei", serif; color: #1E293B; background: #f1f5f9; }
.surclaw-page { width: 210mm; min-height: 297mm; margin: 0 auto; padding: 15mm 10mm; background: #fff; box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12); }
img { max-width: 100%; height: auto; display: block; margin: 8px auto; }
p { margin: 0; padding: 0; line-height: 1.5; }
h1 { font-size: 20px; margin: 16px 0 12px; font-weight: 600; text-align: center; }
table { width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed; }
td { padding: 8px; border: 1px solid #e2e8f0; vertical-align: top; }
.image-placeholder { border: 2px dashed #cbd5e1; border-radius: 8px; padding: 16px; margin-bottom: 8px; background: #f8fafc; min-height: 70px; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; }
.image-placeholder.has-image { border: none; background: transparent; padding: 0; min-height: 0; }
.delete-btn { display: none !important; }
.smart-field-wrapper { display: inline-flex; align-items: baseline; margin: 0; vertical-align: baseline; }
.smart-field-wrapper .field-value { min-width: 24px; padding: 0 2px; margin: 0; border: 1px solid #cbd5e1; border-radius: 2px; display: inline-block; background: #f8fafc; color: #0f172a; line-height: inherit; font-size: inherit; vertical-align: baseline; box-sizing: border-box; outline: none; text-align: center; }
.ai-region { border: 1px dashed #3b82f6; background: #f8fafc; padding: 12px; margin: 8px 0; border-radius: 6px; }
@media print {
body { padding: 0; background: #fff; }
.surclaw-page { width: auto; min-height: auto; margin: 0; padding: 0; box-shadow: none; }
.smart-field-wrapper .field-value { outline: none !important; box-shadow: none !important; border: none !important; border-bottom: 1px solid #000 !important; border-radius: 0 !important; background: transparent !important; }
.smart-field-wrapper .field-value.no-underline { border-bottom: none !important; }
}
</style>
<script id="${TEMPLATE_HTML_META_ID}" type="application/json">${escapeScriptJson(templatePackage)}</script>
</head>
<body>
<main class="surclaw-page">${templatePackage.content}</main>
</body>
</html>`;
export const parseTemplatePackageJson = (text: string): TemplatePackage => {
const json = JSON.parse(text);
if (json.type === TEMPLATE_PACKAGE_TYPE) {
return {
version: String(json.version || '1.0'),
type: TEMPLATE_PACKAGE_TYPE,
title: String(json.title || json.name || '导入模板'),
description: String(json.description || json.desc || ''),
content: String(json.content || ''),
fields: Array.isArray(json.fields) ? json.fields : [],
};
}
if (json.content) {
return {
version: '1.0',
type: TEMPLATE_PACKAGE_TYPE,
title: String(json.name || json.title || '导入模板'),
description: String(json.desc || json.description || ''),
content: String(json.content || ''),
fields: Array.isArray(json.fields) ? json.fields : [],
};
}
throw new Error('无效的模板包文件');
};
export const parseTemplatePackageHtml = (text: string): TemplatePackage => {
const doc = new DOMParser().parseFromString(text, 'text/html');
const meta = doc.getElementById(TEMPLATE_HTML_META_ID)?.textContent;
if (!meta) throw new Error('HTML 文件缺少模板元数据');
return parseTemplatePackageJson(meta);
};
export const parseTemplatePackageFile = (fileName: string, text: string): TemplatePackage => {
if (fileName.toLowerCase().endsWith('.html') || /^\s*<!doctype html/i.test(text) || /^\s*<html/i.test(text)) {
return parseTemplatePackageHtml(text);
}
return parseTemplatePackageJson(text);
};