2 Commits

7 changed files with 370 additions and 6 deletions

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import Sidebar from '../components/Sidebar'; import Sidebar from '../components/Sidebar';
import { Plus, Edit, Trash2, Save, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, Check, Download } from 'lucide-react'; import { Plus, Edit, Trash2, Save, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, Check, Download, Upload } from 'lucide-react';
import { User, Template, FormField, FieldType, DEFAULT_FORM_FIELDS } from '../types'; import { User, Template, FormField, FieldType, DEFAULT_FORM_FIELDS } from '../types';
import { defaultReportContent } from '../utils/defaultContent'; import { defaultReportContent } from '../utils/defaultContent';
import { printDocument } from '../utils/print'; import { printDocument } from '../utils/print';
@@ -16,6 +16,8 @@ export default function TemplateManage() {
const [exportModalOpen, setExportModalOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({ name: '', desc: '' }); const [formData, setFormData] = useState({ name: '', desc: '' });
const [importedContent, setImportedContent] = useState<{content: string; fields: FormField[]} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isSaved, setIsSaved] = useState(false); const [isSaved, setIsSaved] = useState(false);
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
const savedRangeRef = useRef<Range | null>(null); const savedRangeRef = useRef<Range | null>(null);
@@ -128,6 +130,10 @@ export default function TemplateManage() {
const template = templates.find(t => t.id === currentTemplateId); const template = templates.find(t => t.id === currentTemplateId);
if (template) { if (template) {
editorRef.current.innerHTML = template.content; editorRef.current.innerHTML = template.content;
if (template.fields && template.fields.length > 0) {
setFormFields(template.fields);
storage.set('formFieldsConfig', template.fields);
}
} }
setTimeout(() => updatePageHeight(), 0); setTimeout(() => updatePageHeight(), 0);
} }
@@ -598,6 +604,48 @@ export default function TemplateManage() {
} }
}; };
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
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 || '' });
setImportedContent({
content: json.content || '',
fields: Array.isArray(json.fields) ? json.fields : []
});
} catch {
alert('文件解析失败,请检查 JSON 格式');
}
};
reader.readAsText(file);
if (e.target) e.target.value = '';
};
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 blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `模板导出-${template.name}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleModalSubmit = (e: React.FormEvent) => { const handleModalSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const allTemplates = storage.get<Template[]>('templates', []); const allTemplates = storage.get<Template[]>('templates', []);
@@ -615,14 +663,19 @@ export default function TemplateManage() {
id: 'tpl_' + Date.now(), id: 'tpl_' + Date.now(),
name: formData.name, name: formData.name,
desc: formData.desc, desc: formData.desc,
content: defaultReportContent, content: importedContent?.content || defaultReportContent,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
author: currentUser?.username || 'admin' author: currentUser?.username || 'admin',
fields: importedContent?.fields || formFields
}; };
const updated = [...allTemplates, newTpl]; const updated = [...allTemplates, newTpl];
setTemplates([...templates, newTpl]); setTemplates([...templates, newTpl]);
storage.set('templates', updated); storage.set('templates', updated);
setCurrentTemplateId(newTpl.id); setCurrentTemplateId(newTpl.id);
if (importedContent?.fields && importedContent.fields.length > 0) {
setFormFields(importedContent.fields);
storage.set('formFieldsConfig', importedContent.fields);
}
const savedUsers = storage.get<User[]>('users', []); const savedUsers = storage.get<User[]>('users', []);
let updatedUsers = savedUsers; let updatedUsers = savedUsers;
@@ -663,6 +716,7 @@ export default function TemplateManage() {
} }
} }
setIsModalOpen(false); setIsModalOpen(false);
setImportedContent(null);
}; };
if (!currentUser) return null; if (!currentUser) return null;
@@ -708,6 +762,12 @@ export default function TemplateManage() {
> >
</button> </button>
<button
onClick={(e) => { e.stopPropagation(); handleExportTemplate(tpl); }}
className="px-2 py-1 rounded-md bg-blue-50 text-blue-600 text-[10px] font-bold hover:bg-blue-100 transition-colors"
>
</button>
{templates.length > 1 && ( {templates.length > 1 && (
<button <button
onClick={(e) => { e.stopPropagation(); handleDeleteTemplate(tpl.id); }} onClick={(e) => { e.stopPropagation(); handleDeleteTemplate(tpl.id); }}
@@ -1291,6 +1351,19 @@ export default function TemplateManage() {
<div className="bg-white rounded-2xl p-10 w-full max-w-[500px] shadow-2xl border border-border"> <div className="bg-white rounded-2xl p-10 w-full max-w-[500px] shadow-2xl border border-border">
<h3 className="text-xl font-bold text-text-main mb-2">{isEditing ? '编辑模板信息' : '新增模板'}</h3> <h3 className="text-xl font-bold text-text-main mb-2">{isEditing ? '编辑模板信息' : '新增模板'}</h3>
<p className="text-sm text-text-muted mb-8"></p> <p className="text-sm text-text-muted mb-8"></p>
{!isEditing && (
<div className="flex items-center gap-3 mb-6 p-3 bg-slate-50 rounded-xl border border-dashed border-slate-200">
<div className="text-xs text-text-muted flex-1"></div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-8 h-8 bg-accent text-white rounded-lg flex items-center justify-center hover:bg-blue-700 transition-colors shadow-sm"
>
<Upload size={16} />
</button>
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImportFile} />
</div>
)}
<form onSubmit={handleModalSubmit} className="space-y-6"> <form onSubmit={handleModalSubmit} className="space-y-6">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> *</label> <label className="block text-xs font-bold text-text-main uppercase tracking-wider"> *</label>
@@ -1315,7 +1388,7 @@ export default function TemplateManage() {
<div className="flex justify-end gap-3 pt-4 border-t border-border"> <div className="flex justify-end gap-3 pt-4 border-t border-border">
<button <button
type="button" type="button"
onClick={() => setIsModalOpen(false)} onClick={() => { setIsModalOpen(false); setImportedContent(null); }}
className="px-6 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors" className="px-6 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors"
> >

View File

@@ -67,6 +67,7 @@ export interface Template {
createdAt: string; createdAt: string;
updatedAt?: string; updatedAt?: string;
author: string; author: string;
fields?: FormField[];
} }
export interface SystemSettings { export interface SystemSettings {

View File

@@ -6,9 +6,9 @@ const smartField = (key: string) => {
export const defaultReportContent = ` export const defaultReportContent = `
<div style="display: flex; justify-content: center; align-items: center; gap: 12px; margin-bottom: 4px;"> <div style="display: flex; justify-content: center; align-items: center; gap: 12px; margin-bottom: 4px;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="position:relative;display:inline-flex;align-items:center;justify-content:center;width:65px;height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;"> <span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-block;text-align:center;width:65px;height:65px;line-height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;">
<span class="delete-btn" contenteditable="false">×</span> <span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入图片</span> <span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">LOGO</span>
</span> </span>
<div style="text-align: center;"> <div style="text-align: center;">
<div style="font-size: 14pt; font-family: SimSun; border-bottom: 1px solid #000; padding-bottom: 0; margin-bottom: 8px; display: inline-block; line-height: 1;">西 安 交 通 大 学 第 一 附 属 医 院</div> <div style="font-size: 14pt; font-family: SimSun; border-bottom: 1px solid #000; padding-bottom: 0; margin-bottom: 8px; display: inline-block; line-height: 1;">西 安 交 通 大 学 第 一 附 属 医 院</div>

View File

@@ -0,0 +1,130 @@
# 实现方案 —— 2026-04-18-20-03-44
## 方案目标
实现模板的导入/导出迁移能力,统一默认模板 Logo 的交互行为。
## 需求 1模板导出功能
### 修改文件
`src/pages/TemplateManage.tsx`
### 修改内容
在模板列表的每个模板行操作列中增加「导出」按钮(使用 Download 图标)。点击时:
```ts
const handleExportTemplate = (template: Template) => {
const exportData = {
version: '1.0',
type: 'surclaw_template_package',
title: template.title,
description: template.description,
content: template.content,
fields: template.fields || []
};
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;
a.download = `模板导出-${template.title}.json`;
a.click();
URL.revokeObjectURL(url);
};
```
## 需求 2模板导入功能
### 修改文件
`src/pages/TemplateManage.tsx`
### 修改内容
1. **新增状态**
```ts
const [importedContent, setImportedContent] = useState<{content: string, fields: FormField[]} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
```
2. **新增导入处理函数**
```ts
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
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;
}
setNewTemplateTitle(json.title || '');
setNewTemplateDescription(json.description || '');
setImportedContent({
content: json.content || '',
fields: Array.isArray(json.fields) ? json.fields : []
});
} catch {
alert('文件解析失败,请检查 JSON 格式');
}
};
reader.readAsText(file);
};
```
3. **修改创建逻辑**:在 `handleCreateTemplate` 中,如果有 `importedContent`,优先使用导入的内容和字段:
```ts
const newTemplate: Template = {
id: 'tmpl_' + Date.now(),
title: newTemplateTitle,
description: newTemplateDescription,
content: importedContent?.content || `<div style="font-size:12pt;line-height:1.5;"><p>请输入模板内容...</p></div>`,
fields: importedContent?.fields || [],
createdAt: new Date().toISOString()
};
```
4. **UI 调整**:在新增模板 Modal 中标题下方加入导入区域:
```tsx
<div className="flex items-center gap-3 mb-4 p-3 bg-slate-50 rounded-xl border border-dashed border-slate-200">
<div className="text-xs text-text-muted flex-1">已有模板文件?点击右侧图标导入</div>
<button
onClick={() => fileInputRef.current?.click()}
className="w-8 h-8 bg-accent text-white rounded-lg flex items-center justify-center hover:bg-blue-700 transition-colors shadow-sm"
>
<Upload size={16} />
</button>
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImportFile} />
</div>
```
5. **关闭 Modal 时重置**`setImportedContent(null)`
## 需求 3Logo 替换为可交互占位符
### 修改文件
`src/utils/defaultContent.ts`
### 修改内容
将默认模板顶部的 Logo HTML 替换为标准 `image-placeholder`
```html
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-block;text-align:center;width:65px;height:65px;line-height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">LOGO</span>
</span>
```
关键点:
- `class="image-placeholder"`:触发编辑器中的占位符交互逻辑
- `data-mode="manual"`:标记为静态图片占位,不支持自动帧插入
- `position:relative` + `position:absolute` 居中:确保提示文字绝对居中
- `delete-btn`:支持点击右上方的「×」删除
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/pages/TemplateManage.tsx` | 新增 `handleExportTemplate`;新增 `importedContent` 状态和 `handleImportFile`;修改 `handleCreateTemplate` 使用导入数据;新增模板 Modal 中增加导入 UI模板列表操作列增加导出按钮 |
| `src/utils/defaultContent.ts` | 顶部 Logo 替换为标准 `image-placeholder` |
## 风险与注意事项
1. 导入的 JSON 中 `fields` 数组需要与 `FormField` 类型结构兼容。由于 JSON 导入的是纯数据,直接赋值给 `template.fields` 即可TypeScript 编译时类型校验通过)。
2. 导出文件名中包含模板标题,需注意标题中的特殊字符可能影响文件名(但浏览器通常会自动处理)。
3. Logo 占位符替换后,原有「西安交通大学第一附属医院」的样式应保持不变,仅替换 Logo 部分。
4. 新增模板弹窗关闭时,需同步重置 `importedContent` 为 `null`,避免影响下一次创建。

