feat: 6项交互优化 - placeholder虚线框清除、删除按钮遮挡修复、*分隔输入、签名尺寸固定、移除isSigned、多选文本拼接重构

This commit is contained in:
2026-04-17 20:46:58 +08:00
parent ee1ac0d637
commit 28b913692c
5 changed files with 149 additions and 58 deletions

View File

@@ -36,7 +36,6 @@ export default function ReportEditor() {
assistant: [],
anesthesiologist: [],
anesthesiaType: '',
isSigned: '未签字',
reportNote: '',
status: 'draft'
});
@@ -387,6 +386,8 @@ export default function ReportEditor() {
<img src="${src}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
`;
placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
@@ -895,13 +896,9 @@ export default function ReportEditor() {
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 (!hasSignatureImage) {
const proceed = window.confirm('提示:模板中包含【手术者签名】字段,但您的账号尚未上传电子签名图片。报告中将不显示签名图片,是否继续完成?');
if (!proceed) return;
}
}
@@ -993,9 +990,8 @@ export default function ReportEditor() {
const fieldKey = el.getAttribute('data-bind')!;
if (fieldKey === 'surgeonSignature') {
const isSigned = (reportData as any).isSigned === '已签字';
const signatureData = currentUser?.signature;
if (isSigned && signatureData) {
if (signatureData) {
const imgHtml = `<img src="${signatureData}" class="report-signature-img" alt="签名" draggable="false" />`;
if (el.innerHTML !== imgHtml) {
el.innerHTML = imgHtml;
@@ -1003,7 +999,7 @@ export default function ReportEditor() {
el.style.backgroundColor = 'transparent';
}
} else {
const placeholder = isSigned ? '【请上传电子签】' : '【未签字】';
const placeholder = '【请上传电子签】';
if (el.innerText !== placeholder) {
el.innerText = placeholder;
el.style.border = '';
@@ -1298,7 +1294,39 @@ export default function ReportEditor() {
const isOpen = openDropdown === field.key;
const opts = field.options || multiSelectOptions[field.key] || [];
const rawValue = (reportData as any)[field.key];
const tags = Array.isArray(rawValue) ? rawValue : (rawValue ? [String(rawValue)] : []);
const currentValues = Array.isArray(rawValue) ? rawValue : (rawValue ? [String(rawValue)] : []);
const displayText = currentValues.join(', ');
const parseMultiInput = (text: string): string[] => {
return Array.from(new Set(text.split(/[,;;、]/).map(s => s.trim()).filter(Boolean)));
};
const handleMultiChange = (text: string) => {
const values = parseMultiInput(text);
const next = { ...reportData, [field.key]: values };
setReportData(next);
stateRef.current = { ...stateRef.current, reportData: next };
saveDraftToStorage();
};
const handleMultiCommit = (text: string) => {
const values = parseMultiInput(text);
const currentOpts = field.options || multiSelectOptions[field.key] || [];
const newOpts = values.filter(v => !currentOpts.includes(v));
if (newOpts.length > 0) {
const mergedOpts = [...currentOpts, ...newOpts];
const nextMulti = { ...multiSelectOptions, [field.key]: mergedOpts };
setMultiSelectOptions(nextMulti);
storage.set('multiSelectOptions', nextMulti);
const fieldDef = formFields.find(f => f.key === field.key);
if (fieldDef) {
const updatedFields = formFields.map(f => f.key === field.key ? { ...f, options: mergedOpts } : f);
setFormFields(updatedFields);
storage.set('formFieldsConfig', updatedFields);
}
}
};
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>
@@ -1306,22 +1334,18 @@ export default function ReportEditor() {
className="w-full px-3 py-2 border border-border rounded-lg bg-white flex flex-wrap gap-1 items-center min-h-[42px] cursor-text"
onClick={() => setOpenDropdown(field.key)}
>
{tags.map((tag: string) => (
<span key={tag} className="px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 flex items-center gap-1">
{tag}
<span className="cursor-pointer hover:text-amber-900" onClick={(e) => { e.stopPropagation(); removeTag(field.key, tag); }}>×</span>
</span>
))}
<input
type="text"
className="outline-none text-sm min-w-[60px] flex-1 bg-transparent"
placeholder="输入或选择"
className="outline-none text-sm w-full bg-transparent"
placeholder="输入或选择,多个用逗号分隔"
value={displayText}
onChange={(e) => handleMultiChange(e.target.value)}
onFocus={() => setOpenDropdown(field.key)}
onBlur={(e) => handleMultiCommit(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = (e.target as HTMLInputElement).value.trim();
if (val) { addTag(field.key, val); (e.target as HTMLInputElement).value = ''; }
handleMultiCommit((e.target as HTMLInputElement).value);
}
}}
/>
@@ -1332,7 +1356,11 @@ export default function ReportEditor() {
<div
key={opt}
className="px-3 py-2 text-sm hover:bg-slate-50 cursor-pointer flex justify-between items-center"
onClick={() => { addTag(field.key, opt); }}
onClick={() => {
const newText = currentValues.length > 0 ? `${displayText}, ${opt}` : opt;
handleMultiChange(newText);
handleMultiCommit(newText);
}}
>
<span>{opt}</span>
<span

View File

@@ -125,6 +125,8 @@ export default function TemplateManage() {
<img src="${src}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
`;
placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
saveTemplateContent();
};
@@ -486,24 +488,23 @@ export default function TemplateManage() {
const insertImage = () => {
editorRef.current?.focus();
restoreSelection();
const input = prompt('请输入占位符的最大宽度和高度(px),用英文逗号分隔(如: 100,50。留空则默认宽高为 200*200。(提示: 正文一行文字高度约为 20 像素左右)', '');
if (input === null) return;
const parts = input.split(',').map(s => s.trim());
const widthStr = parts[0] || '';
const heightStr = parts[1] || '';
let width = parseInt(widthStr) || 0;
let height = parseInt(heightStr) || 0;
if (!widthStr && !heightStr) {
width = 200;
height = 200;
} else if (widthStr && !heightStr) {
height = 200;
} else if (!widthStr && heightStr) {
width = 200;
let width = 200;
let height = 200;
while (true) {
const input = prompt('请输入占位符的最大宽度和高度(px),用 * 分隔(如: 100*50。留空则默认宽高为 200*200。(提示: 正文一行文字高度约为 20 像素左右)', '');
if (input === null) return;
const trimmed = input.trim();
if (trimmed === '') break;
const parts = trimmed.split('*').map(s => s.trim());
if (parts.length === 2 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
width = parseInt(parts[0]) || 0;
height = parseInt(parts[1]) || 0;
break;
}
alert('格式错误,请确保使用 * 分隔两个数字,例如 100*50');
}
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;overflow:hidden;';
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
if (width > 0) styleStr += `width:${width}px;`;
if (height > 0) styleStr += `height:${height}px;`;

View File

@@ -136,5 +136,4 @@ export const DEFAULT_FORM_FIELDS: FormField[] = [
{ key: 'specimenDescription', label: '切除标本描述', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['胆囊一枚壁厚约0.3cm,内含数枚结石'] },
{ key: 'pathologyCheck', label: '是否送病理检查', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['是', '否'] },
{ key: 'frozenPathology', label: '冰冻病理结果', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['未见恶性', '待石蜡'] },
{ key: 'isSigned', label: '手术者签名确认', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['已签字', '未签字'] },
];

View File

@@ -87,47 +87,47 @@ export const defaultReportContent = `
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; table-layout: fixed;">
<tbody><tr>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
</div>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<p style="color: #64748b; font-size: 13px; margin: 0;">图A 腹腔镜探查</p>
</td>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
</div>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<p style="color: #64748b; font-size: 13px; margin: 0;">图B 胆囊管夹闭与离断</p>
</td>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
</div>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<p style="color: #64748b; font-size: 13px; margin: 0;">图C 胆囊动脉夹闭与离断</p>
</td>
</tr>
<tr>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
</div>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<p style="color: #64748b; font-size: 13px; margin: 0;">图D 胆囊剥离与床面止血</p>
</td>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
</div>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<p style="color: #64748b; font-size: 13px; margin: 0;">图E 胆囊取出与钛夹确认</p>
</td>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
</div>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<p style="color: #64748b; font-size: 13px; margin: 0;">图F 止血材料覆盖及检查</p>
</td>
</tr></tbody>
@@ -151,7 +151,7 @@ export const defaultReportContent = `
</p>
<p style="font-family: SimSun;">
手术者签名:<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;min-width:80px;min-height:24px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入图片</span></span>
手术者签名:<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:200px;height:40px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入图片</span></span>
</p>
<p style="text-align: right; font-family: SimSun; color: #bdbdbd;">

View File

@@ -289,6 +289,69 @@ if ((settings.autoInsertDelay || 0) > 0) {
---
## 记录 136 项交互优化placeholder 虚线框、删除按钮、签名尺寸、多选重构)
**A. 具体问题**
用户提出 6 个 UI/UX 改进需求:
1. 图片插入占位符后虚线框残留——内联 `border:1px dashed #cbd5e1` 优先级高于 `.has-image` CSS class
2. `insertImage` 生成的 placeholder 中 `overflow:hidden` 裁切了绝对定位的删除按钮(`×`
3. 占位符尺寸输入从逗号分隔改为星号(`*`)分隔,格式错误时提示重新输入;
4. 默认模板中「手术者签名」占位符固定为 `200×40px`
5. 删除「手术者签名确认」字段及相关的弱阻断确认弹窗;
6. 多选组件从 tag 形态重构为纯文本拼接形态,支持多种标点符号拆分并自动保存新选项。
**B. 产生问题原因**
1. `fillPlaceholderSrc` 仅添加了 `has-image` class但内联 `style="border:..."` 的优先级永远高于外部 CSS导致虚线框无法消除。
2. `insertImage` 的 `styleStr` 中硬编码了 `overflow:hidden;`,而删除按钮使用 `position:absolute; top:-8px; right:-8px` 之类的定位,必然被父级裁切。
3. 英文逗号分隔容易与用户输入的千位分隔符或中文逗号混淆。
4. 默认模板中签名占位符使用 `min-width:80px;min-height:24px`,尺寸过小且不一致。
5. `isSigned` 字段与签名图片是两个独立的状态,造成医生需要多点一次确认,流程冗余。
6. 原多选使用 tag 胶囊形式,每个 tag 带背景色和删除按钮,占用空间大,且无法直接复制粘贴整段文本。
**C. 解决问题方案**
1. **清除内联样式**:在 `ReportEditor.tsx` 和 `TemplateManage.tsx` 的 `fillPlaceholderSrc` 中增加:
```ts
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
```
同时统一 `defaultContent.ts` 中所有 8 个 placeholder 为 `<span style="display:inline-flex;...">` 格式,表格中的 6 个也统一使用 `width:100%;height:150px;`。
2. **移除 overflow:hidden**:从两个 `insertImage` 的 `styleStr` 中删除 `overflow:hidden;`,保留在 `placeholder-text` 子元素上(文字截断仍可用)。
3. **星号分隔 + 校验循环**
```ts
while (true) {
const input = prompt('...用 * 分隔...', '');
if (input === null) return;
const trimmed = input.trim();
if (trimmed === '') break;
const parts = trimmed.split('*').map(s => s.trim());
if (parts.length === 2 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
width = parseInt(parts[0]); height = parseInt(parts[1]); break;
}
alert('格式错误...');
}
```
4. **签名占位符尺寸**`defaultContent.ts` 中改为 `width:200px;height:40px;`。
5. **移除 `isSigned`**
- `types.ts` 的 `DEFAULT_FORM_FIELDS` 中删除;
- `ReportEditor.tsx` 的初始 `reportData` 中删除;
- `saveReport` 的完成确认逻辑中删除 `isSigned` 判断;
- smart field 同步逻辑中删除 `isSigned` 判断,只要有 `signatureData` 就直接显示签名图。
6. **多选重构为文本拼接**
- `displayText = currentValues.join(', ')`
- input 使用 `value={displayText}` 受控组件;
- `onChange` 实时解析并更新 `reportData``parseMultiInput(text)` 用 `/[,;;、]/` 正则拆分、去重;
- `onBlur` / `Enter` 时调用 `handleMultiCommit`,将拆分出的新选项保存到 `multiSelectOptions` 和 `formFieldsConfig`
- 下拉选择时追加 `, opt` 到现有文本。
**D. 后续如何避免问题**
- 当使用内联样式设置边框/背景时,如需在特定状态下移除,**必须在内联层面重置**`style.border = 'none'`),不能仅依赖 CSS class 覆盖。
- `overflow:hidden` 与绝对定位子元素互斥,若需要裁切文字但保留溢出按钮,应将 `overflow:hidden` 限制在文字子元素上,而非父容器。
- 用户输入的格式校验应使用 `while` 循环 + `alert` 重试,避免静默容错导致不可预期的行为。
- 删除字段时务必全局搜索(`grep -r 'isSigned'`),确保初始化状态、表单验证、模板绑定等所有引用点都被清理。
- 将「标签胶囊」改为「纯文本拼接」时,注意保持 `reportData` 的数据结构仍为数组UI 层只做 `join/split` 转换。
---
## 记录 11关键帧在路由切换后丢失——压缩 Canvas 分辨率并增加存储错误日志
**A. 具体问题**