import React, { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; import { LabelTemplate, PaperConfig, ImportData, RecordRow, ExportRangeConfig, CutLineConfig } from './types'; import { stripTemplateForStorage, normalizeTemplate, DEFAULT_PAPER_CONFIG, DEFAULT_EXPORT_RANGE, DEFAULT_CUT_LINE_CONFIG, exportRangeEquals, buildPrintablePages, extractTemplateVariables, buildAutoVariableMapping, applyRowVariableMapping, type PrintableLabel, } from './utils'; import { LabelDesigner } from './components/LabelDesigner'; import { PropSidebar } from './components/PropSidebar'; import { ExportSidebar } from './components/ExportSidebar'; import { MobileExportView } from './components/MobileExportView'; import { MobileDesignView } from './components/MobileDesignView'; import { LayoutPreview } from './components/PreviewGrid'; import { CanvasLabelImage } from './components/CanvasLabelImage'; import { renderLabelToDataUrl } from './labelRenderer'; import { exportLabelsPdfAsync, type PdfExportProgress } from './pdfExport'; import { useIsMobile } from './hooks/useIsMobile'; import { useUndoRedo } from './hooks/useUndoRedo'; import { useAppDialog } from './components/AppDialog'; import { AnchoredMenu } from './components/AnchoredMenu'; import { FileDown, Code, Loader2, Plus, Upload, Printer, Pencil, Trash2, MoreHorizontal, Undo2, Redo2, X, } from 'lucide-react'; import logoUrl from './assets/logo.svg'; const TEMPLATE_PREVIEW_MAX_W = 220; const TEMPLATE_PREVIEW_MAX_H = 132; const EMPTY_TEMPLATE_PLACEHOLDER: LabelTemplate = { id: '__placeholder__', name: '', width: 70, height: 40, elements: [], paper: DEFAULT_PAPER_CONFIG, cutLine: DEFAULT_CUT_LINE_CONFIG, }; function fitTemplatePreviewSize(widthMm: number, heightMm: number) { const scale = Math.min( TEMPLATE_PREVIEW_MAX_W / widthMm, TEMPLATE_PREVIEW_MAX_H / heightMm ); return { displayWidthPx: widthMm * scale, displayHeightPx: heightMm * scale, }; } export default function App() { const { showAlert, showConfirm } = useAppDialog(); // --- 1. Templates Storage & Initial States --- const [templates, setTemplates] = useState(() => { try { const saved = localStorage.getItem('label_templates_list_v2'); if (saved) { const parsed = JSON.parse(saved); if (Array.isArray(parsed)) { return parsed.map((t: LabelTemplate) => normalizeTemplate(t)); } } } catch (e) { } return []; }); const [selectedTemplateId, setSelectedTemplateId] = useState(() => { try { const savedList = localStorage.getItem('label_templates_list_v2'); const savedId = localStorage.getItem('selected_label_template_id_v2'); if (!savedList || !savedId) return ''; const parsed = JSON.parse(savedList); if (Array.isArray(parsed) && parsed.some((t: LabelTemplate) => t.id === savedId)) { return savedId; } } catch (e) { } return ''; }); const hasTemplates = templates.length > 0; const activeTemplate = useMemo(() => { if (!hasTemplates) return null; return templates.find((t) => t.id === selectedTemplateId) ?? templates[0]; }, [templates, selectedTemplateId, hasTemplates]); const workingTemplate = activeTemplate ?? EMPTY_TEMPLATE_PLACEHOLDER; // --- 2. Workflow Step Manager --- // Step 1: 'template-select' (Templates select/import + Associate excel data sources) // Step 2: 'template-design' (Visual designer workspace canvas) // Step 3: 'print-view' (Final pagination & print setting alignments grid) const [workflowStep, setWorkflowStep] = useState<'template-select' | 'template-design' | 'print-view'>('template-select'); // --- 3. 排版导出会话数据(不持久化,每次进入排版页重新上传) --- const [importData, setImportData] = useState(null); const [variableColumnMapping, setVariableColumnMapping] = useState>({}); const [previewSnapshot, setPreviewSnapshot] = useState<{ pages: (PrintableLabel | null)[][]; gridCols: number; labelsPerPage: number; totalPages: number; selectedCount: number; paper: PaperConfig; exportRange: ExportRangeConfig; } | null>(null); const [draftExportRange, setDraftExportRange] = useState(DEFAULT_EXPORT_RANGE); const [appliedExportData, setAppliedExportData] = useState<{ fileName: string; currentSheet: string; rows: RecordRow[]; mapping: Record; exportRange: ExportRangeConfig; } | null>(null); const [previewLayoutStale, setPreviewLayoutStale] = useState(false); const isMobile = useIsMobile(); const [pdfGenerating, setPdfGenerating] = useState(false); const [pdfProgress, setPdfProgress] = useState({ done: 0, total: 0, page: 0, totalPages: 0, phase: 'render', }); // --- 5. Selection and Active item states --- const [selectedElementIds, setSelectedElementIds] = useState([]); const [activeRowIndex, setActiveRowIndex] = useState(0); // Modal / Inline input values for template creations const [showAddTmplForm, setShowAddTmplForm] = useState(false); const [newTmplName, setNewTmplName] = useState(''); const [newTmplW, setNewTmplW] = useState(75); const [newTmplH, setNewTmplH] = useState(45); const [editingTemplateId, setEditingTemplateId] = useState(null); const [editTmplName, setEditTmplName] = useState(''); const [templateMenuId, setTemplateMenuId] = useState(null); const templateMenuAnchorRef = useRef(null); const [headerTemplateMenuOpen, setHeaderTemplateMenuOpen] = useState(false); const headerTemplateMenuRef = useRef(null); const importTemplateInputRef = useRef(null); const designHistory = useUndoRedo(); const skipDesignHistoryRef = useRef(false); const designHistorySessionRef = useRef<{ step: string; templateId: string | null }>({ step: '', templateId: null, }); const paper = workingTemplate.paper ?? DEFAULT_PAPER_CONFIG; const cutLine = workingTemplate.cutLine ?? DEFAULT_CUT_LINE_CONFIG; // Save template list changes(不将完整行数据写入模板列表,避免 localStorage 膨胀) const saveTemplatesToStorage = (updatedList: LabelTemplate[]) => { setTemplates(updatedList); try { const stripped = updatedList.map(stripTemplateForStorage); localStorage.setItem('label_templates_list_v2', JSON.stringify(stripped)); } catch (e) { } }; // Modify active template elements const handleTemplateChange = (updated: LabelTemplate) => { const updatedList = templates.map(t => t.id === updated.id ? updated : t); saveTemplatesToStorage(updatedList); }; const handleDesignTemplateChange = useCallback( (updated: LabelTemplate) => { if ( !skipDesignHistoryRef.current && workflowStep === 'template-design' && activeTemplate && updated.id === activeTemplate.id ) { designHistory.record(activeTemplate, updated); } skipDesignHistoryRef.current = false; handleTemplateChange(updated); }, [workflowStep, activeTemplate, designHistory, templates] ); const handleDesignUndo = useCallback(() => { if (!activeTemplate) return; const previous = designHistory.undo(activeTemplate); if (!previous) return; skipDesignHistoryRef.current = true; handleTemplateChange(previous); }, [activeTemplate, designHistory, templates]); const handleDesignRedo = useCallback(() => { if (!activeTemplate) return; const next = designHistory.redo(activeTemplate); if (!next) return; skipDesignHistoryRef.current = true; handleTemplateChange(next); }, [activeTemplate, designHistory, templates]); useEffect(() => { if (workflowStep !== 'template-design' || !activeTemplate?.id) return; const needReset = designHistorySessionRef.current.step !== 'template-design' || designHistorySessionRef.current.templateId !== activeTemplate.id; if (needReset) { designHistory.reset(); designHistorySessionRef.current = { step: workflowStep, templateId: activeTemplate.id, }; } }, [workflowStep, activeTemplate?.id, designHistory]); useEffect(() => { if (workflowStep !== 'template-design') return; const onKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null; if ( target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable) ) { return; } const mod = e.metaKey || e.ctrlKey; if (!mod) return; if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); handleDesignUndo(); } else if (e.key === 'y' || (e.key === 'z' && e.shiftKey) || (e.key === 'Z' && e.shiftKey)) { e.preventDefault(); handleDesignRedo(); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [workflowStep, handleDesignUndo, handleDesignRedo]); // Save active template selected state and sync its dataset const handleSelectTemplate = (id: string) => { setSelectedTemplateId(id); setSelectedElementIds([]); try { localStorage.setItem('selected_label_template_id_v2', id); } catch (e) { } setActiveRowIndex(0); }; useEffect(() => { if (!hasTemplates) { if (selectedTemplateId) setSelectedTemplateId(''); if (workflowStep !== 'template-select') setWorkflowStep('template-select'); return; } if (!templates.some((t) => t.id === selectedTemplateId)) { handleSelectTemplate(templates[0].id!); } }, [hasTemplates, templates, selectedTemplateId, workflowStep]); const handlePaperChange = (updated: PaperConfig) => { if (!activeTemplate) return; handleTemplateChange({ ...activeTemplate, paper: updated }); }; const handleCutLineChange = (next: CutLineConfig) => { if (!activeTemplate) return; handleTemplateChange({ ...activeTemplate, cutLine: next }); }; const openPrintView = (templateId?: string) => { if (!hasTemplates) return; if (templateId) { handleSelectTemplate(templateId); } setImportData(null); setVariableColumnMapping({}); setPreviewSnapshot(null); setAppliedExportData(null); setPreviewLayoutStale(false); setActiveRowIndex(0); setDraftExportRange(DEFAULT_EXPORT_RANGE); setWorkflowStep('print-view'); }; const handleDataLoaded = (data: ImportData) => { setImportData(data); setPreviewSnapshot(null); setAppliedExportData(null); setPreviewLayoutStale(false); setActiveRowIndex(0); setDraftExportRange(DEFAULT_EXPORT_RANGE); if (!activeTemplate) return; const vars = extractTemplateVariables(activeTemplate); setVariableColumnMapping(buildAutoVariableMapping(vars, data.columns)); }; const handleClearData = () => { setImportData(null); setVariableColumnMapping({}); setPreviewSnapshot(null); setAppliedExportData(null); setPreviewLayoutStale(false); setActiveRowIndex(0); setDraftExportRange(DEFAULT_EXPORT_RANGE); }; const handleMappingChange = (mapping: Record) => { setVariableColumnMapping(mapping); }; // Add customized new template const handleAddTemplateSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!newTmplName.trim()) { await showAlert('请先输入合法的模板名', { variant: 'warning' }); return; } const newId = `custom_tmpl_${Date.now()}`; const newTmpl: LabelTemplate = { id: newId, name: `${newTmplName}`, width: Math.max(10, Number(newTmplW) || 70), height: Math.max(10, Number(newTmplH) || 40), elements: [ { id: `desc_welcome`, type: 'text', name: '主文字内容', x: 5, y: 5, width: Math.max(10, newTmplW - 10), height: 10, content: '{变量}', fontSize: 12, textAlign: 'center', fontWeight: 'bold', barcodeFormat: 'CODE128', showText: false, fontFamily: 'sans' } ], paper: DEFAULT_PAPER_CONFIG, cutLine: DEFAULT_CUT_LINE_CONFIG, }; const list = [...templates, newTmpl]; saveTemplatesToStorage(list); handleSelectTemplate(newId); setNewTmplName(''); setShowAddTmplForm(false); setWorkflowStep('template-design'); }; const closeAddTemplateDialog = () => { setShowAddTmplForm(false); setNewTmplName(''); setNewTmplW(75); setNewTmplH(45); }; const openEditTemplateDialog = (tmpl: LabelTemplate) => { setEditingTemplateId(tmpl.id!); setEditTmplName(tmpl.name); }; const closeEditTemplateDialog = () => { setEditingTemplateId(null); setEditTmplName(''); }; const closeTemplateMenu = () => setTemplateMenuId(null); const handleEditTemplateSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!editingTemplateId) return; const trimmed = editTmplName.trim(); if (!trimmed) { await showAlert('请先输入合法的模板名', { variant: 'warning' }); return; } const target = templates.find((t) => t.id === editingTemplateId); if (!target) { closeEditTemplateDialog(); return; } saveTemplatesToStorage( templates.map((t) => (t.id === editingTemplateId ? { ...t, name: trimmed } : t)) ); closeEditTemplateDialog(); }; // Export templates list or single active template as standard json const handleExportTemplate = (tmpl: LabelTemplate) => { const dataStr = JSON.stringify(normalizeTemplate(tmpl), null, 2); const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr); const exportFileDefaultName = `${tmpl.name?.replace(/\s+/g, '_') || tmpl.id}_${Date.now()}.lad`; const linkElement = document.createElement('a'); linkElement.setAttribute('href', dataUri); linkElement.setAttribute('download', exportFileDefaultName); linkElement.click(); }; // Import custom template JSON uploader const handleImportTemplate = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = async (event) => { try { const parsed = JSON.parse(event.target?.result as string); if (parsed.width && parsed.height && Array.isArray(parsed.elements)) { const newId = `imported_${Date.now()}`; const newTmpl = normalizeTemplate({ id: newId, name: parsed.name || `导入外部模板_${Date.now()}`, width: Number(parsed.width) || 70, height: Number(parsed.height) || 40, elements: parsed.elements, variableList: parsed.variableList, variableDefaults: parsed.variableDefaults, paper: parsed.paper, cutLine: parsed.cutLine, showPdfCutLines: parsed.showPdfCutLines, }); const list = [...templates, newTmpl]; saveTemplatesToStorage(list); handleSelectTemplate(newId); await showAlert('模板读取解析成功,已追加至库中。', { variant: 'success' }); } else { await showAlert('模板格式错误:外部模板必须至少包含 width, height, 以及 elements 分组', { variant: 'error', }); } } catch (err) { await showAlert('文件解码失败,请确保您上传的是合法的模板文件。', { variant: 'error', }); } }; reader.readAsText(file); e.target.value = ''; // clears input }; // Delete customized template const handleDeleteTemplate = async (idToDelete: string) => { const confirmed = await showConfirm('您确定要彻底删除该标签模板吗?此步骤无法逆转。', { title: '删除模板', confirmLabel: '删除', variant: 'error', }); if (!confirmed) return; const remaining = templates.filter((t) => t.id !== idToDelete); saveTemplatesToStorage(remaining); if (selectedTemplateId === idToDelete) { if (remaining.length > 0) { handleSelectTemplate(remaining[0].id!); } else { setSelectedTemplateId(''); try { localStorage.removeItem('selected_label_template_id_v2'); } catch (e) { } setWorkflowStep('template-select'); } } }; const dataColumns = importData?.columns ?? []; const activeRows = importData?.rows ?? []; const templateVariables = useMemo( () => extractTemplateVariables(workingTemplate), [workingTemplate] ); const mappedRows = useMemo( () => activeRows.map((row) => applyRowVariableMapping( row, variableColumnMapping, templateVariables, workingTemplate.variableDefaults ) ), [activeRows, variableColumnMapping, templateVariables, workingTemplate.variableDefaults] ); const layoutResult = useMemo( () => buildPrintablePages(mappedRows, paper, workingTemplate, draftExportRange), [mappedRows, paper, workingTemplate, draftExportRange] ); const { pages: printablePages, gridCols: gridInfoCols, gridRows: gridInfoRows, labelsPerPage, totalPages, selectedCount, } = layoutResult; const gridInfo = useMemo( () => ({ cols: gridInfoCols, rows: gridInfoRows }), [gridInfoCols, gridInfoRows] ); const appliedMappedRows = useMemo(() => { if (!appliedExportData) return []; return appliedExportData.rows.map((row) => applyRowVariableMapping( row, appliedExportData.mapping, templateVariables, workingTemplate.variableDefaults ) ); }, [appliedExportData, templateVariables, workingTemplate.variableDefaults]); const appliedExportRange = appliedExportData?.exportRange ?? DEFAULT_EXPORT_RANGE; const appliedLayoutResult = useMemo( () => buildPrintablePages(appliedMappedRows, paper, workingTemplate, appliedExportRange), [appliedMappedRows, paper, workingTemplate, appliedExportRange] ); const dataStale = useMemo(() => { if (!importData) return appliedExportData !== null; if (!appliedExportData) return true; if ( importData.fileName !== appliedExportData.fileName || importData.currentSheet !== appliedExportData.currentSheet || importData.rows !== appliedExportData.rows ) { return true; } const draftKeys = Object.keys(variableColumnMapping).sort(); const appliedKeys = Object.keys(appliedExportData.mapping).sort(); if (draftKeys.length !== appliedKeys.length) return true; if (draftKeys.some((key) => variableColumnMapping[key] !== appliedExportData.mapping[key])) { return true; } return !exportRangeEquals(draftExportRange, appliedExportData.exportRange); }, [importData, variableColumnMapping, appliedExportData, draftExportRange]); // 移动端导出页实时同步数据,无需手动「应用数据」 useLayoutEffect(() => { if (!isMobile || workflowStep !== 'print-view') return; if (!importData || activeRows.length === 0) return; const nextExportRange = { ...draftExportRange }; setAppliedExportData({ fileName: importData.fileName, currentSheet: importData.currentSheet, rows: importData.rows, mapping: { ...variableColumnMapping }, exportRange: nextExportRange, }); setPreviewSnapshot({ pages: layoutResult.pages, gridCols: layoutResult.gridCols, labelsPerPage: layoutResult.labelsPerPage, totalPages: layoutResult.totalPages, selectedCount: layoutResult.selectedCount, paper, exportRange: nextExportRange, }); setPreviewLayoutStale(false); }, [ isMobile, workflowStep, importData, activeRows, variableColumnMapping, draftExportRange, layoutResult, paper, ]); const handleApplyData = async () => { if (!importData || activeRows.length === 0) { await showAlert('请先上传 Excel 或 CSV 数据。', { variant: 'warning' }); return; } if (selectedCount === 0) { await showAlert('当前数据范围内没有可预览的标签,请调整范围设置。', { variant: 'warning' }); return; } const nextExportRange = { ...draftExportRange }; setAppliedExportData({ fileName: importData.fileName, currentSheet: importData.currentSheet, rows: importData.rows, mapping: { ...variableColumnMapping }, exportRange: nextExportRange, }); setPreviewSnapshot({ pages: layoutResult.pages, gridCols: layoutResult.gridCols, labelsPerPage: layoutResult.labelsPerPage, totalPages: layoutResult.totalPages, selectedCount: layoutResult.selectedCount, paper, exportRange: nextExportRange, }); setPreviewLayoutStale(false); }; const handleRedrawPreview = async () => { if (!appliedExportData) { await showAlert('请先应用数据后再重新绘制预览。', { variant: 'warning' }); return; } if (appliedLayoutResult.selectedCount === 0) { await showAlert('当前数据范围内没有可预览的标签,请调整范围设置。', { variant: 'warning' }); return; } setPreviewSnapshot({ pages: appliedLayoutResult.pages, gridCols: appliedLayoutResult.gridCols, labelsPerPage: appliedLayoutResult.labelsPerPage, totalPages: appliedLayoutResult.totalPages, selectedCount: appliedLayoutResult.selectedCount, paper, exportRange: appliedExportRange, }); setPreviewLayoutStale(false); }; useEffect(() => { if (previewSnapshot !== null && appliedExportData !== null) { setPreviewLayoutStale(true); } }, [workingTemplate.elements, workingTemplate.width, workingTemplate.height]); useEffect(() => { if (workflowStep !== 'print-view') return; if (!appliedExportData) return; const layout = buildPrintablePages( appliedMappedRows, paper, workingTemplate, appliedExportData.exportRange ); setPreviewSnapshot({ pages: layout.pages, gridCols: layout.gridCols, labelsPerPage: layout.labelsPerPage, totalPages: layout.totalPages, selectedCount: layout.selectedCount, paper, exportRange: appliedExportData.exportRange, }); setPreviewLayoutStale(false); }, [ paper, workflowStep, appliedExportData, appliedMappedRows, workingTemplate.width, workingTemplate.height, ]); const pdfExportImage = useMemo(() => { const hasCode = workingTemplate.elements.some( (e) => e.type === 'barcode' || e.type === 'qrcode' ); return hasCode ? { imageFormat: 'png' as const, jsPdfFormat: 'PNG' as const, scale: 10 } : { imageFormat: 'jpeg' as const, jsPdfFormat: 'JPEG' as const, scale: 10 }; }, [workingTemplate.elements]); const exportPdf = async () => { if (!activeTemplate) return; if (!appliedExportData) { await showAlert('请先应用数据后再导出 PDF。', { variant: 'warning' }); return; } if (appliedLayoutResult.selectedCount === 0) { await showAlert('当前数据范围内没有可排版的标签,请调整范围设置。', { variant: 'warning' }); return; } const labelTotal = appliedLayoutResult.selectedCount; const exportTotalPages = appliedLayoutResult.totalPages; setPdfGenerating(true); setPdfProgress({ done: 0, total: labelTotal, page: 0, totalPages: exportTotalPages, phase: 'render', }); const safeName = activeTemplate.name.replace(/[^\w\u4e00-\u9fa5-]+/g, '_').slice(0, 40) || 'labels'; const progressLastUpdate = { current: 0 }; try { await exportLabelsPdfAsync({ paper, template: activeTemplate, printablePages: appliedLayoutResult.pages, gridCols: appliedLayoutResult.gridCols, cutLine, filename: `${safeName}_${exportTotalPages}页.pdf`, imageFormat: pdfExportImage.jsPdfFormat, renderLabel: (label) => renderLabelToDataUrl({ widthMm: activeTemplate.width, heightMm: activeTemplate.height, elements: activeTemplate.elements, rowData: label.row, rowIndex: label.sourceIndex, showBorder: false, mode: 'output', scale: pdfExportImage.scale, imageFormat: pdfExportImage.imageFormat, jpegQuality: 0.9, }), onProgress: (progress) => { const now = Date.now(); const isDone = progress.done >= progress.total || progress.phase === 'save'; if (isDone || now - progressLastUpdate.current >= 150) { progressLastUpdate.current = now; setPdfProgress(progress); } }, }); } catch (e) { console.error('PDF export failed', e); await showAlert('PDF 生成失败,请重试。', { variant: 'error' }); } finally { setPdfGenerating(false); } }; return (
{/* ========================================= SCREEN HEADER ========================================= */} {workflowStep === 'template-select' && (
{/* Title logo and slogan description */}

标签设计生成器

所见即所得

{workflowStep === 'template-select' && hasTemplates && (
{isMobile ? ( <>
setHeaderTemplateMenuOpen(false)} anchorRef={headerTemplateMenuRef} className="min-w-[148px] py-1 bg-white border border-gray-200 rounded-xl shadow-lg" >
) : ( <> )}
)}
)} {/* Primary Workspace scroll container */}
{/* ======================================================== STEP 1: Template Selection, Import, and Excel binds dashboard ======================================================== */} {workflowStep === 'template-select' && (
{hasTemplates ? (

标签模板库

{isMobile ? '选择模板 → 上传 Excel → 关联变量 → 排版生成标签' : '选择模板规格后进行设计与排版导出,或使用右上角按钮新建、导入模板。'}

) : (
{/*
*/}

还没有标签模板

{isMobile ? '导入已有模板,或在电脑端设计标签模板' : '创建模板开始可视化设计,或导入已有的模板文件。'}

)} {hasTemplates && (
{templates.map((tmpl) => { const isActive = tmpl.id === selectedTemplateId; const { displayWidthPx, displayHeightPx } = fitTemplatePreviewSize( tmpl.width, tmpl.height ); return (
handleSelectTemplate(tmpl.id!)} className={`bg-white border rounded-2xl p-5 shadow-xs transition-all flex flex-col cursor-pointer relative overflow-visible group select-none ${isActive // ? 'ring-2 ring-indigo-500 border-indigo-500 bg-indigo-50/5' ? 'border-gray-200 hover:shadow-md hover:border-indigo-200' : 'border-gray-200 hover:shadow-md hover:border-indigo-200' }`} > {/* {isActive && (
当前选中
)} */}

{tmpl.name}

{tmpl.width} × {tmpl.height} mm

{/* Preview */}
{/* Actions */}
{!isMobile && ( )} {isMobile && ( )}
); })}
)}
)} {/* ======================================================== STEP 2: The visual drag-and-drop label designer ======================================================== */} {workflowStep === 'template-design' && isMobile && hasTemplates && activeTemplate && ( setWorkflowStep('template-select')} onExport={() => openPrintView()} /> )} {workflowStep === 'template-design' && !isMobile && hasTemplates && activeTemplate && (
{/* Options bar — Photoshop style */}
| {activeTemplate.name} {activeTemplate.width} × {activeTemplate.height} mm
Shift/Ctrl+点击多选
{/* Workspace: toolbar + canvas + panels */}
{ }} paper={paper} />
)} {/* ======================================================== STEP 3: Pagination layouts & Printed grids configuration ======================================================== */} {workflowStep === 'print-view' && isMobile && hasTemplates && activeTemplate && ( setWorkflowStep('template-select')} onExportPdf={exportPdf} /> )} {workflowStep === 'print-view' && !isMobile && hasTemplates && activeTemplate && (
| {activeTemplate.name} {gridInfo.cols}×{gridInfo.rows} · {totalPages} 页 ·{' '} {selectedCount < activeRows.length ? `${selectedCount}/${activeRows.length} 行` : `${activeRows.length} 行`}
)}
{editingTemplateId && (
e.stopPropagation()} role="dialog" aria-modal="true" aria-label="重命名模板" >
setEditTmplName(e.target.value)} className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:border-indigo-400 focus:outline-none" />
)} {showAddTmplForm && (
e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="add-template-dialog-title" >

新建模板

setNewTmplName(e.target.value)} className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:border-indigo-400 focus:outline-none" />
setNewTmplW(Number(e.target.value))} className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none" />
setNewTmplH(Number(e.target.value))} className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none" />
)} {pdfGenerating && (

{pdfProgress.phase === 'save' ? '正在保存 PDF 文件…' : '正在生成 PDF'}

{pdfProgress.phase === 'save' ? '即将完成,请稍候' : `第 ${pdfProgress.page} / ${pdfProgress.totalPages} 页 · ${pdfProgress.done} / ${pdfProgress.total} 张标签`}

)}
); }