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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 || ''}`;
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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">支持 JPG、PNG,自动压缩至 500px 以内</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showManageableTemplates && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user