Files
Mdeical_Sur_Report/src/pages/TemplateManage.tsx

1253 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import { Plus, Edit, Trash2, Save, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, Check } from 'lucide-react';
import { User, Template, FormField, FieldType, DEFAULT_FORM_FIELDS } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
import { printDocument } from '../utils/print';
import { storage } from '../utils/storage';
export default function TemplateManage() {
const navigate = useNavigate();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [templates, setTemplates] = useState<Template[]>([]);
const [currentTemplateId, setCurrentTemplateId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({ name: '', desc: '' });
const [isSaved, setIsSaved] = useState(false);
const editorRef = useRef<HTMLDivElement>(null);
const savedRangeRef = useRef<Range | null>(null);
const undoStack = useRef<string[]>([]);
const redoStack = useRef<string[]>([]);
const [fieldLibTab, setFieldLibTab] = useState<'insert' | 'manage'>('insert');
const [formFields, setFormFields] = useState<FormField[]>([]);
const [newFieldForm, setNewFieldForm] = useState({ label: '', category: '填空', type: 'text' as FieldType });
const [newFieldOptions, setNewFieldOptions] = useState('');
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 [editingFieldKey, setEditingFieldKey] = useState<string | null>(null);
const [editFieldLabel, setEditFieldLabel] = useState('');
const [editFieldOptions, setEditFieldOptions] = useState('');
const [editFieldTimeFormat, setEditFieldTimeFormat] = useState('');
const [editFieldTimeDefault, setEditFieldTimeDefault] = useState<'current' | 'specific'>('specific');
const [editFieldFixedTimeValue, setEditFieldFixedTimeValue] = useState('');
const [newFieldTimeFormat, setNewFieldTimeFormat] = useState('YYYY年MM月DD日');
const [newFieldTimeDefault, setNewFieldTimeDefault] = useState<'current' | 'specific'>('specific');
const [newFieldFixedTimeValue, setNewFieldFixedTimeValue] = useState('');
const [customTimeFormats, setCustomTimeFormats] = useState<string[]>([]);
const [imageAssets, setImageAssets] = useState<{ id: string; name: string; dataUrl: string }[]>([]);
const updatePageHeight = () => {
if (!editorRef.current) return;
const contentHeight = editorRef.current.scrollHeight;
const pageHeightMm = 297;
const mmToPx = 3.7795275591;
const pages = Math.max(2, Math.ceil(contentHeight / (pageHeightMm * mmToPx)));
editorRef.current.style.minHeight = `${pages * pageHeightMm}mm`;
};
useEffect(() => {
const user = storage.get<User | null>('currentUser', null);
if (!user || user.role === 'user') {
navigate('/dashboard');
return;
}
setCurrentUser(user);
const savedFields = storage.get<FormField[]>('formFieldsConfig', []);
if (savedFields.length > 0) {
setFormFields(savedFields);
} else {
setFormFields(DEFAULT_FORM_FIELDS);
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
}
const savedAssets = storage.get<{ id: string; name: string; dataUrl: string }[]>('imageAssets', []);
if (savedAssets.length > 0) {
setImageAssets(savedAssets);
} else {
fetch('/logo_square.png')
.then((res) => res.blob())
.then((blob) => {
const reader = new FileReader();
reader.onloadend = () => {
const dataUrl = reader.result as string;
const initialAssets = [{ id: 'asset_logo', name: '医院Logo', dataUrl }];
setImageAssets(initialAssets);
storage.set('imageAssets', initialAssets);
};
reader.readAsDataURL(blob);
})
.catch(() => setImageAssets([]));
}
const savedFormats = storage.get<string[]>('customTimeFormats', []);
const defaultFormats = ['YYYY-MM-DD', 'YYYY年MM月DD日', 'MM-DD', 'MM月DD日', 'HH:mm', 'hh:mm A'];
setCustomTimeFormats(Array.from(new Set([...defaultFormats, ...savedFormats])));
const savedTemplates = storage.get<Template[]>('templates', []);
if (savedTemplates.length === 0) {
const initial: Template = {
id: 'surgery',
name: '腹腔镜胆囊切除术报告',
desc: '标准手术记录模板',
content: defaultReportContent,
createdAt: new Date().toISOString(),
author: 'admin'
};
setTemplates([initial]);
storage.set('templates', [initial]);
setCurrentTemplateId(initial.id);
} else {
const manageable = user.role === 'super'
? savedTemplates.map(t => t.id)
: (Array.isArray(user.manageableTemplates) ? user.manageableTemplates : savedTemplates.map(t => t.id));
const filtered = savedTemplates.filter(t => manageable.includes(t.id));
setTemplates(filtered);
setCurrentTemplateId(filtered[0]?.id || null);
}
}, [navigate]);
useEffect(() => {
if (currentTemplateId && editorRef.current) {
const template = templates.find(t => t.id === currentTemplateId);
if (template) {
editorRef.current.innerHTML = template.content;
}
setTimeout(() => updatePageHeight(), 0);
}
}, [currentTemplateId, templates]);
useEffect(() => {
if (!editorRef.current) return;
const observer = new MutationObserver(() => {
updatePageHeight();
});
observer.observe(editorRef.current, { childList: true, subtree: true, attributes: true, characterData: true });
return () => observer.disconnect();
}, [currentUser]);
const fillPlaceholderSrc = (placeholder: HTMLElement, src: string) => {
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<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();
};
// Handle image placeholder and smart field delete interactions via click capture
useEffect(() => {
const handleEditorClick = (e: MouseEvent) => {
let node: Node | null = e.target as Node;
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
const targetEl = node as HTMLElement | null;
if (!targetEl) return;
const smartField = targetEl.closest('.smart-field-wrapper') as HTMLElement | null;
if (smartField && targetEl.closest('.delete-btn')) {
e.stopPropagation();
e.preventDefault();
pushHistory();
const sel = window.getSelection();
const range = document.createRange();
range.selectNode(smartField);
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand('delete');
saveTemplateContent();
return;
}
if (smartField) {
const valueSpan = smartField.querySelector('.field-value');
const fieldKey = valueSpan?.getAttribute('data-bind') || smartField.getAttribute('data-bind');
if (fieldKey) {
setActiveFieldKey(fieldKey);
const field = formFields.find(f => f.key === fieldKey);
if (field) {
setExpandedCategories(prev => prev.includes(field.category) ? prev : [...prev, field.category]);
setTimeout(() => {
const el = document.getElementById(`sidebar-field-${fieldKey}`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 50);
}
}
return;
}
const placeholder = targetEl.closest('.image-placeholder') as HTMLElement | null;
if (!placeholder) return;
if (targetEl.closest('.delete-btn')) {
e.stopPropagation();
e.preventDefault();
pushHistory();
if (placeholder.classList.contains('has-image')) {
placeholder.classList.remove('has-image');
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>
`;
placeholder.style.border = '1px dashed #cbd5e1';
placeholder.style.background = '#f8fafc';
} else {
const range = document.createRange();
range.selectNode(placeholder);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand('delete');
}
return;
}
if (!placeholder.classList.contains('has-image')) {
e.preventDefault();
e.stopPropagation();
setImagePickerTarget(placeholder);
setImagePickerOpen(true);
}
};
const editor = editorRef.current;
if (editor) {
editor.addEventListener('click', handleEditorClick, true);
}
return () => {
if (editor) {
editor.removeEventListener('click', handleEditorClick, true);
}
};
}, [currentTemplateId, currentUser, formFields]);
// Intercept Backspace/Delete next to smart fields to avoid whole-line deletion
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
e.preventDefault();
if (e.shiftKey) {
handleRedo();
} else {
handleUndo();
}
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') {
e.preventDefault();
handleRedo();
return;
}
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
const sel = window.getSelection();
if (!sel || !sel.isCollapsed || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
const node = range.startContainer;
const offset = range.startOffset;
let target: Element | null = null;
if (node.nodeType === Node.TEXT_NODE) {
if (e.key === 'Backspace' && offset === 0) {
const prev = node.previousSibling;
if (prev && prev.nodeType === Node.ELEMENT_NODE && (prev as Element).classList?.contains('smart-field-wrapper')) {
target = prev as Element;
}
} else if (e.key === 'Delete' && offset === (node.textContent?.length || 0)) {
const next = node.nextSibling;
if (next && next.nodeType === Node.ELEMENT_NODE && (next as Element).classList?.contains('smart-field-wrapper')) {
target = next as Element;
}
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as Element;
if (e.key === 'Backspace' && offset > 0) {
const prev = el.childNodes[offset - 1];
if (prev && prev.nodeType === Node.ELEMENT_NODE && (prev as Element).classList?.contains('smart-field-wrapper')) {
target = prev as Element;
}
} else if (e.key === 'Delete' && offset < el.childNodes.length) {
const next = el.childNodes[offset];
if (next && next.nodeType === Node.ELEMENT_NODE && (next as Element).classList?.contains('smart-field-wrapper')) {
target = next as Element;
}
}
}
if (target) {
e.preventDefault();
pushHistory();
const sel = window.getSelection();
const range = document.createRange();
range.selectNode(target);
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand('delete');
saveTemplateContent();
}
};
editor.addEventListener('keydown', handleKeyDown, true);
return () => {
editor.removeEventListener('keydown', handleKeyDown, true);
};
}, [currentTemplateId]);
const saveSelection = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
savedRangeRef.current = sel.getRangeAt(0);
}
};
const restoreSelection = () => {
if (!savedRangeRef.current) return;
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(savedRangeRef.current);
};
const pushHistory = () => {
if (!editorRef.current) return;
undoStack.current.push(editorRef.current.innerHTML);
redoStack.current = [];
};
const handleUndo = () => {
if (undoStack.current.length === 0 || !editorRef.current) return;
redoStack.current.push(editorRef.current.innerHTML);
const prev = undoStack.current.pop();
if (prev !== undefined) {
editorRef.current.innerHTML = prev;
saveTemplateContent();
}
};
const handleRedo = () => {
if (redoStack.current.length === 0 || !editorRef.current) return;
undoStack.current.push(editorRef.current.innerHTML);
const next = redoStack.current.pop();
if (next !== undefined) {
editorRef.current.innerHTML = next;
saveTemplateContent();
}
};
const execCmd = (command: string, value: string | undefined = undefined) => {
if (command !== 'undo' && command !== 'redo') {
pushHistory();
}
editorRef.current?.focus();
document.execCommand(command, false, value);
editorRef.current?.focus();
};
const saveTemplateContent = () => {
if (!currentTemplateId || !editorRef.current) return;
const allTemplates = storage.get<Template[]>('templates', []);
const updated = allTemplates.map(t =>
t.id === currentTemplateId ? { ...t, content: editorRef.current!.innerHTML, updatedAt: new Date().toISOString() } : t
);
setTemplates(prevTemplates => prevTemplates.map(t => updated.find(u => u.id === t.id) || t));
storage.set('templates', updated);
};
const insertSmartField = (field: FormField) => {
editorRef.current?.focus();
restoreSelection();
if (editorRef.current?.querySelector(`[data-bind="${field.key}"]`)) {
alert(`字段 "${field.label}" 已存在,请勿重复插入。`);
return;
}
pushHistory();
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;`;
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.deleteContents();
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
const fragment = document.createDocumentFragment();
while (wrapper.firstChild) {
fragment.appendChild(wrapper.firstChild);
}
range.insertNode(fragment);
const lastNode = fragment.lastChild;
if (lastNode) {
const newRange = document.createRange();
newRange.setStartAfter(lastNode);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
}
}
editorRef.current?.focus();
saveTemplateContent();
};
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);
storage.set('formFieldsConfig', updated);
};
const deleteField = (key: string) => {
const updated = formFields.filter(f => f.key !== key);
setFormFields(updated);
storage.set('formFieldsConfig', updated);
};
const saveFieldEdit = (key: string) => {
const updated = formFields.map(f => {
if (f.key !== key) return f;
const next: FormField = { ...f };
if (!f.isSystemLocked) {
next.label = editFieldLabel.trim() || f.label;
}
if (['单选', '多选', '图片'].includes(f.category)) {
next.options = editFieldOptions.split(/[,]/).map(s => s.trim()).filter(Boolean);
}
if (f.category === '时间') {
next.timeFormat = editFieldTimeFormat;
next.timeDefault = editFieldTimeDefault;
next.fixedTimeValue = editFieldFixedTimeValue;
}
return next;
});
setFormFields(updated);
storage.set('formFieldsConfig', updated);
setEditingFieldKey(null);
};
const addField = () => {
if (!newFieldForm.label.trim()) return;
const key = 'custom_' + Date.now();
const newField: FormField = {
key,
label: newFieldForm.label.trim(),
category: newFieldForm.category,
type: newFieldForm.type,
visibleInForm: true,
isSystemLocked: false,
options: ['单选', '多选'].includes(newFieldForm.category) && newFieldOptions.trim()
? newFieldOptions.split(/[,]/).map(s => s.trim()).filter(Boolean)
: undefined
};
if (newFieldForm.category === '时间') {
newField.timeFormat = newFieldTimeFormat;
newField.timeDefault = newFieldTimeDefault;
newField.fixedTimeValue = newFieldFixedTimeValue;
}
const updated = [...formFields, newField];
setFormFields(updated);
storage.set('formFieldsConfig', updated);
setNewFieldForm({ label: '', category: '填空', type: 'text' });
setNewFieldOptions('');
setNewFieldTimeFormat('YYYY年MM月DD日');
setNewFieldTimeDefault('specific');
setNewFieldFixedTimeValue('');
};
const handleAssetUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const dataUrl = event.target?.result as string;
const asset = { id: 'asset_' + Date.now(), name: file.name, dataUrl };
const updated = [...imageAssets, asset];
setImageAssets(updated);
storage.set('imageAssets', updated);
};
reader.readAsDataURL(file);
e.target.value = '';
};
const insertTable = () => {
const rowsStr = prompt('请输入行数:', '2');
const colsStr = prompt('请输入列数:', '3');
if (rowsStr && colsStr) {
const rows = parseInt(rowsStr);
const cols = parseInt(colsStr);
if (isNaN(rows) || isNaN(cols)) return;
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
for (let i = 0; i < rows; i++) {
table += '<tr>';
for (let j = 0; j < cols; j++) {
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
}
table += '</tr>';
}
table += '</table><p></p>';
pushHistory();
execCmd('insertHTML', table);
}
};
const insertImage = () => {
editorRef.current?.focus();
restoreSelection();
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;';
if (width > 0) styleStr += `width:${width}px;`;
if (height > 0) styleStr += `height:${height}px;`;
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;`;
pushHistory();
execCmd('insertHTML', html);
};
const saveCurrentTemplate = () => {
if (!currentTemplateId || !editorRef.current) return;
const allTemplates = storage.get<Template[]>('templates', []);
const updated = allTemplates.map(t => {
if (t.id === currentTemplateId) {
return { ...t, content: editorRef.current!.innerHTML, updatedAt: new Date().toISOString() };
}
return t;
});
setTemplates(updated.filter(t => templates.some(x => x.id === t.id)));
storage.set('templates', updated);
setIsSaved(true);
setTimeout(() => setIsSaved(false), 3000);
};
const handleAddTemplate = () => {
setIsEditing(false);
setFormData({ name: '', desc: '' });
setIsModalOpen(true);
};
const handleEditInfo = (template: Template) => {
setIsEditing(true);
setFormData({ name: template.name, desc: template.desc || '' });
setIsModalOpen(true);
};
const handleDeleteTemplate = (id: string) => {
if (templates.length <= 1) {
alert('至少需要保留一个模板');
return;
}
if (window.confirm('确定要删除此模板吗?')) {
const allTemplates = storage.get<Template[]>('templates', []);
const updated = allTemplates.filter(t => t.id !== id);
setTemplates(updated.filter(t => templates.some(x => x.id === t.id)));
storage.set('templates', updated);
if (currentTemplateId === id) {
const visible = updated.filter(t => templates.some(x => x.id === t.id));
setCurrentTemplateId(visible[0]?.id || null);
}
}
};
const handleModalSubmit = (e: React.FormEvent) => {
e.preventDefault();
const allTemplates = storage.get<Template[]>('templates', []);
if (isEditing) {
const updated = allTemplates.map(t => {
if (t.id === currentTemplateId) {
return { ...t, name: formData.name, desc: formData.desc };
}
return t;
});
setTemplates(updated.filter(t => templates.some(x => x.id === t.id)));
storage.set('templates', updated);
} else {
const newTpl: Template = {
id: 'tpl_' + Date.now(),
name: formData.name,
desc: formData.desc,
content: defaultReportContent,
createdAt: new Date().toISOString(),
author: currentUser?.username || 'admin'
};
const updated = [...allTemplates, newTpl];
setTemplates([...templates, newTpl]);
storage.set('templates', updated);
setCurrentTemplateId(newTpl.id);
const savedUsers = storage.get<User[]>('users', []);
let updatedUsers = savedUsers;
if (currentUser?.role === 'super') {
updatedUsers = savedUsers.map(u => {
if (u.role === 'super') {
const mt = [...(u.manageableTemplates || [])];
const vt = [...(u.visibleTemplates || [])];
if (!mt.includes(newTpl.id)) mt.push(newTpl.id);
if (!vt.includes(newTpl.id)) vt.push(newTpl.id);
return { ...u, manageableTemplates: mt, visibleTemplates: vt };
}
return u;
});
} else if (currentUser?.role === 'admin') {
const dept = currentUser.department || '';
updatedUsers = savedUsers.map(u => {
if (u.username === currentUser.username) {
const mt = [...(u.manageableTemplates || [])];
const vt = [...(u.visibleTemplates || [])];
if (!mt.includes(newTpl.id)) mt.push(newTpl.id);
if (!vt.includes(newTpl.id)) vt.push(newTpl.id);
return { ...u, manageableTemplates: mt, visibleTemplates: vt };
}
if (u.role === 'user' && u.department === dept) {
const vt = [...(u.visibleTemplates || [])];
if (!vt.includes(newTpl.id)) vt.push(newTpl.id);
return { ...u, visibleTemplates: vt };
}
return u;
});
}
storage.set('users', updatedUsers);
const currentCached = updatedUsers.find(u => u.username === currentUser?.username);
if (currentCached) {
storage.set('currentUser', currentCached);
setCurrentUser(currentCached);
}
}
setIsModalOpen(false);
};
if (!currentUser) return null;
const currentTemplate = templates.find(t => t.id === currentTemplateId);
return (
<div className="flex h-screen bg-bg overflow-hidden">
<Sidebar />
{/* Template List Sidebar */}
<aside className="w-72 bg-sidebar-bg border-r border-border flex flex-col shrink-0 overflow-hidden">
<div className="p-6 border-b border-border flex items-center justify-between">
<span className="text-sm font-bold text-text-main uppercase tracking-wider"></span>
<button
onClick={handleAddTemplate}
className="w-8 h-8 bg-accent text-white rounded-lg flex items-center justify-center hover:bg-blue-700 transition-colors shadow-sm"
>
<Plus size={16} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{templates.map(tpl => (
<div
key={tpl.id}
onClick={() => setCurrentTemplateId(tpl.id)}
className={`p-4 rounded-xl border transition-all group ${
currentTemplateId === tpl.id
? 'bg-white border-accent shadow-sm'
: 'bg-transparent border-transparent hover:bg-white hover:border-border'
}`}
>
<div className="flex justify-between items-start mb-1">
<div className={`text-sm font-bold ${currentTemplateId === tpl.id ? 'text-accent' : 'text-text-main'}`}>
{tpl.name}
</div>
</div>
<div className="text-[10px] text-text-muted line-clamp-1 mb-2">{tpl.desc || '无描述'}</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); handleEditInfo(tpl); }}
className="px-2 py-1 rounded-md bg-slate-100 text-slate-600 text-[10px] font-bold hover:bg-slate-200 transition-colors"
>
</button>
{templates.length > 1 && (
<button
onClick={(e) => { e.stopPropagation(); handleDeleteTemplate(tpl.id); }}
className="px-2 py-1 rounded-md bg-red-50 text-red-600 text-[10px] font-bold hover:bg-red-100 transition-colors"
>
</button>
)}
</div>
</div>
))}
{templates.length === 0 && (
<div className="text-center text-text-muted text-sm py-8"></div>
)}
</div>
</aside>
{/* Main Editor */}
<div className="flex-1 flex flex-col overflow-hidden">
<header className="h-20 bg-white border-b border-border flex items-center justify-between px-8 shrink-0">
<div className="flex items-center gap-4">
<div>
<h1 className="text-lg font-bold text-text-main"></h1>
<p className="text-[10px] text-text-muted mt-0.5 uppercase tracking-wider font-bold">
{currentTemplate ? currentTemplate.name : '请选择模板'}
</p>
</div>
</div>
<div className="flex items-center gap-4">
{isSaved && (
<span className="text-xs text-green-600 font-bold flex items-center gap-1">
<Check size={14} />
</span>
)}
<button
onClick={saveCurrentTemplate}
className="btn-accent inline-flex items-center gap-2"
>
<Save size={16} />
</button>
<button
onClick={() => editorRef.current && printDocument(editorRef.current.innerHTML)}
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
title="打印预览"
>
<Printer size={18} />
</button>
</div>
</header>
<div className="flex-1 flex overflow-hidden">
{/* Editor Main */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-1 p-3 border-b border-border bg-slate-50 shrink-0 overflow-x-auto no-scrollbar">
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onMouseDown={(e) => e.preventDefault()} onClick={handleUndo} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="撤销"><Undo size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={handleRedo} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="重做"><Redo size={16} /></button>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { execCmd('fontName', e.target.value); e.target.value = ''; }}
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
<option value=""></option>
<option value="SimSun"></option>
<option value="Microsoft YaHei"></option>
<option value="SimHei"></option>
<option value="KaiTi"></option>
</select>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('bold')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="粗体"><Bold size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('italic')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="斜体"><Italic size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('underline')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="下划线"><Underline size={16} /></button>
<div className="relative flex items-center">
<input
type="color"
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => execCmd('foreColor', e.target.value)}
className="w-9 h-9 p-1.5 bg-transparent border-none cursor-pointer rounded-lg hover:bg-white transition-colors"
title="文字颜色"
/>
</div>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('justifyLeft')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="左对齐"><AlignLeft size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('justifyCenter')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="居中"><AlignCenter size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('justifyRight')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
</div>
<div className="flex gap-1">
<button onMouseDown={(e) => e.preventDefault()} onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入表格"><Table size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
</div>
</div>
{/* Editor Area */}
<div className="editor-content-wrapper print-wrapper">
<div
ref={editorRef}
contentEditable
className="editor-content print-content template-editor-mode"
onBlur={saveSelection}
onMouseUp={saveSelection}
onKeyUp={saveSelection}
>
</div>
</div>
</div>
{/* Right: Field Library */}
<aside className="w-[240px] bg-sidebar-bg border-l border-border flex flex-col shrink-0 overflow-hidden">
<div className="flex border-b border-border">
{(['insert', 'manage'] as const).map(tab => (
<button
key={tab}
onClick={() => setFieldLibTab(tab)}
className={`flex-1 py-3 text-xs font-bold transition-all border-b-2 uppercase tracking-wider ${
fieldLibTab === tab ? 'text-accent border-accent' : 'text-text-muted border-transparent hover:text-text-main'
}`}
>
{tab === 'insert' ? '插入字段' : '字段管理'}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{fieldLibTab === 'insert' && (
<div className="space-y-4">
{['填空', '单选', '多选', '时间'].map(cat => {
const catFields = formFields.filter(f => f.category === cat);
if (catFields.length === 0) return null;
return (
<div key={cat}>
<div className="text-[10px] text-slate-400 mb-1.5 font-medium">{cat}</div>
<div className="flex flex-wrap gap-1.5">
{catFields.map(field => (
<button
key={field.key}
id={`sidebar-field-${field.key}`}
type="button"
onMouseDown={(e) => e.preventDefault()}
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 ${activeFieldKey === field.key ? 'ring-2 ring-accent bg-blue-50 border-accent' : ''}`}
title={`插入 ${field.label}`}
>
{field.label}
</button>
))}
</div>
</div>
);
})}
</div>
)}
{fieldLibTab === 'manage' && (
<div className="space-y-3">
{['填空', '单选', '多选', '时间'].map(cat => {
const catFields = formFields.filter(f => f.category === cat);
if (catFields.length === 0) return null;
const expanded = expandedCategories.includes(cat);
return (
<div key={cat} className="border border-slate-200 rounded overflow-hidden">
<button
onClick={() => setExpandedCategories(prev => prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat])}
className="w-full flex items-center justify-between px-3 py-2 bg-slate-50 text-xs font-semibold text-slate-700 hover:bg-slate-100"
>
<span>{cat}</span>
<span>{expanded ? '▾' : '▸'}</span>
</button>
{expanded && (
<div className="p-2 space-y-2 bg-white">
{catFields.map(field => (
<div
key={field.key}
id={`sidebar-field-${field.key}`}
onClick={() => {
setEditingFieldKey(field.key);
setEditFieldLabel(field.label);
setEditFieldOptions((field.options || []).join(', '));
setEditFieldTimeFormat(field.timeFormat || '');
setEditFieldTimeDefault(field.timeDefault || 'specific');
setEditFieldFixedTimeValue(field.fixedTimeValue || '');
}}
className={`cursor-pointer rounded border p-2 transition-all ${activeFieldKey === field.key ? 'border-accent bg-blue-50 ring-1 ring-accent' : 'border-slate-100 bg-slate-50 hover:border-slate-200'}`}
>
{editingFieldKey === field.key ? (
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
<div className="text-xs font-medium text-text-main">
{field.isSystemLocked ? field.label : (
<input
type="text"
value={editFieldLabel}
onChange={(e) => setEditFieldLabel(e.target.value)}
className="w-full px-1.5 py-1 text-xs border border-border rounded"
placeholder="字段名称"
/>
)}
</div>
{['单选', '多选'].includes(field.category) && (
<input
type="text"
value={editFieldOptions}
onChange={(e) => setEditFieldOptions(e.target.value)}
className="w-full px-1.5 py-1 text-xs border border-border rounded"
placeholder="选项,用逗号分隔"
/>
)}
{field.category === '时间' && (
<div className="space-y-1">
<select
value={editFieldTimeDefault}
onChange={(e) => setEditFieldTimeDefault(e.target.value as 'current' | 'specific')}
className="w-full px-1.5 py-1 text-xs border border-border rounded bg-white"
>
<option value="specific"></option>
<option value="current"></option>
</select>
{editFieldTimeDefault === 'specific' && (
<input
type={field.type === 'date' ? 'date' : 'time'}
value={editFieldFixedTimeValue}
onChange={(e) => setEditFieldFixedTimeValue(e.target.value)}
className="w-full px-1.5 py-1 text-xs border border-border rounded"
/>
)}
<input
list={`edit-format-list-${field.key}`}
value={editFieldTimeFormat}
onChange={(e) => setEditFieldTimeFormat(e.target.value)}
onBlur={(e) => {
const val = e.target.value.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = (e.target as HTMLInputElement).value.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
}
}}
className="w-full px-1.5 py-1 text-xs border border-border rounded"
placeholder="输入格式,如 YYYY-MM-DD"
/>
<datalist id={`edit-format-list-${field.key}`}>
{customTimeFormats.map(fmt => <option key={fmt} value={fmt} />)}
</datalist>
</div>
)}
<div className="flex gap-2">
<button
onClick={() => saveFieldEdit(field.key)}
className="px-2 py-1 bg-accent text-white text-[10px] rounded"
></button>
<button
onClick={() => setEditingFieldKey(null)}
className="px-2 py-1 bg-slate-200 text-slate-700 text-[10px] rounded"
></button>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-text-main text-xs">{field.label}</div>
<div className="text-[10px] text-slate-400">{field.category} · {field.type}</div>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-[10px] text-slate-600 cursor-pointer" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={field.visibleInForm}
onChange={() => toggleFieldVisible(field.key)}
/>
</label>
{!field.isSystemLocked && (
<button onClick={(e) => { e.stopPropagation(); deleteField(field.key); }} className="text-red-500 text-[10px] hover:underline"></button>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
})}
{/* Asset Library */}
<div className="border border-slate-200 rounded overflow-hidden mt-4">
<div className="px-3 py-2 bg-slate-50 text-xs font-semibold text-slate-700"></div>
<div className="p-2 space-y-2 bg-white">
<div className="flex flex-wrap gap-2">
{imageAssets.map(asset => (
<div key={asset.id} className="relative w-14 h-14 border rounded overflow-hidden group">
<img src={asset.dataUrl} alt={asset.name} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 hidden group-hover:flex items-center justify-center">
<button
onClick={() => {
const updated = imageAssets.filter(a => a.id !== asset.id);
setImageAssets(updated);
storage.set('imageAssets', updated);
}}
className="text-white text-[10px]"
></button>
</div>
</div>
))}
</div>
<label className="block w-full py-1.5 bg-accent text-white text-xs font-semibold rounded text-center cursor-pointer hover:opacity-90">
<input type="file" accept="image/*" className="hidden" onChange={handleAssetUpload} />
</label>
</div>
</div>
<div className="pt-3 border-t border-slate-200 space-y-2">
<div className="text-xs font-semibold text-text-main"></div>
<input
type="text"
value={newFieldForm.label}
onChange={(e) => setNewFieldForm({ ...newFieldForm, label: e.target.value })}
placeholder="字段名称"
className="w-full px-2 py-1.5 text-xs border border-border rounded focus:outline-hidden focus:border-accent"
/>
<div className="flex gap-2">
<select
value={newFieldForm.category}
onChange={(e) => {
const cat = e.target.value;
let t: FieldType = 'text';
if (cat === '单选') t = 'single_select';
else if (cat === '多选') t = 'multi_select';
else if (cat === '时间') t = 'date';
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"
>
<option value="填空"></option>
<option value="单选"></option>
<option value="多选"></option>
<option value="时间"></option>
</select>
<select
value={newFieldForm.type}
onChange={(e) => {
const t = e.target.value as FieldType;
setNewFieldForm({ ...newFieldForm, type: t });
if (newFieldForm.category === '时间') {
setNewFieldTimeFormat(t === 'date' ? 'YYYY年MM月DD日' : 'HH:mm');
}
}}
className="flex-1 px-2 py-1.5 text-xs border border-border rounded focus:outline-hidden focus:border-accent bg-white"
>
{newFieldForm.category === '填空' && <option value="text"></option>}
{newFieldForm.category === '单选' && <option value="single_select"></option>}
{newFieldForm.category === '多选' && <option value="multi_select"></option>}
{newFieldForm.category === '时间' && <><option value="date"></option><option value="time"></option></>}
</select>
</div>
{newFieldForm.category === '时间' && (
<div className="space-y-1">
<select
value={newFieldTimeDefault}
onChange={(e) => setNewFieldTimeDefault(e.target.value as 'current' | 'specific')}
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-white"
>
<option value="specific"></option>
<option value="current"></option>
</select>
{newFieldTimeDefault === 'specific' && (
<input
type={newFieldForm.type === 'date' ? 'date' : 'time'}
value={newFieldFixedTimeValue}
onChange={(e) => setNewFieldFixedTimeValue(e.target.value)}
className="w-full px-2 py-1.5 text-xs border border-border rounded"
/>
)}
<input
list="new-format-list"
value={newFieldTimeFormat}
onChange={(e) => setNewFieldTimeFormat(e.target.value)}
onBlur={(e) => {
const val = e.target.value.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = (e.target as HTMLInputElement).value.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
}
}}
className="w-full px-2 py-1.5 text-xs border border-border rounded"
placeholder="输入格式,如 YYYY-MM-DD"
/>
<datalist id="new-format-list">
{customTimeFormats.map(fmt => <option key={fmt} value={fmt} />)}
</datalist>
</div>
)}
{['单选', '多选'].includes(newFieldForm.category) && (
<input
type="text"
value={newFieldOptions}
onChange={(e) => setNewFieldOptions(e.target.value)}
placeholder="选项,用逗号分隔"
className="w-full px-2 py-1.5 text-xs border border-border rounded focus:outline-hidden focus:border-accent"
/>
)}
<button
onClick={addField}
className="w-full py-1.5 bg-accent text-white text-xs font-semibold rounded hover:opacity-90 transition-colors"
>
</button>
</div>
</div>
)}
</div>
</aside>
</div>
</div>
{isModalOpen && (
<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-10 w-full max-w-[500px] shadow-2xl border border-border">
<h3 className="text-xl font-bold text-text-main mb-2">{isEditing ? '编辑模板信息' : '新增模板'}</h3>
<p className="text-sm text-text-muted mb-8"></p>
<form onSubmit={handleModalSubmit} className="space-y-6">
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> *</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
placeholder="请输入模板名称"
className="input-minimal"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"></label>
<textarea
value={formData.desc}
onChange={(e) => setFormData({ ...formData, desc: e.target.value })}
placeholder="请输入模板描述"
className="input-minimal min-h-[100px] resize-y"
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-6 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors"
>
</button>
<button
type="submit"
className="btn-accent"
>
</button>
</div>
</form>
</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>
);
}