feat: smart field uniqueness, delete button, bulk export in report manage

- TemplateManage: add uniqueness check for smart fields to prevent duplicate inserts
- Add red circular delete button to smart-field-wrapper (visible on hover via CSS)
- Enhance keydown handler to delete smart fields at block-level boundaries
- Update defaultContent.ts smartField() to include delete-btn
- ReportManage: add per-row checkboxes, select-all, bulk delete
- Add single-report export modal (PDF via printDocument, JSON via Blob)
- Add bulk export actions for PDF and JSON
- Update experience record (#16)
This commit is contained in:
2026-04-17 10:32:07 +08:00
parent 38ff67a6a8
commit db5df13a05
8 changed files with 530 additions and 30 deletions

View File

@@ -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;
}
}

View File

@@ -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<Report | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportTarget, setExportTarget] = useState<Report | null>(null);
useEffect(() => {
const user = storage.get<User | null>('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<string, any> = {};
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('<div style="page-break-after: always;"></div>');
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() {
</div>
</header>
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex flex-wrap gap-4 mb-4">
<div className="relative flex-1 min-w-[240px] max-w-[400px]">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 text-text-muted" size={18} />
<input
@@ -155,12 +236,51 @@ export default function ReportManage() {
<option value="month"></option>
</select>
</div>
{selectedIds.length > 0 && (
<div className="flex items-center gap-3 mb-4 p-3 bg-slate-50 border border-border rounded-lg">
<span className="text-sm font-semibold text-text-main"> {selectedIds.length} </span>
<div className="flex-1"></div>
<button
onClick={exportBulkPDF}
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-white border border-border hover:bg-slate-100 transition-colors flex items-center gap-1"
>
<Printer size={14} /> PDF
</button>
<button
onClick={exportBulkJSON}
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-white border border-border hover:bg-slate-100 transition-colors flex items-center gap-1"
>
<Download size={14} /> JSON
</button>
<button
onClick={handleBulkDelete}
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
>
</button>
<button
onClick={() => setSelectedIds([])}
className="px-3 py-1.5 text-sm font-medium rounded-lg text-text-muted hover:bg-slate-100 transition-colors"
>
</button>
</div>
)}
<div className="card-minimal p-0 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-slate-50">
<th className="px-4 py-4 text-left border-b border-border w-10">
<input
type="checkbox"
className="w-4 h-4 rounded border-border text-accent focus:ring-accent"
checked={filteredReports.length > 0 && selectedIds.length === filteredReports.length}
onChange={toggleSelectAll}
/>
</th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
@@ -174,6 +294,14 @@ export default function ReportManage() {
{filteredReports.length > 0 ? (
filteredReports.map((report) => (
<tr key={report.id} className="hover:bg-slate-50 transition-colors group">
<td className="px-4 py-4 border-b border-border">
<input
type="checkbox"
className="w-4 h-4 rounded border-border text-accent focus:ring-accent"
checked={selectedIds.includes(report.id)}
onChange={() => toggleSelect(report.id)}
/>
</td>
<td className="px-6 py-4">
<div className="text-sm font-semibold text-text-main">{report.title}</div>
<div className="text-xs text-text-muted font-mono mt-1">{report.id}</div>
@@ -228,13 +356,20 @@ export default function ReportManage() {
>
<History size={16} />
</button>
<button
onClick={() => openExportModal(report)}
className="p-2 rounded-lg bg-emerald-50 text-emerald-600 hover:bg-emerald-100 transition-colors"
title="导出"
>
<Download size={16} />
</button>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={7} className="px-6 py-24 text-center">
<td colSpan={8} className="px-6 py-24 text-center">
<div className="flex flex-col items-center text-text-muted">
<FileText size={48} className="mb-4 opacity-20" />
<h3 className="text-base font-semibold text-text-main mb-1"></h3>
@@ -297,6 +432,45 @@ export default function ReportManage() {
</div>
</div>
)}
{exportModalOpen && exportTarget && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-text-main"></h3>
<button
onClick={() => setExportModalOpen(false)}
className="p-2 rounded-lg hover:bg-slate-100 text-text-muted transition-colors"
>
<X size={18} />
</button>
</div>
<p className="text-sm text-text-muted mb-4"></p>
<div className="flex flex-col gap-3">
<button
onClick={() => { exportSinglePDF(exportTarget); setExportModalOpen(false); }}
className="w-full px-4 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 transition-colors flex items-center gap-3"
>
<Printer size={18} className="text-text-muted" />
<div className="text-left">
<div className="text-sm font-semibold text-text-main"> PDF</div>
<div className="text-xs text-text-muted"> PDF</div>
</div>
</button>
<button
onClick={() => { exportSingleJSON(exportTarget); setExportModalOpen(false); }}
className="w-full px-4 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 transition-colors flex items-center gap-3"
>
<Download size={18} className="text-text-muted" />
<div className="text-left">
<div className="text-sm font-semibold text-text-main"> JSON</div>
<div className="text-xs text-text-muted"></div>
</div>
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -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<Template[]>('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<Template[]>('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. <p>) 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<Template[]>('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 = `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;"><span class="field-value" data-bind="${field.key}" contenteditable="true" style="min-width:32px;padding:0 4px;margin:0 2px;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:1.2;font-size:inherit;vertical-align:text-bottom;box-sizing:border-box;min-height:1.2em;"> </span></span>`;
if (editorRef.current?.querySelector(`[data-bind="${field.key}"]`)) {
alert(`字段 "${field.label}" 已存在,请勿重复插入。`);
return;
}
const html = `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;"><span class="delete-btn" contenteditable="false">×</span><span class="field-value" data-bind="${field.key}" contenteditable="true" style="min-width:32px;padding:0 4px;margin:0 2px;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:1.2;font-size:inherit;vertical-align:text-bottom;box-sizing:border-box;min-height:1.2em;"> </span></span>`;
document.execCommand('insertHTML', false, html);
editorRef.current?.focus();
};

View File

@@ -1,4 +1,4 @@
const smartField = (key: string) => `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;"><span class="field-value" data-bind="${key}" contenteditable="true" style="min-width:32px;padding:0 4px;margin:0 2px;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:1.2;font-size:inherit;vertical-align:text-bottom;box-sizing:border-box;min-height:1.2em;"> </span></span>`;
const smartField = (key: string) => `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;"><span class="delete-btn" contenteditable="false">×</span><span class="field-value" data-bind="${key}" contenteditable="true" style="min-width:32px;padding:0 4px;margin:0 2px;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:1.2;font-size:inherit;vertical-align:text-bottom;box-sizing:border-box;min-height:1.2em;"> </span></span>`;
export const defaultReportContent = `
<!-- 医院Logo -->

View File

@@ -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
<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="field-value" data-bind="..." ...> </span>
</span>
```
#### 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``<p>``range.startOffset` 指向字段节点位置)时按 Backspace。
- 光标直接在字段前面时按 Delete。
- 选区 collapsed 且紧邻字段的各种边界情况。
实现策略:在 `keydown` 中统一写一个 `findAdjacentSmartField(range, direction)` 辅助函数,先尝试从文本节点 sibling 找,若找不到则从父级块节点的 childNodes 中按 offset 找。
### 2.2 `src/utils/defaultContent.ts`
修改 `smartField(key)` 辅助函数,使其输出包含 `<span class="delete-btn" contenteditable="false">×</span>` 的单行 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<string[]>([]);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportTarget, setExportTarget] = useState<Report | null>(null);
```
#### B. 复选框与全选逻辑
- 表头 `<th>` 增加 Checkbox状态为 `selectedIds.length === filteredReports.length && filteredReports.length > 0`
- 每行 `<td>` 最左侧增加 Checkboxchecked 状态为 `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` + `<a download>` 触发下载。
#### D. 批量导出
- **批量 PDF**`const mergedHTML = selectedReports.map(r => r.content).join('<div style="page-break-after: always;"></div>'); 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` 的修改。

View File

@@ -0,0 +1,47 @@
# 测试方案 — 模板字段唯一性、删除交互与报告批量导出2026-04-17-10-21-18
## 一、编译检查
- 执行 `npm run lint``tsc --noEmit`),确保全量 TypeScript 无编译错误。
## 二、功能验证步骤
### 测试 1TemplateManage 字段唯一性
1. 进入【模板管理】,选择一个模板。
2. 在右侧字段库点击"姓名"`patientName`),确认成功插入智能字段方框。
3. 再次点击"姓名",确认弹出提示 `"姓名" 已存在,请勿重复插入。`,且没有再次插入方框。
### 测试 2TemplateManage 字段删除(点击删除按钮)
1. 在模板编辑器中,鼠标悬停在任意智能字段方框上,确认左上角出现红色小圆 ×。
2. 点击该 ×,确认字段方框被移除,模板内容自动保存。
3. 刷新页面,确认该字段确实已被删除。
### 测试 3TemplateManage 字段删除(键盘 Backspace/Delete
1. 将光标定位在智能字段方框**正后方**(字段与后续文字之间),按 Backspace确认字段被删除。
2. 将光标定位在智能字段方框**正前方**(段落开头,字段前面),按 Delete确认字段被删除。
3. 尝试在段落中间的其他位置按 Backspace/Delete确认不影响正常文本编辑。
### 测试 4ReportManage 单报告导出
1. 进入【报告管理】,确保列表中至少有一份已完成的报告。
2. 点击某报告操作列的"导出"按钮,弹出导出选项弹窗。
3. 选择 **PDF**:确认调用 `printDocument` 弹出浏览器打印窗口(可选择"另存为 PDF")。
4. 再次点击"导出",选择 **JSON**:确认浏览器下载了一个 `.json` 文件。
5. 打开该 JSON 文件,确认结构包含 `meta`id、title、createdAt 等)和 `fields`patientName、hospitalId 等字段值)。
### 测试 5ReportManage 复选框与批量删除
1. 在报告列表中,点击多行的左侧复选框,确认 `selectedIds` 状态更新,顶部出现批量操作栏并显示"已选择 N 项"。
2. 点击表头全选 Checkbox确认所有行被选中再次点击确认全部取消。
3. 选中 2 份报告,点击批量操作栏的"批量删除",在确认弹窗中点击"取消",确认报告未被删除。
4. 再次点击"批量删除"并确认,确认选中的报告从列表和 localStorage 中移除,批量操作栏消失。
### 测试 6ReportManage 批量导出
1. 选中 2 份报告,点击"批量导出 JSON",确认下载的 JSON 文件中包含一个数组,数组长度为 2每个元素结构同单报告导出。
2. 选中 2 份报告,点击"批量导出 PDF",确认弹出浏览器打印窗口,打印内容中两份报告之间有明显的分页(或分页符空白)。
## 三、预期结果
- `npm run lint` 0 错误。
- 模板字段唯一性校验生效,重复插入被阻止。
- 模板字段可通过点击 × 或键盘 Backspace/Delete 删除。
- 报告管理支持单报告 PDF/JSON 导出。
- 报告管理支持复选框全选、批量删除、批量 PDF/JSON 导出。

View File

@@ -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` 为 `<p>` 等块元素)的场景。
3. `ReportManage` 的设计只支持单条查看/编辑/删除,没有设计多选状态和导出逻辑。
**C. 解决问题方案**
1. **唯一性校验**:在 `insertSmartField` 中通过 `editorRef.current?.querySelector([data-bind="..."])` 预检查,若已存在则 `alert` 并终止插入。
2. **删除按钮**:给 `.smart-field-wrapper` 内部增加一个红色圆形的 `<span class="delete-btn">×</span>`,点击即可删除整个字段节点。同时在 `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 用 `<div style="page-break-after: always;"></div>` 拼接后统一打印。
- 批量 JSON 将多份报告导出为数组形式的单个 `.json` 文件。
**D. 后续如何避免问题**
- 在 `contentEditable` 中插入的任何可复用控件,都应考虑增加唯一性校验和明确的删除入口(可视化按钮 + 键盘事件拦截)。
- 键盘事件处理不能假设 `startContainer` 一定是文本节点,必须覆盖块级元素边界的情况。
- 当列表页需要增加批量操作时,建议将"选择状态"和"批量动作"封装为独立逻辑,保持单条操作按钮的可维护性。
- 导出功能应尽量复用现有的 `printDocument` 等工具函数,减少新依赖引入。
---
## 记录 14智能字段插入间距修复与 Backspace 防误删
**A. 具体问题**

View File

@@ -0,0 +1,56 @@
# 需求分析 — 模板字段唯一性、删除交互与报告批量导出2026-04-17-10-21-18
## 一、需求来源
用户反馈 TemplateManage 中智能字段存在无法删除和重复插入的问题,同时要求升级 ReportManage 的报告管理能力,支持单份/多份报告的结构化导出。
## 二、具体需求拆解
### 需求 1模板字段唯一性校验
**问题**`TemplateManage` 中点击字段库按钮插入智能字段时,同一个字段(如"姓名")可以被重复插入多次,导致模板混乱。
**期望**:每个 `data-bind` 对应的智能字段在编辑器中只能存在一份,若已存在则提示用户并阻止插入。
### 需求 2模板字段删除交互优化
**问题**:默认模板中的智能字段以及手动插入的智能字段,按下 Backspace/Delete 时经常无法删除(尤其是字段位于段落开头/结尾时,浏览器默认行为会误删父级 `<p>`)。
**期望**
- 键盘 Backspace/Delete 能正确删除相邻的智能字段。
- 为智能字段增加可视化删除按钮(类似图片占位符的 ×),用户可直接点击删除。
### 需求 3ReportManage 单报告导出PDF / JSON
**期望**:在报告管理列表的每行操作列增加"导出"按钮,点击后弹出选项:
- **导出 PDF**:调用现有的 `printDocument` 打印报告 HTML 内容,由用户在浏览器打印弹窗中选择"另存为 PDF"。
- **导出 JSON**:下载结构化 JSON 文件,文件内包含该报告所有 `data-bind` 对应字段的值( patientName、hospitalId、title、surgeryDate 等 以及报告元信息id、createdAt 等)。
### 需求 4ReportManage 批量操作(复选框 + 批量删除 / 批量导出)
**期望**
- 表格每行最左侧增加复选框,表头增加全选/反选复选框。
- 当有报告被选中时,表格上方显示批量操作栏,包含:
- **批量删除**:确认后从 `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` 字段值及报告元信息。