Include field library metadata in template exports

- Add fieldLibrary metadata to HTML template packages, including form fields, custom time formats, multi-select options, and anesthesia options.

- Restore imported template field metadata into local compatibility caches and the backend field library API when available.

- Preserve legacy JSON template import compatibility while keeping user-facing exports on HTML packages.

- Prevent template field saves from overwriting stored multi-select and anesthesia options with empty values.

- Update README, AGENTS, feature, requirement, design, module, progress, component, and testing docs for complete template export behavior.

- Extend template export tests to cover field library metadata round-tripping.
This commit is contained in:
2026-05-02 04:27:08 +08:00
parent 03b54fe18b
commit 44decaa396
12 changed files with 171 additions and 37 deletions

View File

@@ -18,8 +18,12 @@ import {
getExportTimestamp,
parseTemplatePackageFile,
safeFileName,
type TemplateFieldLibrary,
type TemplatePackage,
} from '../utils/templateExport';
const DEFAULT_TIME_FORMATS = ['YYYY-MM-DD', 'YYYY年MM月DD日', 'MM-DD', 'MM月DD日', 'HH:mm', 'hh:mm A'];
export default function TemplateManage() {
const navigate = useNavigate();
const [currentUser, setCurrentUser] = useState<User | null>(null);
@@ -29,7 +33,7 @@ export default function TemplateManage() {
const [exportModalOpen, setExportModalOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({ name: '', desc: '' });
const [importedContent, setImportedContent] = useState<{content: string; fields: FormField[]} | null>(null);
const [importedContent, setImportedContent] = useState<TemplatePackage | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isSaved, setIsSaved] = useState(false);
const editorRef = useRef<HTMLDivElement>(null);
@@ -76,6 +80,17 @@ export default function TemplateManage() {
editorRef.current.style.minHeight = `${pages * pageHeightMm}mm`;
};
const getStoredFieldLibraryOptions = () => ({
multiSelectOptions: storage.get<Record<string, string[]>>('multiSelectOptions', {}),
anesthesiaOptions: storage.get<string[]>('anesthesiaOptions', []),
});
const buildFieldLibrarySnapshot = (fields: FormField[] = formFields): TemplateFieldLibrary => ({
formFields: fields,
customTimeFormats,
...getStoredFieldLibraryOptions(),
});
useEffect(() => {
const user = storage.get<User | null>('currentUser', null);
if (!user || user.role === 'user') {
@@ -112,16 +127,18 @@ export default function TemplateManage() {
}
const savedFormats = storage.get<string[]>('customTimeFormats', []);
const defaultFormats = ['YYYY-MM-DD', 'YYYY年MM月DD日', 'MM-DD', 'MM月DD日', 'HH:mm', 'hh:mm A'];
const cleanedSaved = savedFormats.filter(f => f !== '24h' && f !== '12h');
setCustomTimeFormats(Array.from(new Set([...defaultFormats, ...cleanedSaved])));
setCustomTimeFormats(Array.from(new Set([...DEFAULT_TIME_FORMATS, ...cleanedSaved])));
void getFieldLibrary().then((library) => {
if (library.formFields.length > 0) {
setFormFields(library.formFields);
storage.set('formFieldsConfig', library.formFields);
}
setCustomTimeFormats(Array.from(new Set([...defaultFormats, ...library.customTimeFormats])));
setCustomTimeFormats(Array.from(new Set([...DEFAULT_TIME_FORMATS, ...library.customTimeFormats])));
storage.set('customTimeFormats', library.customTimeFormats);
storage.set('multiSelectOptions', library.multiSelectOptions);
storage.set('anesthesiaOptions', library.anesthesiaOptions);
}).catch(() => {});
void listFiles('TEMPLATE_ASSET').then((files) => {
@@ -173,16 +190,55 @@ export default function TemplateManage() {
const persistFieldLibrary = (next: Partial<{
formFields: FormField[];
customTimeFormats: string[];
multiSelectOptions: Record<string, string[]>;
anesthesiaOptions: string[];
}>) => {
const storedOptions = getStoredFieldLibraryOptions();
void updateFieldLibrary({
formFields,
customTimeFormats,
multiSelectOptions: {},
anesthesiaOptions: [],
...storedOptions,
...next,
}).catch(() => {});
};
const applyImportedFieldLibrary = (templatePackage: TemplatePackage) => {
const library = templatePackage.fieldLibrary;
const importedFields = templatePackage.fields.length > 0
? templatePackage.fields
: library?.formFields || [];
const nextFormats = library?.customTimeFormats
? Array.from(new Set([...DEFAULT_TIME_FORMATS, ...library.customTimeFormats]))
: customTimeFormats;
if (importedFields.length > 0) {
setFormFields(importedFields);
storage.set('formFieldsConfig', importedFields);
}
if (library?.customTimeFormats) {
setCustomTimeFormats(nextFormats);
storage.set('customTimeFormats', nextFormats);
}
if (library?.multiSelectOptions) {
storage.set('multiSelectOptions', library.multiSelectOptions);
}
if (library?.anesthesiaOptions) {
storage.set('anesthesiaOptions', library.anesthesiaOptions);
}
if (importedFields.length > 0 || library) {
persistFieldLibrary({
...(importedFields.length > 0 ? { formFields: importedFields } : {}),
...(library?.customTimeFormats ? { customTimeFormats: nextFormats } : {}),
...(library?.multiSelectOptions ? { multiSelectOptions: library.multiSelectOptions } : {}),
...(library?.anesthesiaOptions ? { anesthesiaOptions: library.anesthesiaOptions } : {}),
});
}
};
useEffect(() => {
if (currentTemplateId && editorRef.current) {
const template = templates.find(t => t.id === currentTemplateId);
@@ -771,10 +827,7 @@ export default function TemplateManage() {
try {
const templatePackage = parseTemplatePackageFile(file.name, event.target?.result as string);
setFormData({ name: templatePackage.title || '', desc: templatePackage.description || '' });
setImportedContent({
content: templatePackage.content || '',
fields: templatePackage.fields
});
setImportedContent(templatePackage);
} catch (error) {
alert(error instanceof Error ? error.message : '文件解析失败,请检查 JSON/HTML 格式');
}
@@ -784,7 +837,8 @@ export default function TemplateManage() {
};
const handleExportTemplate = (template: Template) => {
const exportData = createTemplatePackage(template, template.content, template.fields || formFields);
const templateFields = template.fields || formFields;
const exportData = createTemplatePackage(template, template.content, templateFields, buildFieldLibrarySnapshot(templateFields));
const html = createTemplateHtmlDocument(exportData);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
@@ -799,7 +853,8 @@ export default function TemplateManage() {
const downloadCurrentTemplateHtmlPackage = () => {
const content = editorRef.current?.innerHTML || currentTemplate?.content || '';
const name = currentTemplate?.name || '模板';
const templatePackage = createTemplatePackage(currentTemplate, content, currentTemplate?.fields || formFields);
const templateFields = currentTemplate?.fields || formFields;
const templatePackage = createTemplatePackage(currentTemplate, content, templateFields, buildFieldLibrarySnapshot(templateFields));
const ts = getExportTimestamp();
const safeName = safeFileName(name);
const body = createTemplateHtmlDocument(templatePackage);
@@ -845,7 +900,7 @@ export default function TemplateManage() {
content: importedContent?.content || defaultReportContent,
createdAt: new Date().toISOString(),
author: currentUser?.username || 'admin',
fields: importedContent?.fields || formFields,
fields: importedContent?.fields.length ? importedContent.fields : formFields,
scope: 'department',
department: currentUser?.role === 'super' ? '' : (currentUser?.department || '')
};
@@ -861,9 +916,8 @@ export default function TemplateManage() {
setTemplates(updatedTemplates);
storage.set('templates', mergeTemplatesById(cachedTemplates, updatedTemplates));
setCurrentTemplateId(newTpl.id);
if (importedContent?.fields && importedContent.fields.length > 0) {
setFormFields(importedContent.fields);
storage.set('formFieldsConfig', importedContent.fields);
if (importedContent) {
applyImportedFieldLibrary(importedContent);
}
const savedUsers = storage.get<User[]>('users', []);

View File

@@ -28,13 +28,43 @@ describe('template export utilities', () => {
});
it('round-trips standalone HTML template packages', () => {
const pkg = createTemplatePackage({ name: '整体版式', desc: '' }, '<h1>报告</h1>', []);
const pkg = createTemplatePackage(
{ name: '整体版式', desc: '' },
'<h1>报告</h1>',
[{
key: 'startTime',
label: '开始时间',
category: '时间',
type: 'time',
visibleInForm: true,
isSystemLocked: true,
timeFormat: 'HH:mm',
}],
{
formFields: [{
key: 'startTime',
label: '开始时间',
category: '时间',
type: 'time',
visibleInForm: true,
isSystemLocked: true,
timeFormat: 'HH:mm',
}],
customTimeFormats: ['HH:mm:ss'],
multiSelectOptions: { surgeon: ['张医生'] },
anesthesiaOptions: ['全麻'],
},
);
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>');
expect(parsed.fields[0].key).toBe('startTime');
expect(parsed.fieldLibrary?.customTimeFormats).toEqual(['HH:mm:ss']);
expect(parsed.fieldLibrary?.multiSelectOptions.surgeon).toEqual(['张医生']);
expect(parsed.fieldLibrary?.anesthesiaOptions).toEqual(['全麻']);
});
it('accepts existing raw template JSON for compatibility', () => {

View File

@@ -10,6 +10,14 @@ export interface TemplatePackage {
description: string;
content: string;
fields: FormField[];
fieldLibrary?: TemplateFieldLibrary;
}
export interface TemplateFieldLibrary {
formFields: FormField[];
customTimeFormats: string[];
multiSelectOptions: Record<string, string[]>;
anesthesiaOptions: string[];
}
export const getExportTimestamp = () =>
@@ -22,14 +30,44 @@ 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,
});
fieldLibrary?: TemplateFieldLibrary,
): TemplatePackage => {
const templatePackage: TemplatePackage = {
version: '1.1',
type: TEMPLATE_PACKAGE_TYPE,
title: template?.name || '模板',
description: template?.desc || '',
content,
fields,
};
if (fieldLibrary) {
templatePackage.fieldLibrary = fieldLibrary;
}
return templatePackage;
};
const normalizeStringArray = (value: unknown): string[] =>
Array.isArray(value) ? value.map(String).filter(Boolean) : [];
const normalizeMultiSelectOptions = (value: unknown): Record<string, string[]> => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, values]) => [key, normalizeStringArray(values)]),
);
};
const parseFieldLibrary = (value: unknown): TemplateFieldLibrary | undefined => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const raw = value as Record<string, unknown>;
return {
formFields: Array.isArray(raw.formFields) ? raw.formFields as FormField[] : [],
customTimeFormats: normalizeStringArray(raw.customTimeFormats),
multiSelectOptions: normalizeMultiSelectOptions(raw.multiSelectOptions),
anesthesiaOptions: normalizeStringArray(raw.anesthesiaOptions),
};
};
const escapeHtml = (value: string) =>
value
@@ -80,6 +118,7 @@ export const createTemplateHtmlDocument = (templatePackage: TemplatePackage) =>
export const parseTemplatePackageJson = (text: string): TemplatePackage => {
const json = JSON.parse(text);
if (json.type === TEMPLATE_PACKAGE_TYPE) {
const fieldLibrary = parseFieldLibrary(json.fieldLibrary);
return {
version: String(json.version || '1.0'),
type: TEMPLATE_PACKAGE_TYPE,
@@ -87,10 +126,12 @@ export const parseTemplatePackageJson = (text: string): TemplatePackage => {
description: String(json.description || json.desc || ''),
content: String(json.content || ''),
fields: Array.isArray(json.fields) ? json.fields : [],
...(fieldLibrary ? { fieldLibrary } : {}),
};
}
if (json.content) {
const fieldLibrary = parseFieldLibrary(json.fieldLibrary);
return {
version: '1.0',
type: TEMPLATE_PACKAGE_TYPE,
@@ -98,6 +139,7 @@ export const parseTemplatePackageJson = (text: string): TemplatePackage => {
description: String(json.desc || json.description || ''),
content: String(json.content || ''),
fields: Array.isArray(json.fields) ? json.fields : [],
...(fieldLibrary ? { fieldLibrary } : {}),
};
}