View File

@@ -0,0 +1,62 @@
# 测试方案 —— 2026-04-18-20-03-44
## 测试目标
验证模板导入/导出功能和默认模板 Logo 替换的正确性。
## 测试用例
### TC-1模板导出
**前置条件**:模板列表中已有至少一个模板,且该模板有内容和字段配置。
**步骤**
1. 在模板列表中找到目标模板。
2. 点击操作列的「导出」按钮。
**预期结果**
- 浏览器下载一个 JSON 文件,文件名为 `模板导出-{模板名称}.json`
- JSON 内容包含 `version``type``title``description``content``fields` 字段。
- `fields` 数组与模板原有的字段配置一致。
### TC-2模板导入自动填充名称和描述
**前置条件**:已有一个有效的模板导出 JSON 文件。
**步骤**
1. 点击「新增模板」按钮。
2. 在弹窗中点击导入图标,选择 JSON 文件。
**预期结果**
- 模板名称输入框自动填充为 JSON 中的 `title`
- 模板描述输入框自动填充为 JSON 中的 `description`
- 无控制台报错。
### TC-3模板导入后创建
**前置条件**:已完成 TC-2 的导入操作。
**步骤**
1. 点击「创建」按钮。
2. 在新创建的模板中点击「编辑模板」。
**预期结果**
- 编辑器中显示的内容与导入 JSON 中的 `content` 一致。
- 字段管理中的配置与导入 JSON 中的 `fields` 一致。
### TC-4导入无效文件
**前置条件**:准备一个非 JSON 文件或格式错误的 JSON。
**步骤**
1. 在新增模板弹窗中选择无效文件。
**预期结果**
- 弹出提示「文件解析失败,请检查 JSON 格式」或「无效的模板包文件」。
- 表单保持空白,不填充任何数据。
### TC-5Logo 占位符交互
**前置条件**:新建报告,默认模板已加载。
**步骤**
1. 查看顶部 Logo 区域。
2. 点击 Logo 占位符右上方的「×」。
3. 再次点击 Logo 区域。
**预期结果**
- Logo 区域显示为虚线框提示文字「LOGO」居中显示。
- 点击「×」后 Logo 占位符被删除。
- 再次点击可打开图片选择器插入图片。
## 回归测试
- 确保模板列表的加载、编辑、删除功能正常。
- 确保默认模板的其他部分(基础信息、手术步骤、图片表格等)不受影响。
- 确保打印样式正常。
## 测试通过标准
所有用例均通过,无控制台报错,导入/导出数据完整准确。

