1667 lines
83 KiB
TypeScript
1667 lines
83 KiB
TypeScript
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>​`;
|
||
|
||
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>​`;
|
||
}
|
||
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>
|
||
);
|
||
}
|