refactor: unify image-placeholder across editors and remove image field type

- Remove surgeonSignature and hospitalLogo from DEFAULT_FORM_FIELDS.
- Replace logo and signature in default template with inline image-placeholder spans.
- Enhance insertImage() in both editors with prompt for max-width/height (px).
- Abbreviate placeholder text to '插入图片' when width < 80px.
- Force inline insertion using display:inline-flex + vertical-align:middle.
- Port image-source picker modal from ReportEditor to TemplateManage.
- Remove legacy triggerPlaceholderUpload direct upload logic.
This commit is contained in:
2026-04-17 19:34:03 +08:00
parent 0c57409c59
commit 5fee3352c1
8 changed files with 432 additions and 57 deletions

View File

@@ -306,7 +306,7 @@ export default function ReportEditor() {
placeholder.classList.remove('has-image'); placeholder.classList.remove('has-image');
placeholder.innerHTML = ` placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span> <span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p> <span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
`; `;
if (editorRef.current) contentRef.current = editorRef.current.innerHTML; if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage(); saveDraftToStorage();
@@ -384,7 +384,7 @@ export default function ReportEditor() {
const fillPlaceholderSrc = (placeholder: HTMLElement, src: string) => { const fillPlaceholderSrc = (placeholder: HTMLElement, src: string) => {
placeholder.innerHTML = ` placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span> <span class="delete-btn" contenteditable="false">×</span>
<img src="${src}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false"> <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.classList.add('has-image');
if (editorRef.current) contentRef.current = editorRef.current.innerHTML; if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
@@ -422,13 +422,21 @@ export default function ReportEditor() {
const insertImage = () => { const insertImage = () => {
editorRef.current?.focus(); editorRef.current?.focus();
const widthStr = prompt('请输入占位符最大宽度 (px),留空无限制:\n(提示:正文一行文字高度约为 20 像素左右)', '');
const heightStr = prompt('请输入占位符最大高度 (px),留空无限制:', '');
const width = parseInt(widthStr || '0');
const height = parseInt(heightStr || '0');
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 += ` max-width:${width}px;`;
if (height > 0) styleStr += ` max-height:${height}px;`;
if (!width && !height) styleStr += ' padding:8px 16px;';
const showShortText = width > 0 && width < 80;
const hintText = showShortText ? '插入图片' : '插入/点击放置图片';
const id = 'ph_' + Date.now(); const id = 'ph_' + Date.now();
const html = ` const html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><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;">${hintText}</span></span>&#8203;`;
<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false">
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
</div>
`;
execCmd('insertHTML', html); execCmd('insertHTML', html);
}; };

View File

@@ -24,7 +24,9 @@ export default function TemplateManage() {
const [formFields, setFormFields] = useState<FormField[]>([]); const [formFields, setFormFields] = useState<FormField[]>([]);
const [newFieldForm, setNewFieldForm] = useState({ label: '', category: '填空', type: 'text' as FieldType }); const [newFieldForm, setNewFieldForm] = useState({ label: '', category: '填空', type: 'text' as FieldType });
const [newFieldOptions, setNewFieldOptions] = useState(''); const [newFieldOptions, setNewFieldOptions] = useState('');
const [expandedCategories, setExpandedCategories] = useState<string[]>(['填空', '单选', '多选', '时间', '图片']); const [expandedCategories, setExpandedCategories] = useState<string[]>(['填空', '单选', '多选', '时间']);
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [imagePickerTarget, setImagePickerTarget] = useState<HTMLElement | null>(null);
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(null); const [activeFieldKey, setActiveFieldKey] = useState<string | null>(null);
const [editingFieldKey, setEditingFieldKey] = useState<string | null>(null); const [editingFieldKey, setEditingFieldKey] = useState<string | null>(null);
const [editFieldLabel, setEditFieldLabel] = useState(''); const [editFieldLabel, setEditFieldLabel] = useState('');
@@ -117,26 +119,13 @@ export default function TemplateManage() {
return () => observer.disconnect(); return () => observer.disconnect();
}, [currentUser]); }, [currentUser]);
const triggerPlaceholderUpload = (placeholder: HTMLElement) => { const fillPlaceholderSrc = (placeholder: HTMLElement, src: string) => {
const input = document.createElement('input'); placeholder.innerHTML = `
input.type = 'file'; <span class="delete-btn" contenteditable="false">×</span>
input.accept = 'image/*'; <img src="${src}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
input.onchange = (ev) => { `;
const file = (ev.target as HTMLInputElement).files?.[0]; placeholder.classList.add('has-image');
if (file) { saveTemplateContent();
const reader = new FileReader();
reader.onload = (event) => {
const src = event.target?.result as string;
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${src}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
`;
placeholder.classList.add('has-image');
};
reader.readAsDataURL(file);
}
};
input.click();
}; };
// Handle image placeholder and smart field delete interactions via click capture // Handle image placeholder and smart field delete interactions via click capture
@@ -190,7 +179,7 @@ export default function TemplateManage() {
placeholder.classList.remove('has-image'); placeholder.classList.remove('has-image');
placeholder.innerHTML = ` placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span> <span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p> <span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
`; `;
} else { } else {
const range = document.createRange(); const range = document.createRange();
@@ -206,7 +195,8 @@ export default function TemplateManage() {
if (!placeholder.classList.contains('has-image')) { if (!placeholder.classList.contains('has-image')) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
triggerPlaceholderUpload(placeholder); setImagePickerTarget(placeholder);
setImagePickerOpen(true);
} }
}; };
@@ -358,19 +348,13 @@ export default function TemplateManage() {
const insertSmartField = (field: FormField) => { const insertSmartField = (field: FormField) => {
editorRef.current?.focus(); editorRef.current?.focus();
restoreSelection(); restoreSelection();
if (field.type !== 'image' && editorRef.current?.querySelector(`[data-bind="${field.key}"]`)) { if (editorRef.current?.querySelector(`[data-bind="${field.key}"]`)) {
alert(`字段 "${field.label}" 已存在,请勿重复插入。`); alert(`字段 "${field.label}" 已存在,请勿重复插入。`);
return; return;
} }
pushHistory(); pushHistory();
let html = ''; 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>&#8203;`;
if (field.type === 'image') {
const id = 'ph_' + Date.now();
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" data-bind="${field.key}" contenteditable="false" style="display:inline-block;vertical-align:middle;"><span class="delete-btn" contenteditable="false">×</span><p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p></div>&#8203;`;
} else {
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>&#8203;`;
}
const sel = window.getSelection(); const sel = window.getSelection();
if (sel && sel.rangeCount > 0) { if (sel && sel.rangeCount > 0) {
@@ -502,13 +486,21 @@ export default function TemplateManage() {
const insertImage = () => { const insertImage = () => {
editorRef.current?.focus(); editorRef.current?.focus();
restoreSelection(); restoreSelection();
const widthStr = prompt('请输入占位符最大宽度 (px),留空无限制:\n(提示:正文一行文字高度约为 20 像素左右)', '');
const heightStr = prompt('请输入占位符最大高度 (px),留空无限制:', '');
const width = parseInt(widthStr || '0');
const height = parseInt(heightStr || '0');
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 += ` max-width:${width}px;`;
if (height > 0) styleStr += ` max-height:${height}px;`;
if (!width && !height) styleStr += ' padding:8px 16px;';
const showShortText = width > 0 && width < 80;
const hintText = showShortText ? '插入图片' : '插入/点击放置图片';
const id = 'ph_' + Date.now(); const id = 'ph_' + Date.now();
const html = ` const html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><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;">${hintText}</span></span>&#8203;`;
<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false">
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
</div>
`;
pushHistory(); pushHistory();
execCmd('insertHTML', html); execCmd('insertHTML', html);
}; };
@@ -798,7 +790,7 @@ export default function TemplateManage() {
<div className="flex-1 overflow-y-auto p-4 space-y-3"> <div className="flex-1 overflow-y-auto p-4 space-y-3">
{fieldLibTab === 'insert' && ( {fieldLibTab === 'insert' && (
<div className="space-y-4"> <div className="space-y-4">
{['填空', '单选', '多选', '时间', '图片'].map(cat => { {['填空', '单选', '多选', '时间'].map(cat => {
const catFields = formFields.filter(f => f.category === cat); const catFields = formFields.filter(f => f.category === cat);
if (catFields.length === 0) return null; if (catFields.length === 0) return null;
return ( return (
@@ -829,7 +821,7 @@ export default function TemplateManage() {
{fieldLibTab === 'manage' && ( {fieldLibTab === 'manage' && (
<div className="space-y-3"> <div className="space-y-3">
{['填空', '单选', '多选', '时间', '图片'].map(cat => { {['填空', '单选', '多选', '时间'].map(cat => {
const catFields = formFields.filter(f => f.category === cat); const catFields = formFields.filter(f => f.category === cat);
if (catFields.length === 0) return null; if (catFields.length === 0) return null;
const expanded = expandedCategories.includes(cat); const expanded = expandedCategories.includes(cat);
@@ -868,7 +860,7 @@ export default function TemplateManage() {
/> />
)} )}
</div> </div>
{['单选', '多选', '图片'].includes(field.category) && ( {['单选', '多选'].includes(field.category) && (
<input <input
type="text" type="text"
value={editFieldOptions} value={editFieldOptions}
@@ -963,7 +955,6 @@ export default function TemplateManage() {
if (cat === '单选') t = 'single_select'; if (cat === '单选') t = 'single_select';
else if (cat === '多选') t = 'multi_select'; else if (cat === '多选') t = 'multi_select';
else if (cat === '时间') t = 'date'; else if (cat === '时间') t = 'date';
else if (cat === '图片') t = 'image';
setNewFieldForm({ ...newFieldForm, category: cat, type: t }); setNewFieldForm({ ...newFieldForm, category: cat, type: t });
}} }}
className="flex-1 px-2 py-1.5 text-xs border border-border rounded focus:outline-hidden focus:border-accent bg-white" className="flex-1 px-2 py-1.5 text-xs border border-border rounded focus:outline-hidden focus:border-accent bg-white"
@@ -972,7 +963,6 @@ export default function TemplateManage() {
<option value="单选"></option> <option value="单选"></option>
<option value="多选"></option> <option value="多选"></option>
<option value="时间"></option> <option value="时间"></option>
<option value="图片"></option>
</select> </select>
<select <select
value={newFieldForm.type} value={newFieldForm.type}
@@ -983,7 +973,6 @@ export default function TemplateManage() {
{newFieldForm.category === '单选' && <option value="single_select"></option>} {newFieldForm.category === '单选' && <option value="single_select"></option>}
{newFieldForm.category === '多选' && <option value="multi_select"></option>} {newFieldForm.category === '多选' && <option value="multi_select"></option>}
{newFieldForm.category === '时间' && <><option value="date"></option><option value="time"></option></>} {newFieldForm.category === '时间' && <><option value="date"></option><option value="time"></option></>}
{newFieldForm.category === '图片' && <option value="image"></option>}
</select> </select>
</div> </div>
{['单选', '多选'].includes(newFieldForm.category) && ( {['单选', '多选'].includes(newFieldForm.category) && (
@@ -1054,6 +1043,67 @@ export default function TemplateManage() {
</div> </div>
</div> </div>
)} )}
{imagePickerOpen && imagePickerTarget && (
<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">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<button
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (ev) => {
const file = (ev.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const src = event.target?.result as string;
fillPlaceholderSrc(imagePickerTarget, src);
setImagePickerOpen(false);
setImagePickerTarget(null);
};
reader.readAsDataURL(file);
}
};
input.click();
}}
className="w-full py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded text-sm font-semibold"
></button>
<button
onClick={() => {
if (currentUser?.signature) {
fillPlaceholderSrc(imagePickerTarget, currentUser.signature);
}
setImagePickerOpen(false);
setImagePickerTarget(null);
}}
disabled={!currentUser?.signature}
className={`w-full py-2 rounded text-sm font-semibold ${currentUser?.signature ? 'bg-slate-100 hover:bg-slate-200 text-slate-700' : 'bg-slate-50 text-slate-400 cursor-not-allowed'}`}
> {!currentUser?.signature && '(未上传)'}</button>
<div>
<div className="text-xs text-slate-500 mb-2"></div>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{imageAssets.map(asset => (
<button
key={asset.id}
onClick={() => { fillPlaceholderSrc(imagePickerTarget, asset.dataUrl); setImagePickerOpen(false); setImagePickerTarget(null); }}
className="w-16 h-16 border rounded overflow-hidden hover:ring-2 ring-accent"
>
<img src={asset.dataUrl} alt={asset.name} className="w-full h-full object-cover" />
</button>
))}
{imageAssets.length === 0 && <div className="text-xs text-slate-400"></div>}
</div>
</div>
</div>
<div className="mt-5 flex justify-end">
<button onClick={() => { setImagePickerOpen(false); setImagePickerTarget(null); }} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm"></button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -137,6 +137,4 @@ export const DEFAULT_FORM_FIELDS: FormField[] = [
{ key: 'pathologyCheck', label: '是否送病理检查', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['是', '否'] }, { 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: 'frozenPathology', label: '冰冻病理结果', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['未见恶性', '待石蜡'] },
{ key: 'isSigned', label: '手术者签名确认', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['已签字', '未签字'] }, { key: 'isSigned', label: '手术者签名确认', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['已签字', '未签字'] },
{ key: 'surgeonSignature', label: '手术者签名', category: '图片', type: 'signature', visibleInForm: true, isSystemLocked: false },
{ key: 'hospitalLogo', label: '医院Logo', category: '图片', type: 'image', visibleInForm: true, isSystemLocked: true },
]; ];

View File

@@ -3,10 +3,10 @@ const smartField = (key: string) => `<span class="smart-field-wrapper" contented
export const defaultReportContent = ` export const defaultReportContent = `
<!-- 医院Logo --> <!-- 医院Logo -->
<p style="text-align: center; margin-bottom: 16px;" contenteditable="false"> <p style="text-align: center; margin-bottom: 16px;" contenteditable="false">
<div class="image-placeholder" data-placeholder="true" data-bind="hospitalLogo" contenteditable="false" style="width: 65px; margin: 0 auto;"> <span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:65px;height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 auto;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span> <span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p> <span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入图片</span>
</div> </span>
</p> </p>
<!-- 医院名称 --> <!-- 医院名称 -->
@@ -151,7 +151,7 @@ export const defaultReportContent = `
</p> </p>
<p style="font-family: SimSun;"> <p style="font-family: SimSun;">
手术者签名:${smartField('surgeonSignature')} 手术者签名:<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>
</p> </p>
<p style="text-align: right; font-family: SimSun; color: #bdbdbd;"> <p style="text-align: right; font-family: SimSun; color: #bdbdbd;">

View File

@@ -0,0 +1,154 @@
# 实现方案 — 2026-04-17-19-26-17
## 变更文件
1. `src/types.ts`
2. `src/utils/defaultContent.ts`
3. `src/pages/TemplateManage.tsx`
4. `src/pages/ReportEditor.tsx`
---
## 一、types.ts 修改
`DEFAULT_FORM_FIELDS` 中移除以下两个字段:
- `surgeonSignature`
- `hospitalLogo`
同时从 `FieldType` 中移除 `'image'`(因为图片不再作为可插入的字段类型,仅作为占位符存在)。若移除 `'image'` 会导致大量类型错误,也可保留类型但不在 UI 中暴露。为最小侵入,保留 `'image'` 类型但不再在 `DEFAULT_FORM_FIELDS` 中使用。
实际执行:删除 `DEFAULT_FORM_FIELDS` 中的最后两项(`surgeonSignature``hospitalLogo`)。
---
## 二、defaultContent.ts 修改
### 2.1 医院Logo
将原有的 `div.image-placeholder` 替换为 `span.image-placeholder`(保持居中):
```html
<!-- 医院Logo -->
<p style="text-align: center; margin-bottom: 16px;" contenteditable="false">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:65px;height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 auto;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>
```
### 2.2 手术者签名
`手术者签名:${smartField('surgeonSignature')}` 替换为:
```html
<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>
</p>
```
---
## 三、TemplateManage.tsx 修改
### 3.1 移除"图片"分类暴露
- `expandedCategories` 初始值从 `['填空','单选','多选','时间','图片']` 改为 `['填空','单选','多选','时间']`
- "插入字段"Tab 的遍历数组从 `['填空','单选','多选','时间','图片']` 改为 `['填空','单选','多选','时间']`
- "字段管理"Tab 的遍历数组同样移除 `'图片'`
- 新增字段表单的 category select 中移除 `<option value="图片">图片</option>`
- type select 的条件渲染中移除图片相关的 option。
### 3.2 改造 insertImage()
将现有的 `insertImage()` 替换为:
```typescript
const insertImage = () => {
const widthStr = prompt('请输入占位符最大宽度 (px),留空无限制:\n(提示:正文一行文字高度约为 20 像素左右)', '');
const heightStr = prompt('请输入占位符最大高度 (px),留空无限制:', '');
const width = parseInt(widthStr || '0');
const height = parseInt(heightStr || '0');
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 += ` max-width:${width}px;`;
if (height > 0) styleStr += ` max-height:${height}px;`;
if (!width && !height) styleStr += ' padding:8px 16px;';
const showShortText = width > 0 && width < 80;
const hintText = showShortText ? '插入图片' : '插入/点击放置图片';
const id = 'ph_' + Date.now();
const html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><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;">${hintText}</span></span>&#8203;`;
editorRef.current?.focus();
restoreSelection();
pushHistory();
execCmd('insertHTML', html);
};
```
### 3.3 统一图片源选择弹窗
`ReportEditor.tsx` 复用以下逻辑到 `TemplateManage.tsx`
- 新增状态:`imagePickerOpen``imagePickerTarget``imageAssets`(已存在)。
- 新增 `fillPlaceholderSrc` 函数。
- 修改 `handleEditorClick` 中的 placeholder 点击逻辑:
```typescript
if (!placeholder.classList.contains('has-image')) {
e.preventDefault();
e.stopPropagation();
setImagePickerTarget(placeholder);
setImagePickerOpen(true);
}
```
- 删除原有的 `triggerPlaceholderUpload` 函数及其直接调用。
- 在 JSX 底部(`isModalOpen` 弹窗之后)新增 `imagePickerOpen` 弹窗组件(与 ReportEditor 完全一致)。
### 3.4 清理删除后的重置逻辑
当 placeholder 被删除(点击 × 后)时,重置为:
```html
<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>
```
同时保留原有内联样式(避免把 `inline-flex` 等样式清掉)。
---
## 四、ReportEditor.tsx 修改
### 4.1 改造 insertImage()
与 TemplateManage 保持一致:
```typescript
const insertImage = () => {
editorRef.current?.focus();
const widthStr = prompt('请输入占位符最大宽度 (px),留空无限制:\n(提示:正文一行文字高度约为 20 像素左右)', '');
const heightStr = prompt('请输入占位符最大高度 (px),留空无限制:', '');
const width = parseInt(widthStr || '0');
const height = parseInt(heightStr || '0');
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 += ` max-width:${width}px;`;
if (height > 0) styleStr += ` max-height:${height}px;`;
if (!width && !height) styleStr += ' padding:8px 16px;';
const showShortText = width > 0 && width < 80;
const hintText = showShortText ? '插入图片' : '插入/点击放置图片';
const id = 'ph_' + Date.now();
const html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><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;">${hintText}</span></span>&#8203;`;
execCmd('insertHTML', html);
};
```
### 4.2 删除后重置逻辑
在 `handleEditorClick` 中placeholder 删除后重置的 HTML 改为使用 `<span>` 结构并保留内联样式:
```typescript
placeholder.innerHTML = `
<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>
`;
```
### 4.3 fillPlaceholderSrc 保持兼容
已有 `fillPlaceholderSrc` 可继续使用,但建议填充的图片增加 `max-width: 100%; max-height: 100%; object-fit: contain;`。
---
## 回滚策略
修改前最新提交为 `0c57409`。若失败可 `git reset --hard 0c57409`。
## 无新增 npm 依赖

View File

@@ -0,0 +1,71 @@
# 测试方案 — 2026-04-17-19-26-17
## 测试目标
验证 6 项需求全部正确实现,且不破坏现有编辑、保存、打印功能。
---
## 测试步骤
### 1. 编译检查
```bash
npm run lint
```
- **预期**`tsc --noEmit` 通过0 errors。
---
### 2. 字段体系清理(需求 1
1. 进入 `/template-manage`
2. 在"插入字段"Tab 中,确认分类列表只有"填空、单选、多选、时间"**没有"图片"**。
3. 在"字段管理"Tab 中,确认同样没有"图片"分组。
4. 在"字段管理 → 新增字段"中,确认 category select 没有"图片"选项type select 也不会出现"图片"。
---
### 3. 默认模板占位符替换(需求 4
1. 重新加载默认模板(或清空 localStorage 后重新登录)。
2. 确认模板顶部 Logo 处显示为一个虚线框占位符(而非直接显示医院 Logo 图片)。
3. 确认"手术者签名:"后方显示为一个虚线框占位符,而非 `smart-field-wrapper` 文本框。
---
### 4. 插入图片占位符同行与尺寸设置(需求 2、5
1.`/template-manage` 编辑器中,将光标放在一段文字中间,点击工具栏"插入图片占位符"。
2. 在 prompt 中输入宽度 `120`,高度 `60`
3. 确认占位符插入后与前后文字**保持在同一行**,没有换行。
4. 使用浏览器 DevTools 检查该占位符,确认 `style` 中包含 `display:inline-flex``max-width:120px; max-height:60px;`
5.`/report-editor` 中重复上述操作,确认行为一致。
6. 测试留空宽高:确认插入的占位符没有 `max-width/max-height`,但有默认的 `padding: 8px 16px;`
---
### 5. 占位符文字自适应(需求 3
1. 插入一个宽度为 `60px` 的图片占位符。
2. 确认占位符内显示的文字是**"插入图片"**(而非"插入/点击放置图片")。
3. 插入一个宽度为 `120px` 的占位符,确认显示"插入/点击放置图片"。
---
### 6. 图片来源选择弹窗统一(需求 6
1.`/template-manage` 中,点击任意无图片的 `image-placeholder`
2. 确认弹出"选择图片来源"弹窗,包含"本地上传"、"我的签名"、"系统素材"三个选项。
3. 选择"系统素材"中的医院 Logo确认占位符被替换为 Logo 图片。
4.`/report-editor` 中点击占位符,确认弹窗行为与 `/template-manage` 完全一致。
5. 测试弹窗中的"取消"按钮,确认点击后弹窗关闭且占位符未被修改。
---
### 7. 回归测试
1. **保存模板**:修改模板后点击保存,刷新页面确认内容不丢失。
2. **保存报告**:在 `/report-editor` 中填写表单并保存草稿,确认内容持久化。
3. **打印预览**:确认图片占位符(已填充和未填充)在打印预览中显示正常。
4. **撤销重做**:插入占位符后按 `Ctrl+Z`,确认占位符被正确撤销。
5. **拖拽/自动插入关键帧**:确认 `/report-editor` 中的视频关键帧仍能正常插入到图片占位符中。
---
## 判定标准
全部测试通过后方可认为任务完成。若任何测试失败,需回滚并重新分析根因。

View File

@@ -610,3 +610,35 @@ ange.insertNode(fragment) 精确插入到 Range 位置;
- 图片字段与普通文本字段的 DOM 结构差异大,插入逻辑需要按 type 分支。 - 图片字段与普通文本字段的 DOM 结构差异大,插入逻辑需要按 type 分支。
- 编辑器与侧边栏联动建议使用 `scrollIntoView` + 临时 CSS 类,避免复杂的状态同步。 - 编辑器与侧边栏联动建议使用 `scrollIntoView` + 临时 CSS 类,避免复杂的状态同步。
- 新增 localStorage key 时应提供合理的默认值或降级处理。 - 新增 localStorage key 时应提供合理的默认值或降级处理。
---
## 记录 23图片占位符体系重构与双端统一
**A. 具体问题**
1. `template-manage` 的"插入字段"中仍存在"图片"分类手术者签名、医院Logo用户认为不再需要。
2. 插入图片占位符时无法自定义默认宽高,且使用 `<div>` 导致强制换行。
3. 占位符框太小时"插入/点击放置图片"文字显示不全。
4. 默认模板中签名和 Logo 的结构不统一(一个是 `smartField`,一个是 `div.image-placeholder`)。
5. `template-manage` 点击图片占位符直接调起本地文件选择器,与 `report-editor` 的三选一弹窗行为不一致。
**B. 问题产生原因**
1. `DEFAULT_FORM_FIELDS` 仍包含 `surgeonSignature` 和 `hospitalLogo`。
2. 两端编辑器的 `insertImage()` 使用块级 `<div>` 插入,未提供尺寸 prompt。
3. 占位符提示文本固定为长文本,未根据容器宽度做缩写适配。
4. `TemplateManage` 的 placeholder 点击事件直接调用 `triggerPlaceholderUpload()`,缺少与 `ReportEditor` 一致的弹窗组件。
**C. 解决问题方法**
1. **清理图片字段**:从 `DEFAULT_FORM_FIELDS` 和 `types.ts` 中移除 `surgeonSignature` 和 `hospitalLogo`;在 `TemplateManage.tsx` 的插入字段/字段管理/新增字段表单中彻底移除"图片"分类。
2. **统一默认模板**:在 `defaultContent.ts` 中将 Logo 和签名均替换为 `<span class="image-placeholder" style="display:inline-flex;...">`。
3. **改造 insertImage()**:在 `TemplateManage.tsx` 和 `ReportEditor.tsx` 中,插入前通过 `prompt` 获取最大宽度/高度px生成带 `max-width/max-height` 的 `<span>` 行内占位符;提示文字中附加"正文一行文字高度约为 20 像素左右"。
4. **文本自适应**:根据 prompt 输入的宽度决定提示文字:宽度 < 80px 时显示"插入图片",否则显示"插入/点击放置图片"。
5. **统一弹窗行为**:将 `ReportEditor` 的 `imagePickerOpen` / `imagePickerTarget` / `fillPlaceholderSrc` 逻辑完整移植到 `TemplateManage`;删除旧的 `triggerPlaceholderUpload` 直接上传逻辑;两端点击图片占位符均弹出"本地上传 / 我的签名 / 系统素材"三选一弹窗。
6. **优化填充样式**`fillPlaceholderSrc` 中给 `<img>` 增加 `max-width:100%; max-height:100%; object-fit:contain;`,避免撑破设置了固定尺寸的占位符。
**D. 经验与教训总结**
- 当从字段体系中彻底移除某一分类时,需要同时清理:`DEFAULT_FORM_FIELDS`、UI 渲染数组、新增表单 options、以及可能残留的分类判断逻辑如编辑字段时显示 options 输入框的条件)。
- 在 `contentEditable` 中实现"同行插入"必须使用行内元素(`<span>`)并显式设置 `display:inline-flex` + `vertical-align:middle`;块级 `<div>` 即使通过 CSS 改 display 也可能因浏览器 execCommand 修正导致换行。
- 跨页面/跨编辑器的一致交互(如图片选择弹窗)应抽取为可复用逻辑或至少保持代码结构一致,避免用户在不同页面产生认知割裂。
- `prompt` 虽不是最优雅的用户交互,但在工具栏快捷操作中是一种零依赖、快速落地的方案;若后续需要更复杂交互,可再替换为 Modal 组件。

View File

@@ -0,0 +1,62 @@
# 需求分析 — 2026-04-17-19-26-17
## 用户反馈的 6 项需求
### 1. 移除插入字段中的"图片"类型字段
`template-manage` 的"插入字段"Tab 中,"图片"分类包含手术者签名、医院Logo不再需要。需要
- 从插入字段的分类列表中移除"图片"
-`DEFAULT_FORM_FIELDS` 中移除 `surgeonSignature``hospitalLogo`
- 清理 `TemplateManage.tsx` 中与图片类型字段相关的分类渲染逻辑(如折叠分组、新增字段表单中的"图片"选项等)。
### 2. 细化"插入图片占位符"功能(支持自定义默认宽高)
`template-manage``report-editor` 中,点击工具栏的"插入图片占位符"按钮时:
- 弹出提示框让用户输入默认最大宽度px和最大高度px留空则表示无限制
- 提示文字中附加说明:"一个文字高度约为 20 像素左右"
- 将用户输入的宽高写入占位符的 `style` 属性中(`max-width` / `max-height`)。
### 3. 占位符文字自适应缩写
`class="image-placeholder"` 中的提示文字,如果占位符框太小(通过宽度 < 80px 判断),则将 "插入/点击放置图片" 缩写为 "插入图片"。
### 4. 手术者签名、医院Logo 改用图片占位符
`defaultContent.ts` 中:
-`smartField('surgeonSignature')` 替换为行内 `<span class="image-placeholder">`
- 将顶部的 `hospitalLogo` 占位符替换为标准的 `<span class="image-placeholder">`(保持居中)。
### 5. 插入图片占位符时保持同行
当前使用 `<div>` 插入图片占位符会导致强制换行。需要:
- 将占位符的容器标签从 `<div>` 改为 `<span>`
- 设置 `display: inline-flex` + `vertical-align: middle`
- 确保插入后与前后文字保持在同一行。
### 6. 统一两个编辑器的图片占位符点击行为
`template-manage` 中点击图片占位符时目前直接调起本地文件选择器。需要将其改为与 `report-editor` 一致的行为:
- 弹出"图片来源选择器"弹窗;
- 支持"本地上传"、"我的签名"、"系统素材"三种来源;
-`ReportEditor` 中的弹窗逻辑复用到 `TemplateManage` 中。
---
## 影响范围
- `src/types.ts`:移除 `surgeonSignature``hospitalLogo` 字段(或将其从字段配置中移除)。
- `src/utils/defaultContent.ts`:签名和 Logo 位置替换为 `image-placeholder`
- `src/pages/TemplateManage.tsx`
- 移除"图片"分类相关渲染和新增字段表单选项;
- 改造 `insertImage()` 支持 prompt 输入宽高;
- 新增图片来源选择弹窗状态和填充逻辑;
- 移除 `triggerPlaceholderUpload` 的直接调用。
- `src/pages/ReportEditor.tsx`
- 改造 `insertImage()` 支持 prompt 输入宽高;
- 保持现有的图片来源选择弹窗逻辑不变。
---
## 验收标准
1. `template-manage` 的"插入字段"和"字段管理"中不再出现"图片"分类和相关字段。
2. `defaultContent.ts` 中签名和 Logo 均为 `image-placeholder`
3. 点击"插入图片占位符"时弹出宽高 prompt正确应用 `max-width` / `max-height`
4. 图片占位符使用 `<span>` + `display: inline-flex`,插入后与文字同行。
5. 宽度 < 80px 的占位符显示"插入图片",否则显示"插入/点击放置图片"。
6. `template-manage``report-editor` 点击图片占位符均弹出三选一图片来源弹窗。
7. `npm run lint` 通过。