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:
@@ -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">
|
||||
|
||||
55
src/utils/templateExport.test.ts
Normal file
55
src/utils/templateExport.test.ts
Normal 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
119
src/utils/templateExport.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
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);
|
||||
};
|
||||
Reference in New Issue
Block a user