fix: undo stack, field insertion wrap, backspace/delete precision; feat: signature size limit & isSigned control
- Replace direct DOM remove() with Range+execCommand('delete') in TemplateManage click and keydown handlers to restore undo stack
- Append ​ zero-width space to smart-field-wrapper HTML in insertSmartField and defaultContent.ts to prevent unwanted line breaks
- Refactor ReportEditor surgeonSignature rendering to depend on isSigned field
- Add isSigned to DEFAULT_FORM_FIELDS (single_select: 已签字/未签字)
- Change surgeonSignature to visibleInForm=true, isSystemLocked=false
- Constrain signature image with max-width:120px, max-height:40px, object-fit:contain in CSS and print.ts
- Add weak-blocking signature validation prompts in saveReport('completed')
- Update experience record (#19)
This commit is contained in:
@@ -157,11 +157,13 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.report-signature-img {
|
.report-signature-img {
|
||||||
height: 2.4em;
|
max-width: 120px;
|
||||||
|
max-height: 40px;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: -0.3em 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,10 +205,12 @@
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
.report-signature-img {
|
.report-signature-img {
|
||||||
height: 2.4em !important;
|
max-width: 120px !important;
|
||||||
|
max-height: 40px !important;
|
||||||
width: auto !important;
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
object-fit: contain !important;
|
||||||
vertical-align: middle !important;
|
vertical-align: middle !important;
|
||||||
display: inline-block !important;
|
display: inline-block !important;
|
||||||
margin: -0.3em 0 !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export default function ReportEditor() {
|
|||||||
assistant: [],
|
assistant: [],
|
||||||
anesthesiologist: [],
|
anesthesiologist: [],
|
||||||
anesthesiaType: '',
|
anesthesiaType: '',
|
||||||
|
isSigned: '未签字',
|
||||||
reportNote: '',
|
reportNote: '',
|
||||||
status: 'draft'
|
status: 'draft'
|
||||||
});
|
});
|
||||||
@@ -855,6 +856,21 @@ export default function ReportEditor() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status === 'completed') {
|
||||||
|
const hasSignatureField = editorRef.current?.querySelector('[data-bind="surgeonSignature"]');
|
||||||
|
if (hasSignatureField) {
|
||||||
|
const isSigned = (reportData as any).isSigned === '已签字';
|
||||||
|
const hasSignatureImage = !!currentUser?.signature;
|
||||||
|
if (!isSigned) {
|
||||||
|
const proceed = window.confirm('提示:模板中包含【手术者签名】字段,但您在基本信息中未选择"已签字"。是否继续完成报告?');
|
||||||
|
if (!proceed) return;
|
||||||
|
} else if (!hasSignatureImage) {
|
||||||
|
const proceed = window.confirm('提示:您选择了"已签字",但您的账号尚未上传电子签名图片。报告中将不显示签名图片,是否继续完成?');
|
||||||
|
if (!proceed) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const content = editorRef.current?.innerHTML || '';
|
const content = editorRef.current?.innerHTML || '';
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const finalReport: Report = {
|
const finalReport: Report = {
|
||||||
@@ -941,8 +957,9 @@ export default function ReportEditor() {
|
|||||||
const fieldKey = el.getAttribute('data-bind')!;
|
const fieldKey = el.getAttribute('data-bind')!;
|
||||||
|
|
||||||
if (fieldKey === 'surgeonSignature') {
|
if (fieldKey === 'surgeonSignature') {
|
||||||
|
const isSigned = (reportData as any).isSigned === '已签字';
|
||||||
const signatureData = currentUser?.signature;
|
const signatureData = currentUser?.signature;
|
||||||
if (signatureData) {
|
if (isSigned && signatureData) {
|
||||||
const imgHtml = `<img src="${signatureData}" class="report-signature-img" alt="签名" draggable="false" />`;
|
const imgHtml = `<img src="${signatureData}" class="report-signature-img" alt="签名" draggable="false" />`;
|
||||||
if (el.innerHTML !== imgHtml) {
|
if (el.innerHTML !== imgHtml) {
|
||||||
el.innerHTML = imgHtml;
|
el.innerHTML = imgHtml;
|
||||||
@@ -950,8 +967,9 @@ export default function ReportEditor() {
|
|||||||
el.style.backgroundColor = 'transparent';
|
el.style.backgroundColor = 'transparent';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (el.innerText !== '【请上传电子签】') {
|
const placeholder = isSigned ? '【请上传电子签】' : '【未签字】';
|
||||||
el.innerText = '【请上传电子签】';
|
if (el.innerText !== placeholder) {
|
||||||
|
el.innerText = placeholder;
|
||||||
el.style.border = '';
|
el.style.border = '';
|
||||||
el.style.backgroundColor = '';
|
el.style.backgroundColor = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,12 @@ export default function TemplateManage() {
|
|||||||
if (smartField && targetEl.closest('.delete-btn')) {
|
if (smartField && targetEl.closest('.delete-btn')) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
smartField.remove();
|
const sel = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNode(smartField);
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
document.execCommand('delete');
|
||||||
saveTemplateContent();
|
saveTemplateContent();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -216,7 +221,12 @@ export default function TemplateManage() {
|
|||||||
|
|
||||||
if (target) {
|
if (target) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
target.remove();
|
const sel = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNode(target);
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
document.execCommand('delete');
|
||||||
saveTemplateContent();
|
saveTemplateContent();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -249,7 +259,7 @@ export default function TemplateManage() {
|
|||||||
alert(`字段 "${field.label}" 已存在,请勿重复插入。`);
|
alert(`字段 "${field.label}" 已存在,请勿重复插入。`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const html = `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><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;outline:none;"> </span><span class="delete-btn" contenteditable="false">×</span></span>`;
|
const html = `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><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;outline:none;"> </span><span class="delete-btn" contenteditable="false">×</span></span>​`;
|
||||||
document.execCommand('insertHTML', false, html);
|
document.execCommand('insertHTML', false, html);
|
||||||
editorRef.current?.focus();
|
editorRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -130,5 +130,6 @@ export const DEFAULT_FORM_FIELDS: FormField[] = [
|
|||||||
{ key: 'assistant', 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: 'anesthesiologist', label: '麻醉师', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: false, options: ['周医生', '吴医生', '郑医生'] },
|
||||||
{ key: 'anesthesiaType', label: '麻醉方式', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉'] },
|
{ key: 'anesthesiaType', label: '麻醉方式', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉'] },
|
||||||
{ key: 'surgeonSignature', label: '手术者签名', category: '图片', type: 'signature', visibleInForm: false, isSystemLocked: true },
|
{ key: 'isSigned', label: '手术者签名确认', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['已签字', '未签字'] },
|
||||||
|
{ key: 'surgeonSignature', label: '手术者签名', category: '图片', type: 'signature', visibleInForm: true, isSystemLocked: false },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const smartField = (key: string) => `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><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;outline:none;"> </span><span class="delete-btn" contenteditable="false">×</span></span>`;
|
const smartField = (key: string) => `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><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;outline:none;"> </span><span class="delete-btn" contenteditable="false">×</span></span>​`;
|
||||||
|
|
||||||
export const defaultReportContent = `
|
export const defaultReportContent = `
|
||||||
<!-- 医院Logo -->
|
<!-- 医院Logo -->
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const printDocument = (htmlContent: string) => {
|
|||||||
.smart-field-wrapper { display: inline-flex; align-items: center; margin: 0 2px; vertical-align: text-bottom; }
|
.smart-field-wrapper { display: inline-flex; align-items: center; margin: 0 2px; vertical-align: text-bottom; }
|
||||||
.smart-field-wrapper .field-label { color: #64748b; user-select: none; }
|
.smart-field-wrapper .field-label { color: #64748b; user-select: none; }
|
||||||
.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; }
|
.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; }
|
||||||
.report-signature-img { height: 2.4em; width: auto; vertical-align: middle; display: inline-block; margin: -0.3em 0; }
|
.report-signature-img { max-width: 120px; max-height: 40px; width: auto; height: auto; object-fit: contain; vertical-align: middle; display: inline-block; }
|
||||||
@media print {
|
@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; }
|
.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; }
|
||||||
}
|
}
|
||||||
|
|||||||
157
工程分析/实现方案-2026-04-17-12-34-56.md
Normal file
157
工程分析/实现方案-2026-04-17-12-34-56.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# 实现方案 — 撤销栈修复、字段删除交互优化与签名字段闭环(2026-04-17-12-34-56)
|
||||||
|
|
||||||
|
## 一、修改文件清单
|
||||||
|
|
||||||
|
1. `src/pages/TemplateManage.tsx` — 删除逻辑改用 `execCommand('delete')`;插入 HTML 增加零宽空格防换行
|
||||||
|
2. `src/types.ts` — 修改 `surgeonSignature` 显隐属性;新增 `isSigned` 字段
|
||||||
|
3. `src/pages/ReportEditor.tsx` — 初始值增加 `isSigned`;签名同步逻辑重构;完成报告签名校验
|
||||||
|
4. `src/index.css` — 签名图片尺寸约束
|
||||||
|
5. `src/utils/print.ts` — 打印样式同步签名尺寸约束
|
||||||
|
|
||||||
|
## 二、详细改动
|
||||||
|
|
||||||
|
### 2.1 `src/pages/TemplateManage.tsx`
|
||||||
|
|
||||||
|
#### A. 点击红 X 删除改用 `execCommand('delete')`
|
||||||
|
```ts
|
||||||
|
if (smartField && targetEl.closest('.delete-btn')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNode(smartField);
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
document.execCommand('delete');
|
||||||
|
saveTemplateContent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. 键盘 Backspace/Delete 改用 `execCommand('delete')`
|
||||||
|
在 `handleKeyDown` 中,当定位到 `smart-field-wrapper` 目标后:
|
||||||
|
```ts
|
||||||
|
if (target) {
|
||||||
|
e.preventDefault();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNode(target);
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
document.execCommand('delete');
|
||||||
|
saveTemplateContent();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### C. 插入 HTML 防换行
|
||||||
|
在 `insertSmartField` 的 HTML 字符串末尾增加 `​`(零宽空格),作为行内锚点,防止浏览器将字段挤到新行:
|
||||||
|
```html
|
||||||
|
<span class="smart-field-wrapper" ...>...</span>​
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 `src/types.ts`
|
||||||
|
|
||||||
|
- 将 `surgeonSignature` 改为:
|
||||||
|
```ts
|
||||||
|
{ key: 'surgeonSignature', label: '手术者签名', category: '图片', type: 'signature', visibleInForm: true, isSystemLocked: false }
|
||||||
|
```
|
||||||
|
- 在 `DEFAULT_FORM_FIELDS` 末尾追加(放在 `surgeonSignature` 之前或之后均可):
|
||||||
|
```ts
|
||||||
|
{ key: 'isSigned', label: '手术者签名确认', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['已签字', '未签字'] },
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 `src/pages/ReportEditor.tsx`
|
||||||
|
|
||||||
|
#### A. 初始 `reportData` 增加 `isSigned`
|
||||||
|
```ts
|
||||||
|
const [reportData, setReportData] = useState<Partial<Report>>({
|
||||||
|
// ... 其他字段
|
||||||
|
isSigned: '未签字',
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. 签名同步逻辑重构
|
||||||
|
将 `surgeonSignature` 的特殊处理从 `useEffect` 移到更前面的位置,逻辑改为:
|
||||||
|
```ts
|
||||||
|
if (fieldKey === 'surgeonSignature') {
|
||||||
|
const isSigned = (reportData as any).isSigned === '已签字';
|
||||||
|
const signatureData = currentUser?.signature;
|
||||||
|
if (isSigned && signatureData) {
|
||||||
|
const imgHtml = `<img src="${signatureData}" class="report-signature-img" alt="签名" draggable="false" />`;
|
||||||
|
if (el.innerHTML !== imgHtml) {
|
||||||
|
el.innerHTML = imgHtml;
|
||||||
|
el.style.border = 'none';
|
||||||
|
el.style.backgroundColor = 'transparent';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const placeholder = isSigned ? '【请上传电子签】' : '【未签字】';
|
||||||
|
if (el.innerText !== placeholder) {
|
||||||
|
el.innerText = placeholder;
|
||||||
|
el.style.border = '';
|
||||||
|
el.style.backgroundColor = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### C. 完成报告签名校验
|
||||||
|
在 `saveReport` 的 `status === 'completed'` 分支中,在现有患者信息校验之后追加:
|
||||||
|
```ts
|
||||||
|
const hasSignatureField = editorRef.current?.querySelector('[data-bind="surgeonSignature"]');
|
||||||
|
if (hasSignatureField) {
|
||||||
|
const isSigned = reportData.isSigned === '已签字';
|
||||||
|
const hasSignatureImage = !!currentUser?.signature;
|
||||||
|
if (!isSigned) {
|
||||||
|
const proceed = window.confirm('提示:模板中包含【手术者签名】字段,但您在基本信息中未选择"已签字"。是否继续完成报告?');
|
||||||
|
if (!proceed) return;
|
||||||
|
} else if (!hasSignatureImage) {
|
||||||
|
const proceed = window.confirm('提示:您选择了"已签字",但您的账号尚未上传电子签名图片。报告中将不显示签名图片,是否继续完成?');
|
||||||
|
if (!proceed) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 `src/index.css`
|
||||||
|
|
||||||
|
修改 `.report-signature-img`:
|
||||||
|
```css
|
||||||
|
.report-signature-img {
|
||||||
|
max-width: 120px;
|
||||||
|
max-height: 40px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
vertical-align: middle;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `@media print` 中同步:
|
||||||
|
```css
|
||||||
|
@media print {
|
||||||
|
.report-signature-img {
|
||||||
|
max-width: 120px !important;
|
||||||
|
max-height: 40px !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
object-fit: contain !important;
|
||||||
|
vertical-align: middle !important;
|
||||||
|
display: inline-block !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 `src/utils/print.ts`
|
||||||
|
|
||||||
|
在 iframe 的 `<style>` 中,`.smart-field-wrapper` 规则之后追加:
|
||||||
|
```css
|
||||||
|
.report-signature-img { max-width: 120px; max-height: 40px; width: auto; height: auto; object-fit: contain; vertical-align: middle; display: inline-block; }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三、风险与回滚
|
||||||
|
|
||||||
|
- **风险**:改用 `execCommand('delete')` 后,少数旧版浏览器可能行为不一致,但现代 Chromium/Edge 支持良好。
|
||||||
|
- **风险**:`​` 零宽空格在极少数场景下可能导致光标异常,但其为无形字符,影响极小。
|
||||||
|
- **回滚**:如出现问题,可回退上述 5 个文件的修改。
|
||||||
56
工程分析/测试方案-2026-04-17-12-34-56.md
Normal file
56
工程分析/测试方案-2026-04-17-12-34-56.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 测试方案 — 撤销栈修复、字段删除交互优化与签名字段闭环(2026-04-17-12-34-56)
|
||||||
|
|
||||||
|
## 一、编译检查
|
||||||
|
|
||||||
|
- 执行 `npm run lint`(`tsc --noEmit`),确保全量 TypeScript 无编译错误。
|
||||||
|
|
||||||
|
## 二、功能验证步骤
|
||||||
|
|
||||||
|
### 测试 1:TemplateManage 撤销功能恢复
|
||||||
|
1. 进入【模板管理】,选择默认模板。
|
||||||
|
2. 点击某智能字段(如"手术日期")右上角的红色 × 删除该字段。
|
||||||
|
3. 点击编辑器工具栏的"撤销"按钮(↶)。
|
||||||
|
4. 确认被删除的字段重新出现,撤销功能正常。
|
||||||
|
|
||||||
|
### 测试 2:TemplateManage 插入字段不强制换行
|
||||||
|
1. 在模板编辑器中,将光标定位到一行文字中间(如"手术名称:"后面)。
|
||||||
|
2. 点击右侧字段库插入"手术日期"。
|
||||||
|
3. 确认"手术日期"字段框紧跟在光标位置,没有跳到下一行。
|
||||||
|
4. 再次插入"手术者签名",确认同样保持在当前行。
|
||||||
|
|
||||||
|
### 测试 3:TemplateManage Backspace/Delete 精准删除
|
||||||
|
1. 将光标定位在某智能字段框**正后方**,按 Backspace。
|
||||||
|
2. 确认仅该字段被删除,前面和后面的文本不受影响。
|
||||||
|
3. 将光标定位在某智能字段框**正前方**,按 Delete。
|
||||||
|
4. 确认仅该字段被删除,其他文本完好无损。
|
||||||
|
5. 删除后点击"撤销",确认字段恢复。
|
||||||
|
|
||||||
|
### 测试 4:签名图片尺寸约束
|
||||||
|
1. 进入【用户管理】,给当前登录用户上传一张较大的电子签图片。
|
||||||
|
2. 进入【新建报告】,在右侧【基本信息】中将"手术者签名确认"选择为"已签字"。
|
||||||
|
3. 观察模板中的签名图片,确认其宽度不超过 120px,高度不超过 40px,且等比例缩放未变形。
|
||||||
|
|
||||||
|
### 测试 5:签名字段显隐与表单联动
|
||||||
|
1. 进入【模板管理】,查看右侧【字段管理】,确认"手术者签名"和"手术者签名确认"字段存在且可切换"显示/隐藏"。
|
||||||
|
2. 进入【新建报告】,确认右侧【基本信息】表单中出现了"手术者签名确认"下拉框(默认"未签字")。
|
||||||
|
3. 选择"未签字",确认模板中的签名方框显示"【未签字】"。
|
||||||
|
4. 选择"已签字",确认模板中的签名方框显示签名图片。
|
||||||
|
5. 在 UserManage 中清除当前用户签名,返回 ReportEditor 将"手术者签名确认"选为"已签字"。
|
||||||
|
6. 确认签名方框显示"【请上传电子签】"。
|
||||||
|
|
||||||
|
### 测试 6:完成报告签名校验提示
|
||||||
|
1. 确保模板中存在"手术者签名"字段,且 ReportEditor 中"手术者签名确认"为"未签字"。
|
||||||
|
2. 点击"完成报告",确认弹出提示:"模板中包含【手术者签名】字段,但您在基本信息中未选择'已签字'。是否继续完成报告?"
|
||||||
|
3. 点击"取消",确认报告未被保存,停留在编辑页。
|
||||||
|
4. 将"手术者签名确认"改为"已签字",清除当前用户的电子签图片。
|
||||||
|
5. 再次点击"完成报告",确认弹出提示:"您选择了'已签字',但您的账号尚未上传电子签名图片。报告中将不显示签名图片,是否继续完成?"
|
||||||
|
6. 点击"确定",确认报告正常保存并跳转至报告管理页。
|
||||||
|
|
||||||
|
## 三、预期结果
|
||||||
|
|
||||||
|
- `npm run lint` 0 错误。
|
||||||
|
- TemplateManage 中删除字段后撤销正常。
|
||||||
|
- 插入字段不强制换行;Backspace/Delete 精准删除单个字段。
|
||||||
|
- 签名图片受 120×40px 约束,等比例缩放。
|
||||||
|
- 表单联动正常:已签字→显示图片,未签字→显示"【未签字】",无签名图→显示"【请上传电子签】"。
|
||||||
|
- 完成报告时签名异常给出弱阻断提示,用户可取消或继续。
|
||||||
41
工程分析/经验记录.md
41
工程分析/经验记录.md
@@ -453,6 +453,47 @@ if ((settings.autoInsertDelay || 0) > 0) {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 记录 19:撤销栈修复、字段删除交互优化与签名字段闭环
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
1. `TemplateManage` 中通过红色 × 或键盘删除智能字段后,浏览器撤销栈(Undo)失效,点击"撤销"按钮无法恢复。
|
||||||
|
2. 插入"手术日期"、"手术者签名"等字段后,字段框有时会跳到下一行。
|
||||||
|
3. Backspace 键无法删除字段;Delete 键会误删字段前面的大段文本(如"手术步骤、术中出现的情况及处理:")。
|
||||||
|
4. 签名图片没有最大尺寸限制;"手术者签名"字段不在 ReportEditor 表单中显示,无法受控管理签字状态。
|
||||||
|
5. 点击"完成报告"时缺少对签名状态的确认提示。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
1. 删除字段时使用了 `target.remove()` 直接操作 DOM,绕过了浏览器的原生撤销栈(`undo stack`)。
|
||||||
|
2. 插入的 `smart-field-wrapper` 是 `inline-block` 元素,但其后缺少行内锚点文本节点,浏览器在特定光标位置插入时容易将其挤到新行。
|
||||||
|
3. `keydown` 拦截逻辑中 `target.remove()` 同样会误删父级块节点(WebKit 在边界处对 `contenteditable="false"` inline 元素的处理缺陷)。
|
||||||
|
4. `surgeonSignature` 字段原先 `visibleInForm: false`,且签名图片样式仅用 `height: 2.4em` 约束,没有 `max-width/max-height` 的硬限制。
|
||||||
|
5. 完成报告逻辑中缺少针对签名字段的业务校验。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
1. **撤销栈修复**:将点击红 × 删除和键盘 Backspace/Delete 删除全部改为 `Range.selectNode(target)` + `document.execCommand('delete')`。这样浏览器会将删除操作记录到撤销栈中,`execCommand('undo')` 可以正确恢复。
|
||||||
|
2. **防换行**:在 `insertSmartField` 和 `defaultContent.ts` 的 `smartField()` 生成的 HTML 末尾增加 `​`(零宽空格),作为稳定的行内锚点,防止字段被浏览器排到新行。
|
||||||
|
3. **精准键盘删除**:配合 `Range.selectNode` + `execCommand('delete')`,不再直接 `remove()` DOM 节点,彻底避免误删父级 `<p>` 的问题。
|
||||||
|
4. **签名尺寸与字段管理**:
|
||||||
|
- `types.ts` 中将 `surgeonSignature` 改为 `visibleInForm: true, isSystemLocked: false`,使其出现在字段管理和右侧表单中。
|
||||||
|
- 新增 `isSigned` 字段(单选:已签字 / 未签字,默认"未签字")。
|
||||||
|
- 签名图片样式改为 `max-width: 120px; max-height: 40px; object-fit: contain;`,并在打印样式和 `print.ts` 中同步。
|
||||||
|
5. **签名同步逻辑重构**:`ReportEditor` 中 `surgeonSignature` 的渲染由 `isSigned` 控制:
|
||||||
|
- `已签字` 且 `currentUser.signature` 存在 → 显示签名图片。
|
||||||
|
- `已签字` 但无签名图 → 显示 "【请上传电子签】"。
|
||||||
|
- `未签字` → 显示 "【未签字】"。
|
||||||
|
6. **完成报告签名校验**:`saveReport('completed')` 中,若模板包含 `surgeonSignature`:
|
||||||
|
- 未选择"已签字" → `confirm` 弱阻断提示。
|
||||||
|
- 已选择"已签字"但无签名图 → `confirm` 弱阻断提示。
|
||||||
|
- 用户点击"取消"则中断保存,点击"确定"仍可继续保存。
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- 在 `contentEditable` 中删除元素时,**优先使用 `Range.selectNode` + `execCommand('delete')`** 而非直接 `remove()`,以确保撤销/重做等原生编辑行为正常工作。
|
||||||
|
- 插入 `inline-block` 或 `inline-flex` 控件时,可在其后追加 `​` 零宽空格,为浏览器提供稳定的行内文本锚点,减少排版异常。
|
||||||
|
- 任何需要从"不可见"改为"可见/可配置"的字段,应在 `DEFAULT_FORM_FIELDS`、`Report 类型`、`reportData 初始值` 三处同步更新,防止表单渲染遗漏。
|
||||||
|
- 对于图片类嵌入内容,应使用 `max-width`/`max-height` + `object-fit: contain` 做硬约束,避免不同来源图片破坏页面布局。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 记录 14:智能字段插入间距修复与 Backspace 防误删
|
## 记录 14:智能字段插入间距修复与 Backspace 防误删
|
||||||
|
|
||||||
**A. 具体问题**
|
**A. 具体问题**
|
||||||
|
|||||||
67
工程分析/需求分析-2026-04-17-12-34-56.md
Normal file
67
工程分析/需求分析-2026-04-17-12-34-56.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# 需求分析 — 撤销栈修复、字段删除交互优化与签名字段闭环(2026-04-17-12-34-56)
|
||||||
|
|
||||||
|
## 一、需求来源
|
||||||
|
|
||||||
|
用户反馈 TemplateManage 中删除字段后撤销失效、插入字段导致非预期换行、Backspace/Delete 误删父级内容;同时希望优化手术者签名的业务逻辑闭环,包括尺寸约束、表单显隐控制及完成报告时的弱阻断提示。
|
||||||
|
|
||||||
|
## 二、具体需求拆解
|
||||||
|
|
||||||
|
### 需求 1:修复撤销(Undo)失效
|
||||||
|
|
||||||
|
**问题**:在 TemplateManage 中点击红色 × 或通过键盘删除智能字段后,点击工具栏的"撤销"按钮无法恢复被删除的字段。
|
||||||
|
|
||||||
|
**原因**:当前代码使用 `smartField.remove()` 和 `target.remove()` 直接操作 DOM,绕过了浏览器的原生撤销栈(undo stack)。
|
||||||
|
|
||||||
|
**期望**:删除字段的操作能被浏览器的 `execCommand('undo')` 正确撤销。
|
||||||
|
|
||||||
|
### 需求 2:修复插入字段换行与 Backspace/Delete 误删
|
||||||
|
|
||||||
|
**问题**:
|
||||||
|
- 在 TemplateManage 中插入"手术日期"或"手术者签名"后,字段框有时会直接跳到下一行。
|
||||||
|
- Backspace 键无法删除字段(无反应)。
|
||||||
|
- Delete 键会误删字段前面的大段文本(如"手术步骤、术中出现的情况及处理:")。
|
||||||
|
|
||||||
|
**期望**:
|
||||||
|
- 插入的字段保持在当前行内,不强制换行。
|
||||||
|
- Backspace/Delete 键能精准删除光标相邻的单个字段节点,不影响周围文本。
|
||||||
|
|
||||||
|
### 需求 3:签名图片尺寸约束与字段管理
|
||||||
|
|
||||||
|
**问题**:
|
||||||
|
- 当前签名图片没有最大尺寸限制,可能过大。
|
||||||
|
- "手术者签名"字段不在 ReportEditor 的右侧基本信息表单中显示,无法控制是否签字。
|
||||||
|
|
||||||
|
**期望**:
|
||||||
|
- 签名图片设置最大宽度 `120px`、最大高度 `40px`,等比例缩放(`object-fit: contain`)。
|
||||||
|
- 将 `surgeonSignature` 字段改为 `visibleInForm: true`、`isSystemLocked: false`,使其可在字段管理中显隐控制。
|
||||||
|
- 在 ReportEditor 右侧表单中增加"手术者签名确认"(`isSigned`)单选下拉框,选项为"已签字"、"未签字",默认"未签字"。
|
||||||
|
- 模板中的 `surgeonSignature` 字段根据 `isSigned` 的值渲染:
|
||||||
|
- `isSigned === '已签字'` 且用户有签名图 → 显示等比例缩放后的签名图片。
|
||||||
|
- 其他情况 → 显示"【未签字】"。
|
||||||
|
|
||||||
|
### 需求 4:完成报告时的签名校验提示
|
||||||
|
|
||||||
|
**期望**:点击"完成报告"时:
|
||||||
|
- 若模板中存在 `data-bind="surgeonSignature"` 的字段,但 `isSigned !== '已签字'`,弹出 `confirm` 提示"模板中包含【手术者签名】字段,但您未选择'已签字'。是否继续完成报告?",用户选择"取消"则中断保存,选择"确定"则继续保存。
|
||||||
|
- 若 `isSigned === '已签字'` 但当前用户未上传电子签名图片,弹出 `confirm` 提示"您选择了'已签字',但账号尚未上传电子签名图片。报告中将不显示签名图片,是否继续完成?",同样弱阻断(可继续保存)。
|
||||||
|
|
||||||
|
## 三、影响范围分析
|
||||||
|
|
||||||
|
| 文件 | 改动说明 |
|
||||||
|
|------|----------|
|
||||||
|
| `src/pages/TemplateManage.tsx` | 点击删除和键盘删除逻辑全部改用 `Selection + Range + execCommand('delete')`;`insertSmartField` 的 HTML 末尾增加 `​`(零宽空格)防止强制换行。 |
|
||||||
|
| `src/types.ts` | `surgeonSignature` 改为 `visibleInForm: true, isSystemLocked: false`;新增 `isSigned` 字段到 `DEFAULT_FORM_FIELDS`。 |
|
||||||
|
| `src/pages/ReportEditor.tsx` | `reportData` 初始值增加 `isSigned: '未签字'`;签名同步逻辑改为基于 `isSigned` 判断;`saveReport('completed')` 增加签名校验提示。 |
|
||||||
|
| `src/index.css` | 调整 `.report-signature-img` 为 `max-width: 120px; max-height: 40px; object-fit: contain;`。 |
|
||||||
|
| `src/utils/print.ts` | 打印样式中同步签名图片尺寸约束。 |
|
||||||
|
|
||||||
|
## 四、验收标准
|
||||||
|
|
||||||
|
- [ ] TemplateManage 中删除字段后,点击"撤销"按钮能正确恢复字段。
|
||||||
|
- [ ] TemplateManage 中插入字段后保持在当前行,不跳到下一行。
|
||||||
|
- [ ] Backspace/Delete 键能精准删除单个字段节点,不误删周围文本。
|
||||||
|
- [ ] ReportEditor 中签名图片最大 120×40px,等比例缩放。
|
||||||
|
- [ ] ReportEditor 右侧表单出现"手术者签名确认"下拉框(已签字/未签字)。
|
||||||
|
- [ ] 选择"已签字"且有签名图时,模板中显示签名图片;选择"未签字"或无签名图时显示"【未签字】"。
|
||||||
|
- [ ] 点击"完成报告"时,签名状态异常会弹出弱阻断提示,用户可取消或继续保存。
|
||||||
|
- [ ] `npm run lint` 无编译错误。
|
||||||
Reference in New Issue
Block a user