feat: field hover highlight, e-signature upload, surgeon signature linkage

- Add signature?: string to User type and 'signature' to FieldType
- Add surgeonSignature field to DEFAULT_FORM_FIELDS (category: 图片)
- UserManage: add canvas-based image compression (max 500px) and signature upload UI
- TemplateManage: add hover highlight on field buttons via direct DOM style manipulation
- TemplateManage: add '图片' category to field library for surgeonSignature insertion
- ReportEditor: auto-fill surgeonSignature with currentUser.signature image or placeholder text
- index.css & print.ts: add .report-signature-img styling (height 2.4em, vertical-align middle)
- Update experience record (#18)
This commit is contained in:
2026-04-17 12:04:23 +08:00
parent 0ff1cbe5f0
commit 424407a17e
10 changed files with 445 additions and 4 deletions

View File

@@ -156,6 +156,13 @@
.template-editor-mode .smart-field-wrapper:focus-within .delete-btn {
display: block;
}
.report-signature-img {
height: 2.4em;
width: auto;
vertical-align: middle;
display: inline-block;
margin: -0.3em 0;
}
}
@media print {
@@ -195,4 +202,11 @@
.print-content .smart-field-wrapper .delete-btn {
display: none !important;
}
.report-signature-img {
height: 2.4em !important;
width: auto !important;
vertical-align: middle !important;
display: inline-block !important;
margin: -0.3em 0 !important;
}
}

View File

@@ -940,6 +940,25 @@ export default function ReportEditor() {
const el = node as HTMLElement;
const fieldKey = el.getAttribute('data-bind')!;
if (fieldKey === 'surgeonSignature') {
const signatureData = currentUser?.signature;
if (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 {
if (el.innerText !== '【请上传电子签】') {
el.innerText = '【请上传电子签】';
el.style.border = '';
el.style.backgroundColor = '';
}
}
return;
}
let newValue = '';
if (fieldKey === 'startTime') {
newValue = `${reportData.startHour || ''}:${reportData.startMinute || ''}`;

View File

@@ -254,6 +254,20 @@ export default function TemplateManage() {
editorRef.current?.focus();
};
const highlightField = (key: string, active: boolean) => {
if (!editorRef.current) return;
const el = editorRef.current.querySelector(`[data-bind="${key}"]`) as HTMLElement | null;
if (!el) return;
if (active) {
el.style.transition = 'all 0.2s';
el.style.boxShadow = '0 0 0 2px #3b82f6';
el.style.backgroundColor = '#e0f2fe';
} else {
el.style.boxShadow = '';
el.style.backgroundColor = '';
}
};
const toggleFieldVisible = (key: string) => {
const updated = formFields.map(f => f.key === key ? { ...f, visibleInForm: !f.visibleInForm } : f);
setFormFields(updated);
@@ -601,7 +615,7 @@ export default function TemplateManage() {
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{fieldLibTab === 'insert' && (
<div className="space-y-4">
{['填空', '单选', '多选', '时间'].map(cat => {
{['填空', '单选', '多选', '时间', '图片'].map(cat => {
const catFields = formFields.filter(f => f.category === cat);
if (catFields.length === 0) return null;
return (
@@ -613,6 +627,8 @@ export default function TemplateManage() {
key={field.key}
type="button"
onClick={() => insertSmartField(field)}
onMouseEnter={() => highlightField(field.key, true)}
onMouseLeave={() => highlightField(field.key, false)}
className="px-2 py-1 text-[11px] bg-slate-100 hover:bg-slate-200 text-slate-700 rounded border border-slate-300 transition-colors"
title={`插入 ${field.label}`}
>

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import { UserPlus, Edit, Trash2 } from 'lucide-react';
import { UserPlus, Edit, Trash2, Upload, X } from 'lucide-react';
import { User, Template } from '../types';
import { storage } from '../utils/storage';
@@ -56,6 +56,50 @@ export default function UserManage() {
storage.set('users', updatedUsers);
};
const compressImage = (file: File, maxSize: number = 500): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (e) => {
const img = new Image();
img.src = e.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
let { width, height } = img;
if (width > height && width > maxSize) {
height = Math.round((height * maxSize) / width);
width = maxSize;
} else if (height > maxSize) {
width = Math.round((width * maxSize) / height);
height = maxSize;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
}
resolve(canvas.toDataURL('image/jpeg', 0.8));
};
img.onerror = reject;
};
reader.onerror = reject;
});
};
const handleSignatureUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const compressed = await compressImage(file);
setFormData(prev => ({ ...prev, signature: compressed }));
} catch {
alert('图片压缩失败,请重试');
}
};
const handleDelete = (username: string) => {
if (username === 'admin') {
alert('不能删除默认超级管理员');
@@ -226,7 +270,7 @@ export default function UserManage() {
updatedUsers = users.map(u => {
if (u.username === formData.username) {
return { ...u, role: finalRole, department: finalDepartment, manageableTemplates, visibleTemplates: adminVisible, password: formData.password || u.password } as User;
return { ...u, role: finalRole, department: finalDepartment, manageableTemplates, visibleTemplates: adminVisible, password: formData.password || u.password, signature: formData.signature } as User;
}
if (u.role === 'user' && u.department === (oldUser.department || finalDepartment)) {
const currentVisible = Array.isArray(u.visibleTemplates) ? u.visibleTemplates : [];
@@ -568,6 +612,43 @@ export default function UserManage() {
</div>
)}
<div className="space-y-2">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"></label>
{formData.signature ? (
<div className="flex items-center gap-3">
<img
src={formData.signature}
alt="电子签名预览"
className="h-16 border border-border rounded bg-white object-contain"
/>
<div className="flex flex-col gap-2">
<label className="px-3 py-1.5 text-xs font-medium rounded-lg bg-slate-100 hover:bg-slate-200 transition-colors cursor-pointer inline-flex items-center gap-1">
<Upload size={12} />
<input type="file" accept="image/*" className="hidden" onChange={handleSignatureUpload} />
</label>
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, signature: undefined }))}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors inline-flex items-center gap-1"
>
<X size={12} />
</button>
</div>
</div>
) : (
<div className="flex items-center gap-3">
<label className="px-4 py-2 text-sm font-medium rounded-lg bg-slate-100 hover:bg-slate-200 transition-colors cursor-pointer inline-flex items-center gap-2">
<Upload size={14} />
<input type="file" accept="image/*" className="hidden" onChange={handleSignatureUpload} />
</label>
<span className="text-xs text-text-muted"> JPGPNG 500px </span>
</div>
)}
</div>
{showManageableTemplates && (
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">

View File

@@ -10,6 +10,7 @@ export interface User {
createdAt?: string;
visibleTemplates?: string[];
manageableTemplates?: string[];
signature?: string;
}
export interface Report {
@@ -102,7 +103,7 @@ export const BINDABLE_FIELDS: BindableField[] = [
{ key: 'anesthesiaType', label: '麻醉方式' },
];
export type FieldType = 'text' | 'single_select' | 'multi_select' | 'time' | 'date';
export type FieldType = 'text' | 'single_select' | 'multi_select' | 'time' | 'date' | 'signature';
export interface FormField {
key: string;
@@ -129,4 +130,5 @@ export const DEFAULT_FORM_FIELDS: FormField[] = [
{ 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: ['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉'] },
{ key: 'surgeonSignature', label: '手术者签名', category: '图片', type: 'signature', visibleInForm: false, isSystemLocked: true },
];

View File

@@ -37,6 +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 .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; }
.report-signature-img { height: 2.4em; width: auto; vertical-align: middle; display: inline-block; margin: -0.3em 0; }
@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; }
}