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:
@@ -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', []);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user