2026-04-17-00-13-09 - 手术时间方框联动、动态字段分类管理体系、字段显隐控制、UI紧凑化优化

This commit is contained in:
2026-04-17 00:30:11 +08:00
parent 952856e8c6
commit 2a4934e7c4
11 changed files with 1116 additions and 293 deletions

View File

@@ -0,0 +1,446 @@
# 实现方案 — 2026-04-17-00-13-09
## 根因分析
当前系统存在三个核心问题:
1. **时间字段未联动**`defaultContent.ts` 中手术开始/终止时间是纯文本占位符,无 `data-bind`,导致右侧表单与正文内容无法同步。
2. **表单硬编码不可扩展**`ReportEditor.tsx` 右侧的基本信息表单是写死的 JSX每新增一个字段都需要改代码`TemplateManage.tsx` 的字段库也是静态数组,无法按医院实际需求自定义。
3. **方格 UI 破坏排版**`field-value` 使用了较大的 `min-width` 和上下 `padding`,在 `inline-block` 布局下撑大了行高,导致段落行间距明显变大。
## 修改文件清单
| 文件 | 修改类型 | 说明 |
|------|---------|------|
| `src/types.ts` | 修改 | 新增 `FieldType``FormField``FormFieldsConfig` 类型 |
| `src/utils/defaultContent.ts` | 修改 | 手术时间替换为 `startTime`/`endTime` 智能方格 |
| `src/index.css` | 修改 | 优化 `.field-value` 紧凑样式 |
| `src/utils/print.ts` | 修改 | 同步打印样式 |
| `src/pages/TemplateManage.tsx` | 修改 | 字段库重构为 Tab 结构,支持分类、新增、显隐控制 |
| `src/pages/ReportEditor.tsx` | 修改 | 右侧表单动态渲染 + 时间解析拼接双向转换 |
| `src/pages/Login.tsx` | 修改 | 首次登录时初始化默认字段配置到 localStorage |
---
## 具体代码变更
### 变更 1`src/types.ts` — 动态字段类型定义
**在 `BINDABLE_FIELDS` 之后追加:**
```typescript
export type FieldType = 'text' | 'single_select' | 'multi_select' | 'time' | 'date';
export interface FormField {
key: string;
label: string;
category: string; // 如 '填空'、'单选'、'多选'、'时间'
type: FieldType;
visibleInForm: boolean;
isSystemLocked: boolean;
options?: string[];
}
export const DEFAULT_FORM_FIELDS: FormField[] = [
{ key: 'patientName', label: '患者姓名', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true },
{ key: 'hospitalId', label: '住院号', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true },
{ key: 'title', label: '手术名称', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
{ key: 'patientGender', label: '患者性别', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['男', '女'] },
{ key: 'patientAge', label: '患者年龄', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
{ key: 'department', label: '科别', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
{ key: 'bedNumber', label: '床号', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
{ key: 'surgeryDate', label: '手术日期', category: '时间', type: 'date', visibleInForm: true, isSystemLocked: false },
{ key: 'startTime', label: '手术开始时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: false },
{ key: 'endTime', label: '手术终止时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: false },
{ key: 'surgeon', label: '手术者', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: false, options: ['张医生', '李医生', '王医生'] },
{ key: 'assistant', label: '助手', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: false, options: ['赵医生', '钱医生', '孙医生'] },
{ key: 'anesthesiologist', label: '麻醉师', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: false, options: ['周医生', '吴医生', '郑医生'] },
{ key: 'anesthesiaType', label: '麻醉方式', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉'] },
];
```
### 变更 2`src/utils/defaultContent.ts` — 手术时间方框化
**替换手术时间相关段落:**
```typescript
<p style="font-family: SimSun;">
${smartField('startTime')}
${smartField('endTime')}
</p>
```
> 注意:同时需要把 `smartField` 函数的样式字符串更新为紧凑版本(见变更 4
### 变更 3`src/utils/defaultContent.ts` — 更新 `smartField` 紧凑样式
**替换现有的 `smartField` 函数:**
```typescript
const smartField = (key: string) => `
<span class="smart-field-wrapper" contenteditable="false">
<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>
`;
```
### 变更 4`src/index.css` — 同步优化 `.field-value` 样式
**在 `.smart-field-wrapper` 相关样式区块中更新 `.field-value`**
```css
.smart-field-wrapper .field-value {
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;
outline: none;
}
```
**打印样式同步更新:**
```css
@media print {
.smart-field-wrapper .field-value {
border: none !important;
border-bottom: 1px solid #000 !important;
border-radius: 0 !important;
background: transparent !important;
padding: 0 2px !important;
}
}
```
### 变更 5`src/utils/print.ts` — 同步打印样式
在 iframe 内联 `<style>` 中,将 `.smart-field-wrapper .field-value` 的默认样式更新为紧凑版本,并保留 `@media print` 下划线样式。
### 变更 6`src/pages/TemplateManage.tsx` — 字段库重构
**新增状态:**
```tsx
const [fieldLibTab, setFieldLibTab] = useState<'insert' | 'manage'>('insert');
const [formFields, setFormFields] = useState<FormField[]>([]);
const [newFieldForm, setNewFieldForm] = useState({ label: '', category: '填空', type: 'text' as FieldType });
const [newFieldOptions, setNewFieldOptions] = useState('');
```
**初始化(在 `useEffect` 中读取/初始化配置):**
```tsx
const savedFields = storage.get<FormField[]>('formFieldsConfig', []);
if (savedFields.length > 0) {
setFormFields(savedFields);
} else {
setFormFields(DEFAULT_FORM_FIELDS);
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
}
```
**插入字段 Tab UI**
```tsx
<div className="space-y-4">
{['填空', '单选', '多选', '时间'].map(cat => {
const catFields = formFields.filter(f => f.category === cat);
if (catFields.length === 0) return null;
return (
<div key={cat}>
<div className="text-[10px] text-slate-400 mb-1">{cat}</div>
<div className="flex flex-wrap gap-1">
{catFields.map(field => (
<button
key={field.key}
onClick={() => insertSmartField(field)}
className="..."
>
{field.label}
</button>
))}
</div>
</div>
);
})}
</div>
```
> `insertSmartField` 函数的参数改为 `FormField`,使用 `field.key` 和 `field.label` 生成 HTML。
**字段管理 Tab UI**
```tsx
<div className="space-y-3">
{formFields.filter(f => !f.isSystemLocked).map(field => (
<div key={field.key} className="flex items-center justify-between p-2 bg-slate-50 rounded border border-slate-200">
<div className="text-xs">
<div className="font-medium text-text-main">{field.label}</div>
<div className="text-[10px] text-slate-400">{field.category} · {field.type}</div>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-[10px] text-slate-600">
<input
type="checkbox"
checked={field.visibleInForm}
onChange={() => toggleFieldVisible(field.key)}
/>
</label>
<button onClick={() => deleteField(field.key)} className="text-red-500 text-[10px]"></button>
</div>
</div>
))}
<div className="pt-2 border-t border-slate-200">
<div className="text-xs font-semibold mb-2"></div>
<input ... />
<select ... />
<button onClick={addField}></button>
</div>
</div>
```
**关键操作函数:**
```tsx
const toggleFieldVisible = (key: string) => {
const updated = formFields.map(f => f.key === key ? { ...f, visibleInForm: !f.visibleInForm } : f);
setFormFields(updated);
storage.set('formFieldsConfig', updated);
};
const deleteField = (key: string) => {
const updated = formFields.filter(f => f.key !== key);
setFormFields(updated);
storage.set('formFieldsConfig', updated);
};
const addField = () => {
if (!newFieldForm.label.trim()) return;
const key = 'custom_' + Date.now();
const newField: FormField = {
key,
label: newFieldForm.label.trim(),
category: newFieldForm.category,
type: newFieldForm.type,
visibleInForm: true,
isSystemLocked: false,
options: ['单选', '多选'].includes(newFieldForm.category) && newFieldOptions.trim()
? newFieldOptions.split(/[,]/).map(s => s.trim()).filter(Boolean)
: undefined
};
const updated = [...formFields, newField];
setFormFields(updated);
storage.set('formFieldsConfig', updated);
setNewFieldForm({ label: '', category: '填空', type: 'text' });
setNewFieldOptions('');
};
```
### 变更 7`src/pages/ReportEditor.tsx` — 动态渲染右侧表单 + 时间联动
**初始化字段配置(在 `useEffect` 中):**
```tsx
const [formFields, setFormFields] = useState<FormField[]>([]);
// ...
const savedFields = storage.get<FormField[]>('formFieldsConfig', []);
if (savedFields.length > 0) {
setFormFields(savedFields);
} else {
setFormFields(DEFAULT_FORM_FIELDS);
}
```
**时间解析/拼接辅助函数:**
```tsx
const formatTimeValue = (hour?: string, minute?: string) => {
if (!hour && !minute) return '';
return `${hour || ''}:${minute || ''}`;
};
const parseTimeValue = (value: string) => {
const parts = value.split(':');
return { hour: parts[0] || '', minute: parts[1] || '' };
};
```
**表单 → 方格的时间同步(在 `reportData` 的 `useEffect` 中):**
```tsx
// 对时间字段做特殊拼接
let newValue = '';
if (fieldKey === 'startTime') {
newValue = formatTimeValue(reportData.startHour, reportData.startMinute);
} else if (fieldKey === 'endTime') {
newValue = formatTimeValue(reportData.endHour, reportData.endMinute);
} else {
const rawValue = (reportData as any)[fieldKey];
if (Array.isArray(rawValue)) newValue = rawValue.join(', ');
else if (rawValue !== undefined && rawValue !== null) newValue = String(rawValue);
}
```
**方格 → 表单的时间同步(在 `handleEditorInput` 中):**
```tsx
if (target && target.hasAttribute('data-bind')) {
const fieldKey = target.getAttribute('data-bind')!;
const newValue = target.innerText;
if (fieldKey === 'startTime') {
const { hour, minute } = parseTimeValue(newValue);
setReportData(prev => {
const next = { ...prev, startHour: hour, startMinute: minute };
stateRef.current = { ...stateRef.current, reportData: next };
return next;
});
} else if (fieldKey === 'endTime') {
const { hour, minute } = parseTimeValue(newValue);
setReportData(prev => {
const next = { ...prev, endHour: hour, endMinute: minute };
stateRef.current = { ...stateRef.current, reportData: next };
return next;
});
} else {
setReportData(prev => {
const next = { ...prev, [fieldKey]: newValue };
stateRef.current = { ...stateRef.current, reportData: next };
return next;
});
}
}
```
**动态渲染右侧表单(替换现有的硬编码表单区域):**
将现有的 `activeTab === 'info'` 下的 `<div className="report-info-form space-y-4">...` 整体替换为:
```tsx
{activeTab === 'info' && (
<div className="report-info-form space-y-4">
{formFields.filter(f => f.visibleInForm).map(field => {
if (field.type === 'text' || field.type === 'date') {
const inputType = field.type === 'date' ? 'date' : 'text';
return (
<div key={field.key} className="space-y-1">
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<input
type={inputType}
value={(reportData as any)[field.key] || ''}
onChange={(e) => { const next = { ...reportData, [field.key]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal"
placeholder={field.label}
/>
</div>
);
}
if (field.type === 'single_select') {
return (
<div key={field.key} className="space-y-1">
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<select
value={(reportData as any)[field.key] || ''}
onChange={(e) => { const next = { ...reportData, [field.key]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal bg-white"
>
<option value=""></option>
{(field.options || []).map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</div>
);
}
if (field.type === 'multi_select') {
const isOpen = openDropdown === field.key;
return (
<div key={field.key} className="space-y-1 select-dropdown-root relative">
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div className="..." onClick={() => setOpenDropdown(field.key)}>
{/* 复用现有的多选标签渲染逻辑,字段名用 field.key */}
</div>
{/* 下拉选项弹窗 ... */}
</div>
);
}
if (field.type === 'time') {
const hourKey = field.key === 'startTime' ? 'startHour' : 'endHour';
const minuteKey = field.key === 'startTime' ? 'startMinute' : 'endMinute';
return (
<div key={field.key} className="space-y-1">
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div className="flex items-center gap-2">
<select
value={(reportData as any)[hourKey] || ''}
onChange={(e) => { const next = { ...reportData, [hourKey]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal bg-white flex-1"
>
<option value="">--</option>
{hourOptions.map(h => <option key={h} value={h}>{h}</option>)}
</select>
<span className="text-text-muted">:</span>
<select
value={(reportData as any)[minuteKey] || ''}
onChange={(e) => { const next = { ...reportData, [minuteKey]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal bg-white flex-1"
>
<option value="">--</option>
{minuteOptions.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
</div>
);
}
return null;
})}
</div>
)}
```
> 对于 `multi_select`,可以完全复用现有的 `surgeon`/`assistant`/`anesthesiologist` 的多选组件逻辑,只需将硬编码的字段名替换为 `field.key`,并将 `multiSelectOptions` 的读取逻辑泛化为从 `field.options` 读取。
### 变更 8`src/pages/Login.tsx` — 首次登录初始化字段配置
在 Login 页面初始化默认数据时(与其他 `storage.set` 一起),增加:
```tsx
if (!storage.get<FormField[]>('formFieldsConfig', null)) {
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
}
```
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| 老用户的 `localStorage` 中没有 `formFieldsConfig`,首次进入可能显示空白表单 | 中 | `ReportEditor``TemplateManage` 中都做 fallback若不存在则使用 `DEFAULT_FORM_FIELDS` 并自动写入 localStorage |
| `ReportEditor` 动态渲染多选字段时,现有 `multiSelectOptions` 状态与新字段体系冲突 | 中 | 多选字段的选项统一从 `field.options` 读取,不再依赖独立的 `multiSelectOptions` 状态(或做兼容映射) |
| 时间方格输入非标准格式(如"930"而非"09:30")导致解析失败 | 低 | `parseTimeValue` 使用简单 `split(':')`,若格式不对则 `hour`/`minute` 保持原样或空字符串,不影响系统稳定性 |
| 删除自定义字段后,老报告中仍包含该 `data-bind` 节点 | 低 | 老报告中的 orphan 节点只是普通可编辑方格,右侧表单不显示对应输入项,属于预期行为 |
## 回滚策略
本次改动涉及数据结构和多处 UI 渲染。如出现异常,可:
1. `git revert` 回滚代码;
2. 手动在浏览器控制台执行 `localStorage.removeItem('formFieldsConfig')` 恢复默认字段配置。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。**

View File

@@ -0,0 +1,121 @@
# 测试方案 — 2026-04-17-00-13-09
## 测试目标
验证手术时间方框联动、动态字段分类管理体系、字段显隐控制、自定义字段新增删除、以及 UI 紧凑化优化的正确性与稳定性。
## 测试环境
- 浏览器Chrome / Edge
- 前置条件:已登录系统(建议使用 `admin` 超级管理员账号)
- 测试页面:`/template-manage``/report-editor`
---
## 测试用例设计
### 用例 1手术时间方框联动
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1.1 | 进入 `/report-editor`(新建报告) | 默认模板中"手术开始时间:"和"手术终止时间:"后各有一个可填方格 |
| 1.2 | 检查方格的 `data-bind` 属性 | 分别为 `startTime``endTime` |
| 1.3 | 在右侧【基本信息】选择手术开始时间 "09" 时 "30" 分 | 编辑器内"手术开始时间"方格自动显示 "09:30" |
| 1.4 | 在编辑器"手术终止时间"方格内输入 "14:45" | 右侧表单"手术终止时间"下拉框自动变为 "14" 时 "45" 分 |
| 1.5 | 删除方格内内容 | 右侧时/分下拉框恢复为空("--" |
### 用例 2字段库分类展示与插入
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 2.1 | 进入 `/template-manage` | 右侧字段库面板显示两个 Tab"插入字段"和"字段管理",默认在"插入字段" |
| 2.2 | 观察"插入字段"Tab | 字段按"填空"、"单选"、"多选"、"时间"四组分类展示 |
| 2.3 | 点击"时间"分类下的"手术开始时间" | 编辑器光标处插入带有 `data-bind="startTime"` 的方格 |
| 2.4 | 点击"多选"分类下的"手术者" | 编辑器中插入 `data-bind="surgeon"` 的方格 |
### 用例 3字段显隐控制
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 3.1 | 在 `/template-manage` 切换到"字段管理"Tab | 显示所有非系统锁定字段列表 |
| 3.2 | 找到"患者性别"字段,取消其"显示"勾选 | 该字段右侧出现未勾选状态 |
| 3.3 | 进入 `/report-editor` | 右侧【基本信息】中**不再出现**"患者性别"输入项 |
| 3.4 | 返回 `/template-manage`,重新勾选"患者性别"的"显示" | — |
| 3.5 | 再次进入 `/report-editor` | "患者性别"重新出现在右侧表单中 |
### 用例 4系统锁定字段保护
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 4.1 | 在 `/template-manage` 的"字段管理"中查看"患者姓名"和"住院号" | 这两个字段**不在列表中**(或显示为灰色不可操作状态) |
| 4.2 | 尝试在 ReportEditor 中隐藏"患者姓名" | 无法操作,因为 TemplateManage 中没有提供隐藏开关 |
### 用例 5自定义字段新增与使用
| 步骤 | 操作 | 预期结果 |
|------|------|--------- |
| 5.1 | 在 `/template-manage` "字段管理"Tab 中,输入新字段名"术中失血量",选择分类"填空",类型"text",点击添加 | 列表中出现"术中失血量"字段,且"显示"已默认勾选 |
| 5.2 | 切换到"插入字段"Tab | "填空"分类下出现"术中失血量"按钮 |
| 5.3 | 点击"术中失血量"按钮插入编辑器 | 编辑器中出现 `data-bind="custom_xxxx"` 的方格 |
| 5.4 | 进入 `/report-editor` | 右侧【基本信息】中出现"术中失血量"文本输入框 |
| 5.5 | 在编辑器方格中输入"200ml" | 右侧表单同步显示"200ml" |
### 用例 6自定义字段删除
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 6.1 | 在 `/template-manage` "字段管理"中,点击"术中失血量"的"删除"按钮 | 该字段从列表中消失 |
| 6.2 | 切换到"插入字段"Tab | "术中失血量"按钮已不在"填空"分类中 |
| 6.3 | 进入 `/report-editor` | 右侧表单中不再显示"术中失血量" |
### 用例 7自定义单选/多选字段
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 7.1 | 在字段管理中新增字段"手术体位",分类"单选",类型"single_select",选项填入"仰卧位, 侧卧位, 俯卧位" | 新增成功 |
| 7.2 | 在 `/report-editor` 中查看右侧表单 | "手术体位"显示为下拉选择框,包含三个选项 |
| 7.3 | 选择"侧卧位" | 编辑器内对应方格显示"侧卧位" |
### 用例 8UI 紧凑化验证
| 步骤 | 操作 | 预期结果 |
|------|------|--------- |
| 8.1 | 在 `/report-editor` 中观察姓名方格 | 方格宽度明显变小(约 32px 起),不再撑大行间距 |
| 8.2 | 对比方格与周围普通文字的行高 | 行高基本一致,段落排版紧凑自然 |
| 8.3 | 进入打印预览 | 方格边框消失,变为细下划线,不破坏打印版面 |
### 用例 9老数据兼容
| 步骤 | 操作 | 预期结果 |
|------|------|--------- |
| 9.1 | 编辑一份之前保存的老报告(不含新自定义字段) | 页面正常加载,老字段正常显示和编辑 |
| 9.2 | 检查 `localStorage``formFieldsConfig` | 首次访问后自动生成默认配置 |
### 用例 10类型检查
| 步骤 | 操作 | 预期结果 |
|------|------|--------- |
| 10.1 | 执行 `npm run lint` | 无 TypeScript 编译错误 |
---
## 验收标准
- [ ] 手术开始/终止时间在模板中以方格呈现,且与右侧时/分下拉框双向联动。
- [ ] `TemplateManage` 字段库支持按"填空/单选/多选/时间"分类展示。
- [ ] `TemplateManage` 支持新增自定义字段,新增后可在 ReportEditor 表单和字段库中正常使用。
- [ ] `TemplateManage` 支持删除自定义字段,删除后 ReportEditor 表单和字段库同步移除。
- [ ] 字段显隐开关可控制 ReportEditor 右侧表单是否显示该字段。
- [ ] "患者姓名"和"住院号"为系统锁定字段,不可删除、不可隐藏。
- [ ] `field-value` 方格宽度缩小、行间距恢复正常、排版紧凑。
- [ ] 打印时方格变为下划线风格。
- [ ] 老报告和新用户首次登录均能正常加载,无报错。
- [ ] `npm run lint` 通过。
## 测试方式
手工浏览器验证为主,结合 DevTools 观察 DOM 结构和 `localStorage` 配置。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**

View File

@@ -309,3 +309,36 @@ if ((settings.autoInsertDelay || 0) > 0) {
- 对于需要在富文本中保护的固定文本,优先采用 `contenteditable="false"` 的包装器,而不是仅靠样式区分的普通 `<span>`。
- 在 `State -> DOM` 的同步中务必加入差异判断,避免不必要的 DOM 重写导致输入焦点异常。
- 数组类型字段(如 `surgeon`)在同步到方格前应先 `join(', ')` 转换为字符串,保持显示一致性。
---
## 记录 13手术时间方框化、动态字段分类体系与 UI 紧凑化
**A. 具体问题**
1. 手术开始/终止时间在模板中是纯文本"时 分",无法与右侧表单联动。
2. `TemplateManage` 的字段库是静态列表,无法按医院需求自定义字段;`ReportEditor` 的右侧表单全部硬编码,每新增一个字段就要改代码。
3. `field-value` 方格使用了 `min-width: 60px` 和上下 `padding`,导致行间距被撑大,排版松散。
**B. 产生问题原因**
1. 时间字段在 `defaultContent.ts` 中没有使用 `data-bind` 智能控件,且右侧表单将时间拆分为 `startHour`/`startMinute` 两个独立字段,缺少与方格的双向转换层。
2. 早期设计采用了"硬编码表单"思路,字段名、类型、选项全部写死在 `ReportEditor.tsx` 的 JSX 中,不具备扩展性。
3. `inline-block` 元素自带上下 `padding` 和 `border`,超出了默认行高,浏览器不得不增大整行高度以容纳它。
**C. 解决问题方案**
1. **时间方框联动**
- 在 `defaultContent.ts` 中替换为 `data-bind="startTime"` 和 `data-bind="endTime"` 的方格。
- 在 `ReportEditor.tsx` 的 `handleEditorInput` 中,对 `startTime`/`endTime` 使用 `split(':')` 解析,反向更新 `startHour`/`startMinute`;在 `useEffect(reportData)` 中拼接 `HH:mm` 同步回方格。
2. **动态字段体系**
- 在 `types.ts` 中新增 `FieldType`、`FormField`、`DEFAULT_FORM_FIELDS`,定义字段的 key/label/分类/类型/显隐/锁定状态/选项。
- 使用 `localStorage` 的 `formFieldsConfig` 持久化字段配置。
- `TemplateManage.tsx` 右侧字段库重构为 Tab 结构:【插入字段】按"填空/单选/多选/时间"分组;【字段管理】支持新增、删除(非锁定字段)、显隐开关。
- `ReportEditor.tsx` 右侧基本信息表单改为遍历 `formFieldsConfig`、按 `type` switch-case 动态渲染(文本框/下拉框/多选标签/时间拆分下拉框)。
3. **UI 紧凑化**
- 将 `min-width` 从 `60px` 缩至 `32px`。
- 去除上下 `padding`,使用 `line-height: 1.2`、`font-size: inherit`、`vertical-align: text-bottom`。
- 背景色改为 `#f8fafc`(编辑态更明显),打印时恢复透明并只保留下划线。
**D. 后续如何避免问题**
- 对于需要将多个子字段映射到单一 UI 控件的场景,应在事件处理器和 `useEffect` 中各维护一层"拼接/解析"转换逻辑,保持底层数据结构不变。
- 当表单字段超过 5 个且存在频繁变更需求时,应尽早从硬编码 JSX 转向"配置驱动渲染"Config-Driven UI降低后续维护成本。
- 在 `contentEditable` 中插入 `inline-block` 元素时,务必通过 `line-height`、`vertical-align` 和最小化 `padding` 控制其对行高的影响,避免破坏段落排版的紧凑性。

View File

@@ -0,0 +1,58 @@
# 需求分析 — 2026-04-17-00-13-09
## 原始需求摘要
1. **手术时间方框化**:将报告模板中的"手术开始时间"、"手术终止时间"从静态文本改为可联动的智能方格。
2. **动态字段体系**:在 `TemplateManage` 中将字段库升级为支持分类(填空、多选、单选、时间)、允许自定义新增字段,并可控制字段是否在 `ReportEditor` 右侧【基本信息】中显示。除"姓名"、"住院号"为系统锁定字段外,其余字段均可调整显隐和删除。
3. **UI 紧凑化**:缩小 `field-value` 方格的默认宽度和高度,降低其对行间距的影响,使排版更紧凑自然。
## 需求拆解
### 功能点
#### 需求 1手术开始/终止时间方框联动
-`defaultContent.ts` 中将"手术开始时间:时 分"替换为 `data-bind="startTime"` 的智能方格,同理替换"手术终止时间"。
-`ReportEditor.tsx` 右侧基本信息中,保留现有的 `startHour`/`startMinute``endHour`/`endMinute` 下拉框(或合并为时间输入)。
- 建立双向转换:
- 表单 → 方格:下拉框变化时拼接为 `HH:mm` 同步到方格。
- 方格 → 表单:用户在方格内输入时间文本(如"09:30")时,解析并反向更新 `startHour`/`startMinute`
#### 需求 2动态字段配置体系
- **数据结构**:在 `types.ts` 中新增 `FieldType``FormField` 接口,描述字段的 key、label、分类、类型、显隐状态、是否系统锁定、选项列表等。
- **全局配置存储**:在 `localStorage` 中新增 `formFieldsConfig` key保存字段配置数组。
- **TemplateManage 字段库改造**
- 右侧面板增加【插入字段】和【字段管理】两个 Tab。
- 【插入字段】按分类(填空、单选、多选、时间)分组展示字段按钮,点击插入对应 `data-bind` 方格。
- 【字段管理】展示所有非系统锁定字段,支持新增字段(输入名称、选择类型、选择分类)、编辑选项、删除字段、控制 `visibleInForm` 开关。
- **ReportEditor 动态渲染**:右侧【基本信息】表单不再硬编码,而是读取 `formFieldsConfig`,过滤出 `visibleInForm === true` 的字段,根据 `type` 动态渲染对应输入组件(文本框/下拉框/多选标签/时间拆分下拉框)。
- **初始化默认配置**:首次加载时若 `formFieldsConfig` 不存在,自动生成一套与当前硬编码表单一致的默认配置。
#### 需求 3UI 紧凑化优化
- 调整 `field-value` 的内联样式:
- `min-width``60px` 降至 `32px`
- 去除上下 `padding`(或仅保留极小的上下间距)
- 增加 `line-height: 1.2``font-size: inherit``vertical-align: text-bottom`
- 背景色微调为 `#f8fafc`(编辑态更明显)
- 同步修改 `index.css``print.ts` 中的对应样式。
### 非功能点
- 系统锁定字段(`patientName``hospitalId`)不可删除、不可隐藏。
- 老用户已有的 `localStorage` 数据不因字段配置变化而丢失;新增配置与现有 `Report`/`Template` 数据解耦。
- 保持 `npm run lint` 类型检查通过。
- 不引入新的第三方依赖。
## 影响范围预估
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/types.ts` | 高 | 新增 `FieldType``FormField``FormFieldsConfig` 类型定义 |
| `src/utils/defaultContent.ts` | 中 | 手术时间占位符替换为智能方格 |
| `src/index.css` | 中 | 优化 `.field-value` 紧凑样式 |
| `src/utils/print.ts` | 低 | 同步打印样式 |
| `src/pages/TemplateManage.tsx` | 高 | 字段库 UI 重构为 Tab + 分类分组 + 字段管理 |
| `src/pages/ReportEditor.tsx` | 高 | 右侧基本信息表单从硬编码改为动态渲染,新增时间解析/拼接逻辑 |
## 待确认问题
无。用户已提供详细需求和初步技术思路,可直接进入实现方案设计。