View File

@@ -1001,3 +1001,49 @@ if ((settings.autoInsertDelay || 0) > 0) {
- 填充时是否正确读取并应用这些限制值 - 填充时是否正确读取并应用这些限制值
- 所有填充入口(本地上传、签名插入、系统素材、自动帧插入、拖拽填充)是否同步更新 - 所有填充入口(本地上传、签名插入、系统素材、自动帧插入、拖拽填充)是否同步更新
- 默认模板中的占位符如果没有 `max-width`/`max-height`,回退逻辑 `|| placeholder.style.width` 仍能正确获取限制值,但后续修改默认模板时应注意统一添加 `max-width`/`max-height` 以显式声明意图。 - 默认模板中的占位符如果没有 `max-width`/`max-height`,回退逻辑 `|| placeholder.style.width` 仍能正确获取限制值,但后续修改默认模板时应注意统一添加 `max-width`/`max-height` 以显式声明意图。
---
## 记录 33四项编辑器体验优化集中实施
**A. 具体问题**
1. 视频分析面板中「上传视频」按钮位于视频缩略图列表首位,不符合「先列出现有项,最后提供添加操作」的操作直觉。
2. 图片占位符内的提示文字未在框中绝对居中,当占位符高度较大时文字明显偏上。
3. 删除占位符内已插入的图片后,占位符保持收缩后的 `width:auto; height:auto` 尺寸,未恢复为原始预设大小。
4. 点击「左对齐/居中/右对齐」按钮时,浏览器原生 `execCommand('justifyLeft')` 会用 `<div align="left">` 包裹选区,导致包含 `.field-value` 或 `.image-placeholder` 的段落被肢解,文字与输入框/图片强制换行分离。
**B. 产生问题原因**
1. 上一轮重构视频面板时,将上传按钮移入了缩略图列表,但放在了首位而非末尾。
2. 占位符提示文字使用默认的行内流布局居中,依赖于 `line-height` 和父容器的 `align-items: center`,在填充后 `line-height` 被改为 `normal`,导致文字不再居中。
3. 删除恢复逻辑仅重置了 `border` 和 `background`,未恢复 `width`、`height`、`lineHeight` 等尺寸属性。
4. `execCommand` 的对齐命令实现过于粗暴,会直接修改 DOM 树结构以创建对齐容器,无法安全地处理混合排版(文字 + 交互元素)。
**C. 解决问题方案**
1. **视频按钮位置**:将上传按钮从 `videos.map()` 之前移至之后,保持所有样式和点击逻辑不变。
2. **占位符文字绝对居中**
- 将 `.placeholder-text` 的样式统一改为 `position:absolute; top:50%; left:50%; transform:translate(-50%, -50%); display:block; width:100%;`
- 给所有表格内的 `.image-placeholder` 父容器添加 `position:relative;`inline-block 和签名占位符原本已有)
- 修改范围覆盖 `defaultContent.ts`8 个占位符)、`ReportEditor.tsx`Modal 插入 + 删除恢复)、`TemplateManage.tsx`Modal 插入 + 删除恢复)
3. **删除后恢复尺寸**:在删除恢复逻辑中增加:
```ts
const mw = placeholder.style.maxWidth;
const mh = placeholder.style.maxHeight;
if (mw) placeholder.style.width = mw;
if (mh) { placeholder.style.height = mh; placeholder.style.lineHeight = mh; }
placeholder.style.textAlign = 'center';
placeholder.style.verticalAlign = 'middle';
placeholder.style.justifyContent = 'center';
placeholder.style.alignItems = 'center';
```
同时根据占位符原始宽度(`maxWidth || width`)判断显示「插图」(<80px或「插入/点击放置图片」。
4. **安全对齐**:弃用 `execCommand('justifyLeft'/'justifyCenter'/'justifyRight')`,新增 `changeAlignment(align)` 方法:
- 通过 `window.getSelection()` 获取选区
- 使用 `closest('p, div, td, h1, h2, h3, li')` 找到最近的块级祖先
- 直接设置 `(block as HTMLElement).style.textAlign = align`
- 同步保存内容快照
- 对齐按钮增加 `onMouseDown={(e) => e.preventDefault()}` 防止编辑器失焦
**D. 后续如何避免问题**
- 当修改 `image-placeholder` 的创建或恢复逻辑时,必须在所有入口同步更新:`defaultContent.ts`(静态模板)、`ReportEditor.tsx`(运行时插入/填充/删除恢复)、`TemplateManage.tsx`(模板管理)。
- 任何涉及 `execCommand` 的富文本操作都应评估其安全性,优先使用直接 DOM 样式操作(如 `style.textAlign`、`style.lineHeight`)替代,避免浏览器原生命令对复杂 DOM 结构的不可控修改。
- 绝对定位的居中方案(`transform: translate(-50%, -50%)`)虽然效果稳定,但要求父容器必须带有 `position: relative`,修改时需同步检查所有父容器的样式。

View File

@@ -0,0 +1,52 @@
# 需求分析 —— 2026-04-18-20-03-44
## 需求来源
用户希望增强模板管理模块的数据迁移能力和默认模板的交互一致性。
## 需求概述
### 需求 1模板导出功能
`TemplateManage` 的模板列表中,新增「导出」按钮。导出内容需包含:
- 模板名称(`title`
- 模板描述(`description`
- 模板内容(`content`
- 字段管理配置(`fields`
导出格式为 JSON结构如下
```json
{
"version": "1.0",
"type": "surclaw_template_package",
"title": "...",
"description": "...",
"content": "...",
"fields": []
}
```
### 需求 2模板导入功能
在「新增模板」弹窗中,新增「导入本地模板」选项。用户选择 JSON 文件后:
- 自动解析并填充模板名称和描述到表单
- 暂存模板内容和字段配置
- 点击「创建」时,将暂存的内容和字段一并写入新模板
导入 UI 使用指定的样式类名:`w-8 h-8 bg-accent text-white rounded-lg flex items-center justify-center hover:bg-blue-700 transition-colors shadow-sm`
### 需求 3Logo 替换为可交互占位符
默认模板 `defaultContent.ts` 中顶部医院 Logo 当前为硬编码的 `<span>` 结构(非标准 `image-placeholder`),导致:
- 无法点击右上方的「×」删除
- 无法触发图片上传/选择逻辑
- 与编辑器中其他图片占位符的交互不一致
需将其替换为标准的 65×65 `image-placeholder``data-mode="manual"`),使其支持删除、点击插入等完整交互。
## 涉及文件
- `src/pages/TemplateManage.tsx`(需求 1、2
- `src/utils/defaultContent.ts`(需求 3
- `src/types.ts`(确认 Template 类型结构)
## 需求影响范围
- 模板列表操作列新增导出按钮
- 新增模板弹窗新增导入 UI 和逻辑
- 默认模板头部 Logo 的 HTML 结构
- 模板创建流程需支持字段配置写入