import React, { useState, useMemo, useEffect } 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 { 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 { useAppDialog } from './components/AppDialog'; import { FileDown, Code, Wand2, Loader2, Plus, Download, Upload, Trash2, X, } from 'lucide-react'; 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 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); }; // 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} (${newTmplW}x${newTmplH}mm)`, 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); }; // 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}.json`; 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('JSON格式错误:外部模板必须至少包含 width, height, 以及 elements 分组', { variant: 'error', }); } } catch (err) { await showAlert('文件解码失败,请确保您上传的是合法的 json 模板结构文件。', { 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]); 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 ========================================= */} {!(isMobile && workflowStep === 'print-view') && (
{/* Title logo and slogan description */}

{isMobile ? '批量标签排版印刷生成器' : '批量标签排版印刷生成器'} PDF

{workflowStep !== 'template-design' && workflowStep !== 'print-view' && !isMobile && (

数据所见即所得:可视化添加变量、绑定 Excel 多表、快速二元素对齐排料、高保真物理 A4 无损裁切

)}
{workflowStep === 'template-select' && hasTemplates && !isMobile && (
)}
)} {/* Primary Workspace scroll container */}
{/* ======================================================== STEP 1: Template Selection, Import, and Excel binds dashboard ======================================================== */} {workflowStep === 'template-select' && (
{hasTemplates ? (

标签模板库

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

) : (

还没有标签模板

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

{!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-hidden 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' }`} > {isActive && (
当前选中
)}
{tmpl.width} × {tmpl.height} mm

{tmpl.name}

{tmpl.elements.length} 个元素

{/* Preview */}
{/* Actions */}
{!isMobile && ( )} {!isMobile && ( <> )}
); })}
)}
)} {/* ======================================================== STEP 2: The visual drag-and-drop label designer ======================================================== */} {workflowStep === 'template-design' && isMobile && hasTemplates && (

可视化模板设计需要在电脑端操作。移动端可直接上传数据并生成 PDF。

)} {workflowStep === 'template-design' && !isMobile && hasTemplates && activeTemplate && (
{/* Options bar — Photoshop style */}
| {activeTemplate.name} {activeTemplate.width} × {activeTemplate.height} mm
Shift/Ctrl+点击多选 · V 移动 · T 文本 · 图片工具栏
{/* 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} 行`}
)}
{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} 张标签`}

)}
); }