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

@@ -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);
};