1522 lines
60 KiB
TypeScript
1522 lines
60 KiB
TypeScript
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<LabelTemplate[]>(() => {
|
||
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<string>(() => {
|
||
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<ImportData | null>(null);
|
||
const [variableColumnMapping, setVariableColumnMapping] = useState<Record<string, string>>({});
|
||
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<ExportRangeConfig>(DEFAULT_EXPORT_RANGE);
|
||
const [appliedExportData, setAppliedExportData] = useState<{
|
||
fileName: string;
|
||
currentSheet: string;
|
||
rows: RecordRow[];
|
||
mapping: Record<string, string>;
|
||
exportRange: ExportRangeConfig;
|
||
} | null>(null);
|
||
const [previewLayoutStale, setPreviewLayoutStale] = useState(false);
|
||
const isMobile = useIsMobile();
|
||
|
||
const [pdfGenerating, setPdfGenerating] = useState(false);
|
||
const [pdfProgress, setPdfProgress] = useState<PdfExportProgress>({
|
||
done: 0,
|
||
total: 0,
|
||
page: 0,
|
||
totalPages: 0,
|
||
phase: 'render',
|
||
});
|
||
|
||
// --- 5. Selection and Active item states ---
|
||
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
|
||
const [activeRowIndex, setActiveRowIndex] = useState<number>(0);
|
||
// Modal / Inline input values for template creations
|
||
const [showAddTmplForm, setShowAddTmplForm] = useState<boolean>(false);
|
||
const [newTmplName, setNewTmplName] = useState<string>('');
|
||
const [newTmplW, setNewTmplW] = useState<number>(75);
|
||
const [newTmplH, setNewTmplH] = useState<number>(45);
|
||
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
|
||
const [editTmplName, setEditTmplName] = useState<string>('');
|
||
const [templateMenuId, setTemplateMenuId] = useState<string | null>(null);
|
||
const templateMenuAnchorRef = useRef<HTMLDivElement | null>(null);
|
||
const [headerTemplateMenuOpen, setHeaderTemplateMenuOpen] = useState(false);
|
||
const headerTemplateMenuRef = useRef<HTMLDivElement | null>(null);
|
||
const importTemplateInputRef = useRef<HTMLInputElement | null>(null);
|
||
const designHistory = useUndoRedo<LabelTemplate>();
|
||
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<string, string>) => {
|
||
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<HTMLInputElement>) => {
|
||
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 (
|
||
<div
|
||
className={`flex flex-col font-sans h-dvh max-h-dvh overflow-hidden ${workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile)
|
||
? 'bg-[#323232] text-[#e8e8e8]'
|
||
: workflowStep === 'print-view' && isMobile
|
||
? 'bg-[#323232] text-[#e8e8e8]'
|
||
: 'bg-slate-50 text-slate-800'
|
||
}`}
|
||
>
|
||
{/*
|
||
=========================================
|
||
SCREEN HEADER
|
||
=========================================
|
||
*/}
|
||
{workflowStep === 'template-select' && (
|
||
<header
|
||
className={`no-print bg-indigo-700 border-b border-indigo-900 shrink-0 shadow flex flex-wrap items-center justify-between gap-4 mobile-template-header app-header-sticky ${isMobile && workflowStep === 'template-select' && hasTemplates
|
||
? 'mobile-template-header-row'
|
||
: ''
|
||
} px-4 py-3 md:px-6 md:py-4`}
|
||
>
|
||
{/* Title logo and slogan description */}
|
||
<div
|
||
className={`flex items-center gap-3 min-w-0 ${isMobile && workflowStep === 'template-select' && hasTemplates ? 'flex-1' : ''
|
||
}`}
|
||
>
|
||
<img src={logoUrl} alt="" className="w-10 h-10" aria-hidden />
|
||
<div className="min-w-0">
|
||
<h1 className="text-[18px] font-bold tracking-wider flex items-center gap-2 text-slate-100 uppercase">
|
||
<span className="truncate">标签设计生成器</span>
|
||
</h1>
|
||
<p className="text-[11px] text-slate-400">
|
||
所见即所得
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{workflowStep === 'template-select' && hasTemplates && (
|
||
<div
|
||
className={`flex items-center gap-2 shrink-0 ${isMobile ? 'ml-auto' : 'flex-wrap'
|
||
}`}
|
||
>
|
||
{isMobile ? (
|
||
<>
|
||
<input
|
||
ref={importTemplateInputRef}
|
||
type="file"
|
||
accept=".lad"
|
||
onChange={handleImportTemplate}
|
||
className="hidden"
|
||
/>
|
||
<div ref={headerTemplateMenuRef} className="relative shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => setHeaderTemplateMenuOpen((open) => !open)}
|
||
className="mobile-header-menu-btn"
|
||
aria-expanded={headerTemplateMenuOpen}
|
||
aria-haspopup="menu"
|
||
aria-label="更多"
|
||
>
|
||
<MoreHorizontal className="w-5 h-5 shrink-0" />
|
||
</button>
|
||
<AnchoredMenu
|
||
open={headerTemplateMenuOpen}
|
||
onClose={() => setHeaderTemplateMenuOpen(false)}
|
||
anchorRef={headerTemplateMenuRef}
|
||
className="min-w-[148px] py-1 bg-white border border-gray-200 rounded-xl shadow-lg"
|
||
>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setHeaderTemplateMenuOpen(false);
|
||
setShowAddTmplForm(true);
|
||
}}
|
||
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
|
||
>
|
||
<Plus className="w-4 h-4 shrink-0" />
|
||
新建模板
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setHeaderTemplateMenuOpen(false);
|
||
importTemplateInputRef.current?.click();
|
||
}}
|
||
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
|
||
>
|
||
<Upload className="w-4 h-4 shrink-0" />
|
||
导入模板
|
||
</button>
|
||
</AnchoredMenu>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowAddTmplForm(true)}
|
||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-indigo-600 text-white rounded-xl text-xs font-semibold whitespace-nowrap transition hover:bg-indigo-800 shadow-sm cursor-pointer"
|
||
>
|
||
<Plus className="w-4 h-4 shrink-0" />
|
||
新建模板
|
||
</button>
|
||
<label
|
||
className="inline-flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-800 text-white rounded-xl font-semibold whitespace-nowrap transition shadow-sm cursor-pointer px-4 py-2 text-xs"
|
||
>
|
||
<Upload className="w-4 h-4 shrink-0" />
|
||
导入模板
|
||
<input
|
||
type="file"
|
||
accept=".lad"
|
||
onChange={handleImportTemplate}
|
||
className="hidden"
|
||
/>
|
||
</label>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</header>
|
||
)}
|
||
|
||
{/* Primary Workspace scroll container */}
|
||
<main
|
||
className={`no-print flex-1 flex flex-col w-full min-h-0 ${workflowStep === 'template-design' || workflowStep === 'print-view'
|
||
? 'overflow-hidden'
|
||
: 'overflow-y-auto scrollbar-hide overscroll-contain p-4 md:p-6 max-w-[1720px] mx-auto gap-6'
|
||
}`}
|
||
>
|
||
|
||
{/* ========================================================
|
||
STEP 1: Template Selection, Import, and Excel binds dashboard
|
||
======================================================== */}
|
||
{workflowStep === 'template-select' && (
|
||
<div className="space-y-4 md:space-y-6">
|
||
{hasTemplates ? (
|
||
<div>
|
||
<h2 className="text-base font-bold text-gray-900">标签模板库</h2>
|
||
<p className="text-xs text-gray-400 mt-1">
|
||
{isMobile
|
||
? '选择模板 → 上传 Excel → 关联变量 → 排版生成标签'
|
||
: '选择模板规格后进行设计与排版导出,或使用右上角按钮新建、导入模板。'}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center text-center px-6 py-16 md:py-24">
|
||
{/* <div className="w-16 h-16 rounded-2xl bg-indigo-50 flex items-center justify-center mb-5 border border-indigo-100">
|
||
<img src={logoUrl} alt="" className="w-12 h-12" aria-hidden />
|
||
</div> */}
|
||
<h2 className="text-lg font-bold text-gray-900">还没有标签模板</h2>
|
||
<p className="text-sm text-gray-500 mt-2 max-w-md leading-relaxed">
|
||
{isMobile
|
||
? '导入已有模板,或在电脑端设计标签模板'
|
||
: '创建模板开始可视化设计,或导入已有的模板文件。'}
|
||
</p>
|
||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowAddTmplForm(true)}
|
||
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-semibold transition shadow-sm cursor-pointer"
|
||
>
|
||
<Plus className="w-4 h-4 shrink-0" />
|
||
新建模板
|
||
</button>
|
||
<label className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-white border border-gray-200 hover:border-indigo-300 text-gray-700 rounded-xl text-sm font-semibold transition shadow-sm cursor-pointer shrink-0">
|
||
<Upload className="w-4 h-4 shrink-0" />
|
||
导入模板
|
||
<input
|
||
type="file"
|
||
accept=".lad"
|
||
onChange={handleImportTemplate}
|
||
className="hidden"
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{hasTemplates && (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{templates.map((tmpl) => {
|
||
const isActive = tmpl.id === selectedTemplateId;
|
||
const { displayWidthPx, displayHeightPx } = fitTemplatePreviewSize(
|
||
tmpl.width,
|
||
tmpl.height
|
||
);
|
||
|
||
return (
|
||
<div
|
||
key={tmpl.id}
|
||
onClick={() => 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 && (
|
||
<div
|
||
className="absolute top-0 right-0 py-1.5 px-3 text-[10px] font-bold rounded-bl-xl text-white"
|
||
style={{ backgroundColor: '#6366f1' }}
|
||
>
|
||
当前选中
|
||
</div>
|
||
)} */}
|
||
|
||
<div className="space-y-1 mb-3 min-w-0">
|
||
<h3 className="text-sm font-bold text-gray-900 mt-1 leading-snug truncate">
|
||
{tmpl.name}
|
||
</h3>
|
||
<p className="text-[10px] text-gray-400 flex items-center gap-1">
|
||
<span className="text-[10px] font-mono font-extrabold text-indigo-700 bg-indigo-50 px-2 py-0.5 rounded-lg border border-indigo-100">
|
||
{tmpl.width} × {tmpl.height} mm
|
||
</span>
|
||
</p>
|
||
</div>
|
||
|
||
{/* Preview */}
|
||
<div
|
||
className="rounded-xl bg-gray-50 p-3 overflow-hidden flex items-center justify-center"
|
||
style={{
|
||
minHeight: `${TEMPLATE_PREVIEW_MAX_H + 24}px`,
|
||
}}
|
||
>
|
||
<div
|
||
className="bg-white shadow-sm shrink-0 overflow-hidden"
|
||
style={{
|
||
width: `${displayWidthPx}px`,
|
||
height: `${displayHeightPx}px`,
|
||
}}
|
||
>
|
||
<CanvasLabelImage
|
||
widthMm={tmpl.width}
|
||
heightMm={tmpl.height}
|
||
elements={tmpl.elements}
|
||
rowData={null}
|
||
rowIndex={0}
|
||
showBorder={true}
|
||
variableDefaults={tmpl.variableDefaults ?? null}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className={`flex flex-wrap items-center gap-2 mt-4 text-xs ${isMobile ? 'mobile-template-actions' : ''}`}>
|
||
{!isMobile && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleSelectTemplate(tmpl.id!);
|
||
setWorkflowStep('template-design');
|
||
}}
|
||
className="shrink-0 whitespace-nowrap inline-flex items-center gap-1 px-3 py-1.5 bg-gray-50 text-indigo-700 border border-gray-100 hover:border-indigo-300 hover:bg-indigo-50 rounded-lg text-[11px] font-bold transition-colors cursor-pointer"
|
||
>
|
||
<Code className="w-3.5 h-3.5 shrink-0" />
|
||
设计
|
||
</button>
|
||
)}
|
||
{isMobile && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleSelectTemplate(tmpl.id!);
|
||
setWorkflowStep('template-design');
|
||
}}
|
||
className="mobile-secondary-btn flex-1 min-w-0 justify-center min-h-[44px] px-3 py-2.5 rounded-xl text-[13px] font-semibold"
|
||
>
|
||
<Code className="w-4 h-4 shrink-0" />
|
||
设计
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
openPrintView(tmpl.id!);
|
||
}}
|
||
className={`inline-flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white transition shadow-xs cursor-pointer ${isMobile
|
||
? 'mobile-primary-btn flex-1 min-w-0 justify-center min-h-[44px] px-4 py-2.5 rounded-xl text-[13px] font-semibold'
|
||
: 'shrink-0 whitespace-nowrap px-3 py-1.5 rounded-lg text-[11px] font-bold'
|
||
}`}
|
||
>
|
||
<Printer className={`${isMobile ? 'w-4 h-4' : 'w-3.5 h-3.5'} shrink-0`} />
|
||
{isMobile ? '生成' : '排版导出'}
|
||
</button>
|
||
<div
|
||
ref={templateMenuId === tmpl.id ? templateMenuAnchorRef : null}
|
||
className="relative shrink-0"
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setTemplateMenuId((id) => (id === tmpl.id ? null : tmpl.id!));
|
||
}}
|
||
className={`bg-gray-50 text-gray-600 hover:bg-gray-100 active:bg-gray-200 border border-gray-100 hover:border-gray-300 inline-flex items-center justify-center gap-1 transition cursor-pointer ${isMobile
|
||
? 'mobile-icon-btn min-h-[44px] px-3 rounded-xl text-[13px] font-medium'
|
||
: 'shrink-0 whitespace-nowrap px-2.5 py-1.5 rounded-lg text-[11px] font-semibold'
|
||
}`}
|
||
aria-expanded={templateMenuId === tmpl.id}
|
||
aria-haspopup="menu"
|
||
>
|
||
<MoreHorizontal className={`${isMobile ? 'w-5 h-5' : 'w-4 h-4'} shrink-0`} />
|
||
</button>
|
||
<AnchoredMenu
|
||
open={templateMenuId === tmpl.id}
|
||
onClose={closeTemplateMenu}
|
||
anchorRef={templateMenuAnchorRef}
|
||
className="min-w-[120px] py-1 bg-white border border-gray-200 rounded-xl shadow-lg"
|
||
>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
closeTemplateMenu();
|
||
openEditTemplateDialog(tmpl);
|
||
}}
|
||
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
|
||
>
|
||
<Pencil className="w-4 h-4 shrink-0" />
|
||
编辑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
closeTemplateMenu();
|
||
handleExportTemplate(tmpl);
|
||
}}
|
||
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
|
||
>
|
||
<FileDown className="w-4 h-4 shrink-0" />
|
||
导出
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
closeTemplateMenu();
|
||
void handleDeleteTemplate(tmpl.id!);
|
||
}}
|
||
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-rose-600 hover:bg-rose-50 active:bg-rose-100 cursor-pointer"
|
||
>
|
||
<Trash2 className="w-4 h-4 shrink-0" />
|
||
删除
|
||
</button>
|
||
</AnchoredMenu>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ========================================================
|
||
STEP 2: The visual drag-and-drop label designer
|
||
======================================================== */}
|
||
{workflowStep === 'template-design' && isMobile && hasTemplates && activeTemplate && (
|
||
<MobileDesignView
|
||
template={activeTemplate}
|
||
paper={paper}
|
||
selectedElementIds={selectedElementIds}
|
||
onSelectElements={setSelectedElementIds}
|
||
onChangeTemplate={handleDesignTemplateChange}
|
||
canUndo={designHistory.canUndo}
|
||
canRedo={designHistory.canRedo}
|
||
onUndo={handleDesignUndo}
|
||
onRedo={handleDesignRedo}
|
||
onBack={() => setWorkflowStep('template-select')}
|
||
onExport={() => openPrintView()}
|
||
/>
|
||
)}
|
||
|
||
{workflowStep === 'template-design' && !isMobile && hasTemplates && activeTemplate && (
|
||
<div className="flex-1 flex flex-col min-h-0">
|
||
{/* Options bar — Photoshop style */}
|
||
<div className="ps-options-bar">
|
||
<button
|
||
onClick={() => setWorkflowStep('template-select')}
|
||
className="text-[#aaa] hover:text-white transition cursor-pointer"
|
||
>
|
||
← 返回
|
||
</button>
|
||
<span className="text-[#666]">|</span>
|
||
<span className="font-medium text-[#e8e8e8] truncate max-w-[200px]">
|
||
{activeTemplate.name}
|
||
</span>
|
||
<span className="text-[#666] text-[10px]">
|
||
{activeTemplate.width} × {activeTemplate.height} mm
|
||
</span>
|
||
<div className="flex-1" />
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={handleDesignUndo}
|
||
disabled={!designHistory.canUndo}
|
||
title="撤销 (Ctrl+Z)"
|
||
className="inline-flex items-center justify-center w-7 h-7 text-[#ccc] hover:text-white hover:bg-[#444] rounded transition cursor-pointer disabled:opacity-35 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-[#ccc]"
|
||
>
|
||
<Undo2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleDesignRedo}
|
||
disabled={!designHistory.canRedo}
|
||
title="恢复 (Ctrl+Shift+Z / Ctrl+Y)"
|
||
className="inline-flex items-center justify-center w-7 h-7 text-[#ccc] hover:text-white hover:bg-[#444] rounded transition cursor-pointer disabled:opacity-35 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-[#ccc]"
|
||
>
|
||
<Redo2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
<span className="hidden md:inline text-[10px] text-[#666]">
|
||
Shift/Ctrl+点击多选
|
||
</span>
|
||
<button
|
||
onClick={() => openPrintView()}
|
||
className="inline-flex items-center gap-1 px-3 py-1 bg-[#31a8ff] hover:bg-[#2890d8] text-white text-[11px] font-semibold transition cursor-pointer"
|
||
>
|
||
<FileDown className="w-3 h-3" />
|
||
排版导出
|
||
</button>
|
||
</div>
|
||
|
||
{/* Workspace: toolbar + canvas + panels */}
|
||
<div className="flex-1 flex min-h-0">
|
||
<LabelDesigner
|
||
template={activeTemplate}
|
||
onChange={handleDesignTemplateChange}
|
||
selectedElementIds={selectedElementIds}
|
||
onSelectElements={setSelectedElementIds}
|
||
/>
|
||
<PropSidebar
|
||
template={activeTemplate}
|
||
onChangeTemplate={handleDesignTemplateChange}
|
||
selectedElementIds={selectedElementIds}
|
||
onSelectElements={setSelectedElementIds}
|
||
activeTab="element"
|
||
onChangeTab={() => { }}
|
||
paper={paper}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ========================================================
|
||
STEP 3: Pagination layouts & Printed grids configuration
|
||
======================================================== */}
|
||
{workflowStep === 'print-view' && isMobile && hasTemplates && activeTemplate && (
|
||
<MobileExportView
|
||
template={activeTemplate}
|
||
templateName={activeTemplate.name ?? '标签'}
|
||
templateVariables={templateVariables}
|
||
importData={importData}
|
||
onDataLoaded={handleDataLoaded}
|
||
onClear={handleClearData}
|
||
dataColumns={dataColumns}
|
||
variableColumnMapping={variableColumnMapping}
|
||
onChangeMapping={handleMappingChange}
|
||
paper={previewSnapshot?.paper ?? paper}
|
||
onChangePaper={handlePaperChange}
|
||
rows={mappedRows}
|
||
draftExportRange={draftExportRange}
|
||
onChangeDraftExportRange={setDraftExportRange}
|
||
cutLine={cutLine}
|
||
onChangeCutLine={handleCutLineChange}
|
||
printablePages={previewSnapshot?.pages ?? []}
|
||
gridCols={previewSnapshot?.gridCols ?? gridInfo.cols}
|
||
labelsPerPage={previewSnapshot?.labelsPerPage ?? labelsPerPage}
|
||
totalPages={previewSnapshot?.totalPages ?? totalPages}
|
||
selectedCount={previewSnapshot?.selectedCount ?? selectedCount}
|
||
draftTotalPages={totalPages}
|
||
draftSelectedCount={selectedCount}
|
||
totalRowCount={activeRows.length}
|
||
previewReady={previewSnapshot !== null}
|
||
previewLayoutStale={previewLayoutStale}
|
||
activeRowIndex={activeRowIndex}
|
||
onSelectRowIndex={setActiveRowIndex}
|
||
pdfGenerating={pdfGenerating}
|
||
onBack={() => setWorkflowStep('template-select')}
|
||
onExportPdf={exportPdf}
|
||
/>
|
||
)}
|
||
|
||
{workflowStep === 'print-view' && !isMobile && hasTemplates && activeTemplate && (
|
||
<div className="flex-1 flex flex-col min-h-0 print-view-shell">
|
||
<div className="ps-options-bar">
|
||
<button
|
||
type="button"
|
||
onClick={() => setWorkflowStep('template-select')}
|
||
className="text-[#aaa] hover:text-white transition cursor-pointer"
|
||
>
|
||
← 返回
|
||
</button>
|
||
<span className="text-[#666]">|</span>
|
||
<span className="font-medium text-[#e8e8e8] truncate max-w-[180px]">
|
||
{activeTemplate.name}
|
||
</span>
|
||
<span className="text-[#666] text-[10px]">
|
||
{gridInfo.cols}×{gridInfo.rows} · {totalPages} 页 ·{' '}
|
||
{selectedCount < activeRows.length
|
||
? `${selectedCount}/${activeRows.length} 行`
|
||
: `${activeRows.length} 行`}
|
||
</span>
|
||
<div className="flex-1" />
|
||
<button
|
||
type="button"
|
||
onClick={() => setWorkflowStep('template-design')}
|
||
className="inline-flex items-center gap-1 px-2.5 py-1 border border-[#555] hover:border-[#777] text-[#ccc] hover:text-white text-[10px] transition cursor-pointer"
|
||
>
|
||
修改模板
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={exportPdf}
|
||
disabled={
|
||
!appliedExportData || appliedLayoutResult.selectedCount === 0 || pdfGenerating
|
||
}
|
||
className={`inline-flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold transition cursor-pointer ${appliedExportData && appliedLayoutResult.selectedCount > 0 && !pdfGenerating
|
||
? 'bg-[#31a8ff] hover:bg-[#2890d8] text-white'
|
||
: 'bg-[#444] text-[#666] cursor-not-allowed'
|
||
}`}
|
||
>
|
||
{pdfGenerating ? (
|
||
<Loader2 className="w-3 h-3 shrink-0 animate-spin" />
|
||
) : (
|
||
<FileDown className="w-3 h-3 shrink-0" />
|
||
)}
|
||
{pdfGenerating ? '生成中…' : '生成并保存 PDF'}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 flex min-h-0 print-view-body overflow-hidden">
|
||
<LayoutPreview
|
||
paper={previewSnapshot !== null ? previewSnapshot.paper : paper}
|
||
template={activeTemplate}
|
||
printablePages={previewSnapshot?.pages ?? []}
|
||
gridCols={previewSnapshot?.gridCols ?? gridInfo.cols}
|
||
labelsPerPage={previewSnapshot?.labelsPerPage ?? labelsPerPage}
|
||
totalPages={previewSnapshot?.totalPages ?? totalPages}
|
||
selectedCount={previewSnapshot?.selectedCount ?? selectedCount}
|
||
totalRowCount={activeRows.length}
|
||
draftTotalPages={totalPages}
|
||
draftSelectedCount={selectedCount}
|
||
previewReady={previewSnapshot !== null}
|
||
previewLayoutStale={previewLayoutStale}
|
||
cutLine={cutLine}
|
||
activeRowIndex={activeRowIndex}
|
||
onSelectRowIndex={setActiveRowIndex}
|
||
/>
|
||
<ExportSidebar
|
||
importData={importData}
|
||
onDataLoaded={handleDataLoaded}
|
||
onClear={handleClearData}
|
||
templateName={activeTemplate.name ?? '标签'}
|
||
templateVariables={templateVariables}
|
||
dataColumns={dataColumns}
|
||
variableColumnMapping={variableColumnMapping}
|
||
onChangeMapping={handleMappingChange}
|
||
paper={paper}
|
||
onChangePaper={handlePaperChange}
|
||
template={activeTemplate}
|
||
rows={mappedRows}
|
||
draftExportRange={draftExportRange}
|
||
onChangeDraftExportRange={setDraftExportRange}
|
||
cutLine={cutLine}
|
||
onChangeCutLine={handleCutLineChange}
|
||
previewReady={previewSnapshot !== null}
|
||
dataStale={dataStale}
|
||
previewLayoutStale={previewLayoutStale}
|
||
dataApplied={appliedExportData !== null}
|
||
onApplyData={handleApplyData}
|
||
onRedrawPreview={handleRedrawPreview}
|
||
draftSelectedCount={selectedCount}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</main>
|
||
|
||
{editingTemplateId && (
|
||
<div
|
||
className="fixed inset-0 z-[9000] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4"
|
||
onClick={closeEditTemplateDialog}
|
||
role="presentation"
|
||
>
|
||
<div
|
||
className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden"
|
||
onClick={(e) => e.stopPropagation()}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="重命名模板"
|
||
>
|
||
<form onSubmit={handleEditTemplateSubmit} className="px-6 py-5 space-y-4">
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-gray-500 mb-1.5">
|
||
模板名称
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
autoFocus
|
||
placeholder="请输入模板名称"
|
||
value={editTmplName}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<button
|
||
type="button"
|
||
onClick={closeEditTemplateDialog}
|
||
className="px-4 py-2 border border-gray-200 text-gray-600 hover:bg-gray-50 rounded-lg text-sm font-medium cursor-pointer"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm font-semibold cursor-pointer"
|
||
>
|
||
保存
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showAddTmplForm && (
|
||
<div
|
||
className="fixed inset-0 z-[9000] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4"
|
||
onClick={closeAddTemplateDialog}
|
||
role="presentation"
|
||
>
|
||
<div
|
||
className="bg-white rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden"
|
||
onClick={(e) => e.stopPropagation()}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="add-template-dialog-title"
|
||
>
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||
<h3
|
||
id="add-template-dialog-title"
|
||
className="text-sm font-bold text-gray-900"
|
||
>
|
||
新建模板
|
||
</h3>
|
||
<button
|
||
type="button"
|
||
onClick={closeAddTemplateDialog}
|
||
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition cursor-pointer"
|
||
aria-label="关闭"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
<form onSubmit={handleAddTemplateSubmit} className="px-6 py-5 space-y-4">
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-gray-500 mb-1.5">
|
||
模板名称
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
autoFocus
|
||
placeholder="如: 电商仓外贴标 70x40"
|
||
value={newTmplName}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-gray-500 mb-1.5">
|
||
标签宽度 (mm)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
required
|
||
min="10"
|
||
max="150"
|
||
value={newTmplW}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-gray-500 mb-1.5">
|
||
标签高度 (mm)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
required
|
||
min="10"
|
||
max="150"
|
||
value={newTmplH}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<button
|
||
type="button"
|
||
onClick={closeAddTemplateDialog}
|
||
className="px-4 py-2 border border-gray-200 text-gray-600 hover:bg-gray-50 rounded-lg text-sm font-medium cursor-pointer"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm font-semibold cursor-pointer"
|
||
>
|
||
创建并进入设计
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{pdfGenerating && (
|
||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-950/70 backdrop-blur-sm">
|
||
<div className="bg-white rounded-2xl shadow-2xl px-8 py-6 flex flex-col items-center gap-4 min-w-[280px]">
|
||
<Loader2 className="w-10 h-10 text-indigo-600 animate-spin" />
|
||
<div className="text-center">
|
||
<p className="text-sm font-bold text-gray-900">
|
||
{pdfProgress.phase === 'save' ? '正在保存 PDF 文件…' : '正在生成 PDF'}
|
||
</p>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
{pdfProgress.phase === 'save'
|
||
? '即将完成,请稍候'
|
||
: `第 ${pdfProgress.page} / ${pdfProgress.totalPages} 页 · ${pdfProgress.done} / ${pdfProgress.total} 张标签`}
|
||
</p>
|
||
</div>
|
||
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full bg-indigo-600 transition-all duration-200"
|
||
style={{
|
||
width: pdfProgress.total
|
||
? `${(pdfProgress.done / pdfProgress.total) * 100}%`
|
||
: '0%',
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|