Files
Mdeical_Sur_Report/src/pages/TemplateManage.tsx

1667 lines
83 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, Download, Upload } 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 [exportModalOpen, setExportModalOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({ name: '', desc: '' });
const [importedContent, setImportedContent] = useState<{content: string; fields: FormField[]} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
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 [editFieldHasUnderline, setEditFieldHasUnderline] = useState(false);
const [newFieldTimeFormat, setNewFieldTimeFormat] = useState('YYYY年MM月DD日');
const [newFieldTimeDefault, setNewFieldTimeDefault] = useState<'current' | 'specific'>('specific');
const [newFieldFixedTimeValue, setNewFieldFixedTimeValue] = useState('');
const [newFieldHasUnderline, setNewFieldHasUnderline] = useState(false);
const [customTimeFormats, setCustomTimeFormats] = useState<string[]>([]);
const [formatDropdownOpen, setFormatDropdownOpen] = useState(false);
const [newFormatDropdownOpen, setNewFormatDropdownOpen] = useState(false);
const [placeholderModal, setPlaceholderModal] = useState({
isOpen: false, width: '200', height: '200', mode: 'frame' as 'frame' | 'manual'
});
const [tableModal, setTableModal] = useState({
isOpen: false, rows: '2', cols: '3'
});
const [imageAssets, setImageAssets] = useState<{ id: string; name: string; dataUrl: string }[]>([]);
const [selectedIds, setSelectedIds] = useState<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'];
const cleanedSaved = savedFormats.filter(f => f !== '24h' && f !== '12h');
setCustomTimeFormats(Array.from(new Set([...defaultFormats, ...cleanedSaved])));
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;
if (template.fields && template.fields.length > 0) {
setFormFields(template.fields);
storage.set('formFieldsConfig', template.fields);
}
}
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) => {
const mw = placeholder.style.maxWidth || placeholder.style.width || '200px';
const mh = placeholder.style.maxHeight || placeholder.style.height || '200px';
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${src}" style="max-width:${mw};max-height:${mh};display:block;object-fit:contain;object-position:left top;" draggable="false">
`;
placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
placeholder.style.width = 'auto';
placeholder.style.height = 'auto';
placeholder.style.lineHeight = 'normal';
placeholder.style.maxWidth = mw;
placeholder.style.maxHeight = mh;
placeholder.style.textAlign = 'left';
placeholder.style.verticalAlign = 'top';
placeholder.style.justifyContent = 'flex-start';
placeholder.style.alignItems = 'flex-start';
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 changeLineHeight = (height: string) => {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
let node = sel.getRangeAt(0).commonAncestorContainer;
if (node.nodeType === Node.TEXT_NODE) node = node.parentNode as Node;
const block = (node as HTMLElement).closest('p, div, td, h1, h2, h3, li');
if (block) {
(block as HTMLElement).style.lineHeight = height;
saveTemplateContent();
}
};
const changeAlignment = (align: 'left' | 'center' | 'right' | 'justify') => {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
let node = sel.getRangeAt(0).commonAncestorContainer;
if (node.nodeType === Node.TEXT_NODE) node = node.parentNode as Node;
const block = (node as HTMLElement).closest('p, div, td, h1, h2, h3, li');
if (block) {
(block as HTMLElement).style.textAlign = align;
saveTemplateContent();
}
};
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 underlineClass = field.hasUnderline !== true ? ' no-underline' : '';
const html = `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><span class="field-value${underlineClass}" 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;
}
next.hasUnderline = editFieldHasUnderline;
return next;
});
setFormFields(updated);
storage.set('formFieldsConfig', updated);
setEditingFieldKey(null);
// 同步更新编辑器中已插入字段的 classList
if (editorRef.current) {
const els = editorRef.current.querySelectorAll(`.field-value[data-bind="${key}"]`);
els.forEach(el => {
if (editFieldHasUnderline) {
el.classList.remove('no-underline');
} else {
el.classList.add('no-underline');
}
});
saveTemplateContent();
}
};
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,
hasUnderline: newFieldHasUnderline,
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('');
setNewFieldHasUnderline(true);
};
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 = () => {
editorRef.current?.focus();
restoreSelection();
pushHistory();
setTableModal({ isOpen: true, rows: '2', cols: '3' });
};
const insertImage = () => {
editorRef.current?.focus();
restoreSelection();
pushHistory();
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
};
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 (window.confirm('确定要删除此模板吗?')) {
const allTemplates = storage.get<Template[]>('templates', []);
const updated = allTemplates.filter(t => t.id !== id);
setTemplates(updated);
storage.set('templates', updated);
if (currentTemplateId === id) {
setCurrentTemplateId(updated[0]?.id || null);
}
setSelectedIds(prev => prev.filter(sid => sid !== id));
}
};
const handleBatchDelete = () => {
if (selectedIds.length === 0) return;
if (!window.confirm(`确定要删除选中的 ${selectedIds.length} 个模板吗?`)) return;
const allTemplates = storage.get<Template[]>('templates', []);
const updated = allTemplates.filter(t => !selectedIds.includes(t.id));
setTemplates(updated);
storage.set('templates', updated);
if (currentTemplateId && selectedIds.includes(currentTemplateId)) {
setCurrentTemplateId(updated[0]?.id || null);
}
setSelectedIds([]);
};
const handleBatchExport = () => {
if (selectedIds.length === 0) return;
const targets = templates.filter(t => selectedIds.includes(t.id));
const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
const exportData = {
version: '1.0',
type: 'surclaw_template_package_batch',
templates: targets
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `模板批量导出-${ts}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const json = JSON.parse(event.target?.result as string);
if (json.type !== 'surclaw_template_package') {
alert('无效的模板包文件');
return;
}
setFormData({ name: json.title || '', desc: json.description || '' });
setImportedContent({
content: json.content || '',
fields: Array.isArray(json.fields) ? json.fields : []
});
} catch {
alert('文件解析失败,请检查 JSON 格式');
}
};
reader.readAsText(file);
if (e.target) e.target.value = '';
};
const handleExportTemplate = (template: Template) => {
const exportData = {
version: '1.0',
type: 'surclaw_template_package',
title: template.name,
description: template.desc || '',
content: template.content,
fields: template.fields || formFields
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `模板导出-${template.name}.json`;
a.click();
URL.revokeObjectURL(url);
};
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: importedContent?.content || defaultReportContent,
createdAt: new Date().toISOString(),
author: currentUser?.username || 'admin',
fields: importedContent?.fields || formFields
};
const updated = [...allTemplates, newTpl];
setTemplates([...templates, newTpl]);
storage.set('templates', updated);
setCurrentTemplateId(newTpl.id);
if (importedContent?.fields && importedContent.fields.length > 0) {
setFormFields(importedContent.fields);
storage.set('formFieldsConfig', importedContent.fields);
}
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);
setImportedContent(null);
};
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>
{selectedIds.length > 0 && (
<div className="px-4 pt-3 pb-1 flex items-center justify-between bg-slate-50 border-b border-border">
<span className="text-xs text-text-muted font-bold"> {selectedIds.length} </span>
<div className="flex gap-2">
<button
onClick={handleBatchExport}
className="px-2 py-1 rounded-md bg-blue-50 text-blue-600 text-[10px] font-bold hover:bg-blue-100 transition-colors"
>
</button>
<button
onClick={handleBatchDelete}
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>
)}
<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 cursor-pointer ${
currentTemplateId === tpl.id
? 'bg-white border-accent shadow-sm'
: 'bg-transparent border-transparent hover:bg-white hover:border-border'
}`}
>
<div className="flex items-start gap-2">
<input
type="checkbox"
checked={selectedIds.includes(tpl.id)}
onChange={(e) => {
e.stopPropagation();
setSelectedIds(prev => e.target.checked ? [...prev, tpl.id] : prev.filter(id => id !== tpl.id));
}}
onClick={(e) => e.stopPropagation()}
className="mt-1 shrink-0"
/>
<div className="flex-1 min-w-0">
<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>
<button
onClick={(e) => { e.stopPropagation(); handleExportTemplate(tpl); }}
className="px-2 py-1 rounded-md bg-blue-50 text-blue-600 text-[10px] font-bold hover:bg-blue-100 transition-colors"
>
</button>
<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>
</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={() => setExportModalOpen(true)}
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
title="下载"
>
<Download size={18} />
</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>
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { if (e.target.value) { execCmd('fontSize', 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="3">12pt</option>
<option value="4">14pt</option>
<option value="5">18pt</option>
<option value="6">24pt</option>
</select>
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { if (e.target.value) { changeLineHeight(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="1">1.0</option>
<option value="1.5">1.5</option>
<option value="2">2.0</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={() => changeAlignment('left')} 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={() => changeAlignment('center')} 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={() => changeAlignment('right')} 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={(e) => {
setEditingFieldKey(field.key);
setEditFieldLabel(field.label);
setEditFieldOptions((field.options || []).join(', '));
setEditFieldTimeFormat(field.timeFormat || '');
setEditFieldTimeDefault(field.timeDefault || 'specific');
setEditFieldFixedTimeValue(field.fixedTimeValue || '');
setEditFieldHasUnderline(field.hasUnderline ?? false);
const target = e.currentTarget;
setTimeout(() => {
target.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 50);
}}
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"
/>
)}
<div className="relative">
<input
type="text"
value={editFieldTimeFormat}
onChange={(e) => setEditFieldTimeFormat(e.target.value)}
onFocus={() => setFormatDropdownOpen(true)}
onBlur={() => {
setTimeout(() => setFormatDropdownOpen(false), 200);
const val = editFieldTimeFormat.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = editFieldTimeFormat.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
setFormatDropdownOpen(false);
}
}}
className="w-full px-1.5 py-1 text-xs border border-border rounded"
placeholder="输入格式或下拉选择"
/>
{formatDropdownOpen && (
<div className="absolute z-10 left-0 right-0 top-full mt-1 bg-white border border-border rounded shadow-lg max-h-32 overflow-y-auto">
{customTimeFormats
.filter(fmt => {
const isDateFormat = /YYYY|MM|DD/.test(fmt);
const isTimeFormat = /HH|hh|mm|A/.test(fmt);
if (field.type === 'date') return isDateFormat;
if (field.type === 'time') return isTimeFormat;
return true;
})
.map(fmt => (
<div
key={fmt}
className="px-2 py-1 text-xs hover:bg-slate-100 cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
setEditFieldTimeFormat(fmt);
setFormatDropdownOpen(false);
}}
>
{fmt}
</div>
))}
</div>
)}
</div>
</div>
)}
<label className="flex items-center gap-1 text-[10px] text-slate-600 cursor-pointer">
<input type="checkbox" checked={editFieldHasUnderline} onChange={(e) => setEditFieldHasUnderline(e.target.checked)} />
线
</label>
<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"
/>
)}
<div className="relative">
<input
type="text"
value={newFieldTimeFormat}
onChange={(e) => setNewFieldTimeFormat(e.target.value)}
onFocus={() => setNewFormatDropdownOpen(true)}
onBlur={() => {
setTimeout(() => setNewFormatDropdownOpen(false), 200);
const val = newFieldTimeFormat.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = newFieldTimeFormat.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
setNewFormatDropdownOpen(false);
}
}}
className="w-full px-2 py-1.5 text-xs border border-border rounded"
placeholder="输入格式或下拉选择"
/>
{newFormatDropdownOpen && (
<div className="absolute z-10 left-0 right-0 top-full mt-1 bg-white border border-border rounded shadow-lg max-h-32 overflow-y-auto">
{customTimeFormats
.filter(fmt => {
const isDateFormat = /YYYY|MM|DD/.test(fmt);
const isTimeFormat = /HH|hh|mm|A/.test(fmt);
if (newFieldForm.type === 'date') return isDateFormat;
if (newFieldForm.type === 'time') return isTimeFormat;
return true;
})
.map(fmt => (
<div
key={fmt}
className="px-2 py-1 text-xs hover:bg-slate-100 cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
setNewFieldTimeFormat(fmt);
setNewFormatDropdownOpen(false);
}}
>
{fmt}
</div>
))}
</div>
)}
</div>
</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"
/>
)}
<label className="flex items-center gap-1 text-xs text-slate-600 cursor-pointer">
<input type="checkbox" checked={newFieldHasUnderline} onChange={(e) => setNewFieldHasUnderline(e.target.checked)} />
线
</label>
<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>
{exportModalOpen && (
<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 ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
const name = currentTemplate?.name || '模板';
printDocument(editorRef.current?.innerHTML || '', `${name}-${ts}`);
setExportModalOpen(false);
}}
className="w-full py-2.5 bg-accent text-white rounded text-sm font-semibold hover:opacity-90 transition-colors"
> PDF</button>
<button
onClick={() => {
const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
const name = currentTemplate?.name || '模板';
const data = currentTemplate ? { ...currentTemplate, content: editorRef.current?.innerHTML } : { content: editorRef.current?.innerHTML };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${name}-${ts}.json`;
a.click();
URL.revokeObjectURL(url);
setExportModalOpen(false);
}}
className="w-full py-2.5 bg-slate-100 text-slate-700 rounded text-sm font-semibold hover:bg-slate-200 transition-colors"
> JSON</button>
<button
onClick={() => setExportModalOpen(false)}
className="w-full py-2.5 border border-border text-text-main rounded text-sm font-semibold hover:bg-slate-50 transition-colors"
></button>
</div>
</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>
{!isEditing && (
<div className="flex items-center gap-3 mb-6 p-3 bg-slate-50 rounded-xl border border-dashed border-slate-200">
<div className="text-xs text-text-muted flex-1"></div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-8 h-8 bg-accent text-white rounded-lg flex items-center justify-center hover:bg-blue-700 transition-colors shadow-sm"
>
<Upload size={16} />
</button>
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImportFile} />
</div>
)}
<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); setImportedContent(null); }}
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>
)}
{placeholderModal.isOpen && (
<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">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs mb-1">(px)</label>
<input type="number" value={placeholderModal.width} onChange={e => setPlaceholderModal({...placeholderModal, width: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
<div className="flex-1">
<label className="block text-xs mb-1">(px)</label>
<input type="number" value={placeholderModal.height} onChange={e => setPlaceholderModal({...placeholderModal, height: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
</div>
<div>
<label className="block text-xs mb-1"></label>
<div className="flex gap-2">
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'frame'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'frame' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}><br/><span className="text-[10px] opacity-80">/</span></button>
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'manual'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'manual' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}><br/><span className="text-[10px] opacity-80"></span></button>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button onClick={() => setPlaceholderModal({...placeholderModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm"></button>
<button onClick={() => {
const sel = window.getSelection();
let node: Node | null = sel?.anchorNode ?? null;
let inTable = false;
while (node) {
if ((node as Element).nodeName === 'TD' || (node as Element).nodeName === 'TH') {
inTable = true;
break;
}
node = node.parentNode;
}
const w = parseInt(placeholderModal.width) || 200;
const h = parseInt(placeholderModal.height) || 200;
const modeAttr = placeholderModal.mode === 'manual' ? ' data-mode="manual"' : '';
const hintText = '插入/点击放置图片';
const id = 'ph_' + Date.now();
let html: string;
if (inTable) {
const styleStr = 'position:relative;display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${hintText}</span></div>`;
} else {
let styleStr = 'display:inline-block;text-align:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;';
styleStr += `width:${w}px;height:${h}px;max-width:${w}px;max-height:${h}px;line-height:${h}px;`;
const showShortText = w > 0 && w < 80;
const text = showShortText ? '插图' : hintText;
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>&#8203;`;
}
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
const fragment = document.createDocumentFragment();
while (wrapper.firstChild) {
fragment.appendChild(wrapper.firstChild);
}
const sel2 = window.getSelection();
if (sel2 && sel2.rangeCount > 0) {
const range = sel2.getRangeAt(0);
range.deleteContents();
range.insertNode(fragment);
const lastNode = fragment.lastChild;
if (lastNode) {
range.setStartAfter(lastNode);
range.collapse(true);
sel2.removeAllRanges();
sel2.addRange(range);
}
} else if (editorRef.current) {
editorRef.current.appendChild(fragment);
}
editorRef.current?.focus();
saveTemplateContent();
setPlaceholderModal({...placeholderModal, isOpen: false});
}} className="px-4 py-2 bg-accent text-white rounded text-sm"></button>
</div>
</div>
</div>
)}
{tableModal.isOpen && (
<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">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs mb-1"></label>
<input type="number" min="1" value={tableModal.rows} onChange={e => setTableModal({...tableModal, rows: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
<div className="flex-1">
<label className="block text-xs mb-1"></label>
<input type="number" min="1" value={tableModal.cols} onChange={e => setTableModal({...tableModal, cols: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button onClick={() => setTableModal({...tableModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm"></button>
<button onClick={() => {
const rows = parseInt(tableModal.rows);
const cols = parseInt(tableModal.cols);
if (isNaN(rows) || isNaN(cols) || rows < 1 || cols < 1) {
setTableModal({...tableModal, isOpen: false});
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>';
execCmd('insertHTML', table);
setTableModal({...tableModal, isOpen: false});
}} className="px-4 py-2 bg-accent text-white rounded text-sm"></button>
</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>
);
}