diff --git a/src/index.css b/src/index.css index 73a5363..bee5f3f 100644 --- a/src/index.css +++ b/src/index.css @@ -127,6 +127,24 @@ .smart-field-wrapper .field-value:empty::before { content: '\200b'; } + .smart-field-wrapper .delete-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + margin-right: 2px; + background: #ef4444; + color: white; + border-radius: 50%; + font-size: 10px; + line-height: 1; + cursor: pointer; + user-select: none; + } + .smart-field-wrapper .delete-btn:hover { + background: #dc2626; + } } @media print { @@ -163,4 +181,7 @@ background: transparent !important; padding: 0 2px !important; } + .print-content .smart-field-wrapper .delete-btn { + display: none !important; + } } diff --git a/src/pages/ReportManage.tsx b/src/pages/ReportManage.tsx index 3a07a16..ae9de4c 100644 --- a/src/pages/ReportManage.tsx +++ b/src/pages/ReportManage.tsx @@ -1,9 +1,10 @@ import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import Sidebar from '../components/Sidebar'; -import { Search, Eye, Edit, Trash2, FileText, History, X } from 'lucide-react'; -import { User, Report } from '../types'; +import { Search, Eye, Edit, Trash2, FileText, History, X, Download, Printer } from 'lucide-react'; +import { User, Report, DEFAULT_FORM_FIELDS } from '../types'; import { storage } from '../utils/storage'; +import { printDocument } from '../utils/print'; const formatDateTime = (iso: string) => { if (!iso) return '-'; @@ -23,6 +24,9 @@ export default function ReportManage() { const [dateFilter, setDateFilter] = useState(''); const [historyModalOpen, setHistoryModalOpen] = useState(false); const [historyReport, setHistoryReport] = useState(null); + const [selectedIds, setSelectedIds] = useState([]); + const [exportModalOpen, setExportModalOpen] = useState(false); + const [exportTarget, setExportTarget] = useState(null); useEffect(() => { const user = storage.get('currentUser', null); @@ -82,6 +86,7 @@ export default function ReportManage() { const updatedReports = reports.filter(r => r.id !== id); setReports(updatedReports); storage.set('reports', updatedReports); + setSelectedIds(prev => prev.filter(pid => pid !== id)); } }; @@ -106,6 +111,82 @@ export default function ReportManage() { setHistoryModalOpen(false); }; + const toggleSelect = (id: string) => { + setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]); + }; + + const toggleSelectAll = () => { + if (selectedIds.length === filteredReports.length && filteredReports.length > 0) { + setSelectedIds([]); + } else { + setSelectedIds(filteredReports.map(r => r.id)); + } + }; + + const handleBulkDelete = () => { + if (!window.confirm(`确定要删除选中的 ${selectedIds.length} 份报告吗?`)) return; + const updated = reports.filter(r => !selectedIds.includes(r.id)); + setReports(updated); + storage.set('reports', updated); + setSelectedIds([]); + }; + + const buildExportData = (report: Report) => { + const fields: Record = {}; + DEFAULT_FORM_FIELDS.forEach(f => { + fields[f.key] = (report as any)[f.key]; + }); + return { + meta: { + id: report.id, + title: report.title, + createdAt: report.createdAt, + updatedAt: report.updatedAt, + author: report.author, + authorName: report.authorName, + status: report.status + }, + fields + }; + }; + + const downloadJSON = (data: any, filename: string) => { + 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 = filename; + a.click(); + URL.revokeObjectURL(url); + }; + + const exportSinglePDF = (report: Report) => { + printDocument(report.content); + }; + + const exportSingleJSON = (report: Report) => { + const data = buildExportData(report); + downloadJSON(data, `报告_${report.patientName || '未命名'}_${report.id}.json`); + }; + + const exportBulkPDF = () => { + const selectedReports = reports.filter(r => selectedIds.includes(r.id)); + const mergedHTML = selectedReports.map(r => r.content).join('
'); + printDocument(mergedHTML); + }; + + const exportBulkJSON = () => { + const selectedReports = reports.filter(r => selectedIds.includes(r.id)); + const data = selectedReports.map(r => buildExportData(r)); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + downloadJSON(data, `reports_export_${timestamp}.json`); + }; + + const openExportModal = (report: Report) => { + setExportTarget(report); + setExportModalOpen(true); + }; + if (!currentUser) return null; return ( @@ -122,7 +203,7 @@ export default function ReportManage() { -
+
本月
+ + {selectedIds.length > 0 && ( +
+ 已选择 {selectedIds.length} 项 +
+ + + + +
+ )}
+ @@ -174,6 +294,14 @@ export default function ReportManage() { {filteredReports.length > 0 ? ( filteredReports.map((report) => ( + )) ) : ( -
+ 0 && selectedIds.length === filteredReports.length} + onChange={toggleSelectAll} + /> + 报告信息 患者 患者号
+ toggleSelect(report.id)} + /> +
{report.title}
{report.id}
@@ -228,13 +356,20 @@ export default function ReportManage() { > +
+

暂无报告

@@ -297,6 +432,45 @@ export default function ReportManage() {
)} + + {exportModalOpen && exportTarget && ( +
+
+
+

导出报告

+ +
+

选择导出格式:

+
+ + +
+
+
+ )} ); } diff --git a/src/pages/TemplateManage.tsx b/src/pages/TemplateManage.tsx index c7680c7..e616bbc 100644 --- a/src/pages/TemplateManage.tsx +++ b/src/pages/TemplateManage.tsx @@ -112,7 +112,7 @@ export default function TemplateManage() { input.click(); }; - // Handle image placeholder interactions via click capture for reliable contenteditable behavior + // Handle image placeholder and smart field delete interactions via click capture useEffect(() => { const handleEditorClick = (e: MouseEvent) => { // e.target may be a text node; safely resolve to an Element @@ -121,6 +121,15 @@ export default function TemplateManage() { const targetEl = node as HTMLElement | null; if (!targetEl) return; + const smartField = targetEl.closest('.smart-field-wrapper') as HTMLElement | null; + if (smartField && targetEl.closest('.delete-btn')) { + e.stopPropagation(); + e.preventDefault(); + smartField.remove(); + saveTemplateContent(); + return; + } + const placeholder = targetEl.closest('.image-placeholder') as HTMLElement | null; if (!placeholder) return; @@ -173,34 +182,43 @@ export default function TemplateManage() { if (!sel || !sel.isCollapsed || sel.rangeCount === 0) return; const range = sel.getRangeAt(0); const node = range.startContainer; - if (node.nodeType !== Node.TEXT_NODE) return; const offset = range.startOffset; - if (e.key === 'Backspace' && offset === 0) { - const prev = node.previousSibling; - if (prev && prev.nodeType === Node.ELEMENT_NODE && (prev as Element).classList?.contains('smart-field-wrapper')) { - e.preventDefault(); - prev.remove(); - const allTemplates = storage.get('templates', []); - const updated = allTemplates.map(t => - t.id === currentTemplateId ? { ...t, content: editorRef.current!.innerHTML, updatedAt: new Date().toISOString() } : t - ); - setTemplates(prevTemplates => prevTemplates.map(t => updated.find(u => u.id === t.id) || t)); - storage.set('templates', updated); + let target: Element | null = null; + + if (node.nodeType === Node.TEXT_NODE) { + if (e.key === 'Backspace' && offset === 0) { + const prev = node.previousSibling; + if (prev && prev.nodeType === Node.ELEMENT_NODE && (prev as Element).classList?.contains('smart-field-wrapper')) { + target = prev as Element; + } + } else if (e.key === 'Delete' && offset === (node.textContent?.length || 0)) { + const next = node.nextSibling; + if (next && next.nodeType === Node.ELEMENT_NODE && (next as Element).classList?.contains('smart-field-wrapper')) { + target = next as Element; + } } - } else if (e.key === 'Delete' && offset === (node.textContent?.length || 0)) { - const next = node.nextSibling; - if (next && next.nodeType === Node.ELEMENT_NODE && (next as Element).classList?.contains('smart-field-wrapper')) { - e.preventDefault(); - next.remove(); - const allTemplates = storage.get('templates', []); - const updated = allTemplates.map(t => - t.id === currentTemplateId ? { ...t, content: editorRef.current!.innerHTML, updatedAt: new Date().toISOString() } : t - ); - setTemplates(prevTemplates => prevTemplates.map(t => updated.find(u => u.id === t.id) || t)); - storage.set('templates', updated); + } else if (node.nodeType === Node.ELEMENT_NODE) { + // Cursor is directly inside a block element (e.g.

) at a boundary + const el = node as Element; + if (e.key === 'Backspace' && offset > 0) { + const prev = el.childNodes[offset - 1]; + if (prev && prev.nodeType === Node.ELEMENT_NODE && (prev as Element).classList?.contains('smart-field-wrapper')) { + target = prev as Element; + } + } else if (e.key === 'Delete' && offset < el.childNodes.length) { + const next = el.childNodes[offset]; + if (next && next.nodeType === Node.ELEMENT_NODE && (next as Element).classList?.contains('smart-field-wrapper')) { + target = next as Element; + } } } + + if (target) { + e.preventDefault(); + target.remove(); + saveTemplateContent(); + } }; editor.addEventListener('keydown', handleKeyDown, true); @@ -215,9 +233,23 @@ export default function TemplateManage() { editorRef.current?.focus(); }; + const saveTemplateContent = () => { + if (!currentTemplateId || !editorRef.current) return; + const allTemplates = storage.get('templates', []); + const updated = allTemplates.map(t => + t.id === currentTemplateId ? { ...t, content: editorRef.current!.innerHTML, updatedAt: new Date().toISOString() } : t + ); + setTemplates(prevTemplates => prevTemplates.map(t => updated.find(u => u.id === t.id) || t)); + storage.set('templates', updated); + }; + const insertSmartField = (field: FormField) => { editorRef.current?.focus(); - const html = ` `; + if (editorRef.current?.querySelector(`[data-bind="${field.key}"]`)) { + alert(`字段 "${field.label}" 已存在,请勿重复插入。`); + return; + } + const html = `× `; document.execCommand('insertHTML', false, html); editorRef.current?.focus(); }; diff --git a/src/utils/defaultContent.ts b/src/utils/defaultContent.ts index a798d74..ca740f0 100644 --- a/src/utils/defaultContent.ts +++ b/src/utils/defaultContent.ts @@ -1,4 +1,4 @@ -const smartField = (key: string) => ` `; +const smartField = (key: string) => `× `; export const defaultReportContent = ` diff --git a/工程分析/实现方案-2026-04-17-10-21-18.md b/工程分析/实现方案-2026-04-17-10-21-18.md new file mode 100644 index 0000000..cc72a69 --- /dev/null +++ b/工程分析/实现方案-2026-04-17-10-21-18.md @@ -0,0 +1,138 @@ +# 实现方案 — 模板字段唯一性、删除交互与报告批量导出(2026-04-17-10-21-18) + +## 一、修改文件清单 + +1. `src/pages/TemplateManage.tsx` — 字段唯一性校验 + 删除按钮 + 键盘删除增强 +2. `src/utils/defaultContent.ts` — `smartField()` 增加删除按钮 HTML +3. `src/index.css` — 智能字段删除按钮样式 +4. `src/pages/ReportManage.tsx` — 复选框、批量操作栏、导出功能 + +## 二、详细改动 + +### 2.1 `src/pages/TemplateManage.tsx` + +#### A. `insertSmartField` 增加唯一性校验 +```ts +const insertSmartField = (field: FormField) => { + editorRef.current?.focus(); + if (editorRef.current?.querySelector(`[data-bind="${field.key}"]`)) { + alert(`字段 "${field.label}" 已存在,请勿重复插入。`); + return; + } + // ... 原有 insertHTML 逻辑,同时给 wrapper 增加 delete-btn +}; +``` + +#### B. 给 `smart-field-wrapper` 增加删除按钮 +插入的 HTML 改为: +```html + + × + + +``` + +#### C. 点击删除按钮事件(复用或扩展已有的 `handleEditorClick` capture 事件) +在已有的 `handleEditorClick` 中,除了处理 `.image-placeholder`,再增加对 `.delete-btn` 在 `.smart-field-wrapper` 内的判断: +```ts +const wrapper = targetEl.closest('.smart-field-wrapper') as HTMLElement | null; +if (targetEl.closest('.delete-btn') && wrapper) { + e.preventDefault(); + e.stopPropagation(); + wrapper.remove(); + // 同步保存模板内容到 localStorage + return; +} +``` + +#### D. 增强 `keydown` 删除逻辑 +当前逻辑只处理 "光标在文本节点内且 offset 为 0/末尾" 的情况。需要额外处理: +- 光标直接在字段后面(`range.startContainer` 是 `

`,`range.startOffset` 指向字段节点位置)时按 Backspace。 +- 光标直接在字段前面时按 Delete。 +- 选区 collapsed 且紧邻字段的各种边界情况。 + +实现策略:在 `keydown` 中统一写一个 `findAdjacentSmartField(range, direction)` 辅助函数,先尝试从文本节点 sibling 找,若找不到则从父级块节点的 childNodes 中按 offset 找。 + +### 2.2 `src/utils/defaultContent.ts` + +修改 `smartField(key)` 辅助函数,使其输出包含 `×` 的单行 HTML。 + +### 2.3 `src/index.css` + +新增 `.smart-field-wrapper .delete-btn` 的样式: +```css +.smart-field-wrapper .delete-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + margin-right: 2px; + background: #ef4444; + color: white; + border-radius: 50%; + font-size: 10px; + line-height: 1; + cursor: pointer; + user-select: none; +} +.smart-field-wrapper .delete-btn:hover { + background: #dc2626; +} +@media print { + .smart-field-wrapper .delete-btn { + display: none !important; + } +} +``` + +### 2.4 `src/pages/ReportManage.tsx` + +#### A. 状态扩展 +```ts +const [selectedIds, setSelectedIds] = useState([]); +const [exportModalOpen, setExportModalOpen] = useState(false); +const [exportTarget, setExportTarget] = useState(null); +``` + +#### B. 复选框与全选逻辑 +- 表头 `

` 增加 Checkbox,状态为 `selectedIds.length === filteredReports.length && filteredReports.length > 0`。 +- 每行 `` 最左侧增加 Checkbox,checked 状态为 `selectedIds.includes(report.id)`,onChange 时切换选中状态。 +- 当 `selectedIds.length > 0` 时,在搜索栏下方显示批量操作栏(flex row),包含: + - `已选择 N 项` 文本 + - **批量删除** 按钮(红色) + - **批量导出 PDF** 按钮 + - **批量导出 JSON** 按钮 + - **取消选择** 按钮 + +#### C. 单报告导出 +- **PDF**:调用 `printDocument(report.content)`。 +- **JSON**:构建对象: + ```ts + const exportData = { + meta: { id: report.id, title: report.title, createdAt: report.createdAt, updatedAt: report.updatedAt, author: report.author, authorName: report.authorName, status: report.status }, + fields: { /* 所有 DEFAULT_FORM_FIELDS 的 key 对应的值 */ } + }; + ``` + 通过 `Blob` + `URL.createObjectURL` + `` 触发下载。 + +#### D. 批量导出 +- **批量 PDF**:`const mergedHTML = selectedReports.map(r => r.content).join('
'); printDocument(mergedHTML);` +- **批量 JSON**:`const exportData = selectedReports.map(r => ({ meta: ..., fields: ... }));` 同样通过 Blob 下载为 `reports_export_时间戳.json`。 + +#### E. 批量删除 +```ts +const handleBulkDelete = () => { + if (!window.confirm(`确定要删除选中的 ${selectedIds.length} 份报告吗?`)) return; + const updated = reports.filter(r => !selectedIds.includes(r.id)); + setReports(updated); + storage.set('reports', updated); + setSelectedIds([]); +}; +``` + +## 三、风险与回滚 + +- **风险**:修改 `defaultContent.ts` 中的 `smartField` HTML 结构后,旧模板中已存在的 `smart-field-wrapper` 没有删除按钮。这属于正常行为(旧数据不 retroactive),新建报告时的新模板会带删除按钮。 +- **风险**:`keydown` 删除逻辑改动较大,可能在不同浏览器下对边界 selection 的解析有差异,需要手工测试。 +- **回滚**:如出现问题,可回退 `TemplateManage.tsx`、`defaultContent.ts`、`index.css`、`ReportManage.tsx` 的修改。 diff --git a/工程分析/测试方案-2026-04-17-10-21-18.md b/工程分析/测试方案-2026-04-17-10-21-18.md new file mode 100644 index 0000000..d532aa0 --- /dev/null +++ b/工程分析/测试方案-2026-04-17-10-21-18.md @@ -0,0 +1,47 @@ +# 测试方案 — 模板字段唯一性、删除交互与报告批量导出(2026-04-17-10-21-18) + +## 一、编译检查 + +- 执行 `npm run lint`(`tsc --noEmit`),确保全量 TypeScript 无编译错误。 + +## 二、功能验证步骤 + +### 测试 1:TemplateManage 字段唯一性 +1. 进入【模板管理】,选择一个模板。 +2. 在右侧字段库点击"姓名"(`patientName`),确认成功插入智能字段方框。 +3. 再次点击"姓名",确认弹出提示 `"姓名" 已存在,请勿重复插入。`,且没有再次插入方框。 + +### 测试 2:TemplateManage 字段删除(点击删除按钮) +1. 在模板编辑器中,鼠标悬停在任意智能字段方框上,确认左上角出现红色小圆 ×。 +2. 点击该 ×,确认字段方框被移除,模板内容自动保存。 +3. 刷新页面,确认该字段确实已被删除。 + +### 测试 3:TemplateManage 字段删除(键盘 Backspace/Delete) +1. 将光标定位在智能字段方框**正后方**(字段与后续文字之间),按 Backspace,确认字段被删除。 +2. 将光标定位在智能字段方框**正前方**(段落开头,字段前面),按 Delete,确认字段被删除。 +3. 尝试在段落中间的其他位置按 Backspace/Delete,确认不影响正常文本编辑。 + +### 测试 4:ReportManage 单报告导出 +1. 进入【报告管理】,确保列表中至少有一份已完成的报告。 +2. 点击某报告操作列的"导出"按钮,弹出导出选项弹窗。 +3. 选择 **PDF**:确认调用 `printDocument` 弹出浏览器打印窗口(可选择"另存为 PDF")。 +4. 再次点击"导出",选择 **JSON**:确认浏览器下载了一个 `.json` 文件。 +5. 打开该 JSON 文件,确认结构包含 `meta`(id、title、createdAt 等)和 `fields`(patientName、hospitalId 等字段值)。 + +### 测试 5:ReportManage 复选框与批量删除 +1. 在报告列表中,点击多行的左侧复选框,确认 `selectedIds` 状态更新,顶部出现批量操作栏并显示"已选择 N 项"。 +2. 点击表头全选 Checkbox,确认所有行被选中;再次点击,确认全部取消。 +3. 选中 2 份报告,点击批量操作栏的"批量删除",在确认弹窗中点击"取消",确认报告未被删除。 +4. 再次点击"批量删除"并确认,确认选中的报告从列表和 localStorage 中移除,批量操作栏消失。 + +### 测试 6:ReportManage 批量导出 +1. 选中 2 份报告,点击"批量导出 JSON",确认下载的 JSON 文件中包含一个数组,数组长度为 2,每个元素结构同单报告导出。 +2. 选中 2 份报告,点击"批量导出 PDF",确认弹出浏览器打印窗口,打印内容中两份报告之间有明显的分页(或分页符空白)。 + +## 三、预期结果 + +- `npm run lint` 0 错误。 +- 模板字段唯一性校验生效,重复插入被阻止。 +- 模板字段可通过点击 × 或键盘 Backspace/Delete 删除。 +- 报告管理支持单报告 PDF/JSON 导出。 +- 报告管理支持复选框全选、批量删除、批量 PDF/JSON 导出。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 5ef3ae8..253fa31 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -345,6 +345,38 @@ if ((settings.autoInsertDelay || 0) > 0) { --- +## 记录 16:模板字段唯一性、删除按钮与报告批量导出 + +**A. 具体问题** +1. `TemplateManage` 中智能字段可以重复插入多次,导致模板混乱。 +2. 智能字段在某些边界位置(如段落开头/结尾)无法通过 Backspace/Delete 删除。 +3. `ReportManage` 缺少报告导出功能和批量操作能力。 + +**B. 产生问题原因** +1. `insertSmartField` 没有检测 DOM 中是否已存在相同 `data-bind` 的字段节点。 +2. 之前的 `keydown` 拦截逻辑只处理了光标在文本节点内的情况,没有处理光标直接在块级父节点边界(`startContainer` 为 `

` 等块元素)的场景。 +3. `ReportManage` 的设计只支持单条查看/编辑/删除,没有设计多选状态和导出逻辑。 + +**C. 解决问题方案** +1. **唯一性校验**:在 `insertSmartField` 中通过 `editorRef.current?.querySelector([data-bind="..."])` 预检查,若已存在则 `alert` 并终止插入。 +2. **删除按钮**:给 `.smart-field-wrapper` 内部增加一个红色圆形的 `×`,点击即可删除整个字段节点。同时在 `index.css` 和 `print` 媒体查询中分别定义显示/隐藏样式。 +3. **键盘删除增强**:重写 `keydown` 处理器,同时处理 `startContainer` 为 `TEXT_NODE` 和 `ELEMENT_NODE` 两种情况。当光标位于块级父节点的子节点边界时,通过 `el.childNodes[offset - 1]` 或 `el.childNodes[offset]` 定位字段节点并安全删除。 +4. **报告批量操作**: + - 在 `ReportManage.tsx` 中引入 `selectedIds` 状态,表格每行增加 Checkbox,表头支持全选/反选。 + - 增加浮动批量操作栏,支持"批量删除"、"批量导出 PDF"、"批量导出 JSON"、"取消选择"。 + - 单报告操作列增加"导出"按钮,点击弹出模态框选择 PDF 或 JSON。 + - PDF 导出复用现有的 `printDocument(content)`;JSON 导出通过 `Blob` + `URL.createObjectURL` 实现下载,数据结构包含 `meta`(报告元信息)和 `fields`(所有 `DEFAULT_FORM_FIELDS` 对应值)。 + - 批量 PDF 将多份报告的 HTML 用 `

` 拼接后统一打印。 + - 批量 JSON 将多份报告导出为数组形式的单个 `.json` 文件。 + +**D. 后续如何避免问题** +- 在 `contentEditable` 中插入的任何可复用控件,都应考虑增加唯一性校验和明确的删除入口(可视化按钮 + 键盘事件拦截)。 +- 键盘事件处理不能假设 `startContainer` 一定是文本节点,必须覆盖块级元素边界的情况。 +- 当列表页需要增加批量操作时,建议将"选择状态"和"批量动作"封装为独立逻辑,保持单条操作按钮的可维护性。 +- 导出功能应尽量复用现有的 `printDocument` 等工具函数,减少新依赖引入。 + +--- + ## 记录 14:智能字段插入间距修复与 Backspace 防误删 **A. 具体问题** diff --git a/工程分析/需求分析-2026-04-17-10-21-18.md b/工程分析/需求分析-2026-04-17-10-21-18.md new file mode 100644 index 0000000..adb4f9e --- /dev/null +++ b/工程分析/需求分析-2026-04-17-10-21-18.md @@ -0,0 +1,56 @@ +# 需求分析 — 模板字段唯一性、删除交互与报告批量导出(2026-04-17-10-21-18) + +## 一、需求来源 + +用户反馈 TemplateManage 中智能字段存在无法删除和重复插入的问题,同时要求升级 ReportManage 的报告管理能力,支持单份/多份报告的结构化导出。 + +## 二、具体需求拆解 + +### 需求 1:模板字段唯一性校验 + +**问题**:`TemplateManage` 中点击字段库按钮插入智能字段时,同一个字段(如"姓名")可以被重复插入多次,导致模板混乱。 + +**期望**:每个 `data-bind` 对应的智能字段在编辑器中只能存在一份,若已存在则提示用户并阻止插入。 + +### 需求 2:模板字段删除交互优化 + +**问题**:默认模板中的智能字段以及手动插入的智能字段,按下 Backspace/Delete 时经常无法删除(尤其是字段位于段落开头/结尾时,浏览器默认行为会误删父级 `

`)。 + +**期望**: +- 键盘 Backspace/Delete 能正确删除相邻的智能字段。 +- 为智能字段增加可视化删除按钮(类似图片占位符的 ×),用户可直接点击删除。 + +### 需求 3:ReportManage 单报告导出(PDF / JSON) + +**期望**:在报告管理列表的每行操作列增加"导出"按钮,点击后弹出选项: +- **导出 PDF**:调用现有的 `printDocument` 打印报告 HTML 内容,由用户在浏览器打印弹窗中选择"另存为 PDF"。 +- **导出 JSON**:下载结构化 JSON 文件,文件内包含该报告所有 `data-bind` 对应字段的值( patientName、hospitalId、title、surgeryDate 等 ),以及报告元信息(id、createdAt 等)。 + +### 需求 4:ReportManage 批量操作(复选框 + 批量删除 / 批量导出) + +**期望**: +- 表格每行最左侧增加复选框,表头增加全选/反选复选框。 +- 当有报告被选中时,表格上方显示批量操作栏,包含: + - **批量删除**:确认后从 `localStorage` 中移除所有选中报告。 + - **批量导出 PDF**:将选中报告的 `content` 按分页符拼接后调用 `printDocument`,一次性生成多页 PDF。 + - **批量导出 JSON**:将选中报告数组导出为单个 JSON 文件下载。 + +## 三、影响范围分析 + +| 文件 | 改动说明 | +|------|----------| +| `src/pages/TemplateManage.tsx` | `insertSmartField` 增加唯一性校验;增强 `keydown` 删除逻辑;给 `smart-field-wrapper` 增加删除按钮及点击事件处理。 | +| `src/utils/defaultContent.ts` | 默认模板中预生成的 `smartField()` 需要包含删除按钮 HTML。 | +| `src/index.css` | 增加 `.smart-field-wrapper .delete-btn` 的样式。 | +| `src/pages/ReportManage.tsx` | 增加 `selectedIds` 状态、复选框列、批量操作栏、导出弹窗/下拉、单报告导出函数、批量导出函数。 | +| `src/utils/print.ts` | 可能需要提供 `printDocument` 的复用,无需修改。 | + +## 四、验收标准 + +- [ ] `TemplateManage` 中已存在的字段再次插入时弹出提示并被阻止。 +- [ ] `TemplateManage` 中点击智能字段左上角的 × 可直接删除该字段。 +- [ ] `TemplateManage` 中按 Backspace/Delete 可正确删除光标相邻的智能字段(包括段落边界场景)。 +- [ ] `ReportManage` 操作列出现"导出"按钮,支持单报告 PDF/JSON 导出。 +- [ ] `ReportManage` 表格出现复选框,支持全选/反选。 +- [ ] 选中报告后显示批量操作栏,支持批量删除、批量 PDF 导出、批量 JSON 导出。 +- [ ] 导出 JSON 的文件内容包含所有 `data-bind` 字段值及报告元信息。