1183 lines
46 KiB
TypeScript
1183 lines
46 KiB
TypeScript
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<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 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<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} (${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<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('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 (
|
||
<div
|
||
className={`min-h-screen flex flex-col font-sans ${
|
||
workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile)
|
||
? 'bg-[#323232] text-[#e8e8e8] h-screen overflow-hidden'
|
||
: workflowStep === 'print-view' && isMobile
|
||
? 'bg-slate-100 text-slate-800 h-screen overflow-hidden'
|
||
: 'bg-slate-50 text-slate-800'
|
||
}`}
|
||
>
|
||
{/*
|
||
=========================================
|
||
SCREEN HEADER
|
||
=========================================
|
||
*/}
|
||
{!(isMobile && workflowStep === 'print-view') && (
|
||
<header
|
||
className={`no-print bg-slate-950 border-b border-slate-900 shrink-0 shadow flex flex-wrap items-center justify-between gap-4 mobile-template-header ${
|
||
workflowStep === 'template-design' || workflowStep === 'print-view' ? 'px-4 py-2' : '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">
|
||
<div className="p-2 md:p-2.5 bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 rounded-xl shadow-inner shrink-0">
|
||
<Wand2 className="w-5 h-5" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<h1 className="text-sm font-bold tracking-wider flex items-center gap-2 text-slate-100 uppercase">
|
||
<span className="truncate">{isMobile ? '批量标签排版印刷生成器' : '批量标签排版印刷生成器'}</span>
|
||
<span className="text-[9.5px] bg-indigo-600 text-white font-extrabold px-1.5 py-0.5 rounded shadow-sm tracking-normal shrink-0">PDF</span>
|
||
</h1>
|
||
{workflowStep !== 'template-design' && workflowStep !== 'print-view' && !isMobile && (
|
||
<p className="text-[11px] text-slate-400">
|
||
数据所见即所得:可视化添加变量、绑定 Excel 多表、快速二元素对齐排料、高保真物理 A4 无损裁切
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{workflowStep === 'template-select' && hasTemplates && !isMobile && (
|
||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||
<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-700 shadow-sm cursor-pointer"
|
||
>
|
||
<Plus className="w-4 h-4 shrink-0" />
|
||
新建模板
|
||
</button>
|
||
<label className="inline-flex items-center gap-1.5 px-4 py-2 bg-white/10 hover:bg-white/15 border border-slate-700 text-slate-100 rounded-xl text-xs font-semibold whitespace-nowrap transition shadow-sm cursor-pointer">
|
||
<Upload className="w-4 h-4 shrink-0" />
|
||
导入 JSON 模板
|
||
<input
|
||
type="file"
|
||
accept=".json"
|
||
onChange={handleImportTemplate}
|
||
className="hidden"
|
||
/>
|
||
</label>
|
||
</div>
|
||
)}
|
||
</header>
|
||
)}
|
||
|
||
{/* Primary Workspace scroll container */}
|
||
<main
|
||
className={`no-print flex-1 flex flex-col w-full ${
|
||
workflowStep === 'template-design' || workflowStep === 'print-view'
|
||
? 'overflow-hidden min-h-0'
|
||
: 'overflow-auto 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 → 关联变量 → 生成 PDF'
|
||
: '选择模板规格后进行设计与排版导出,或使用右上角按钮新建、导入模板。'}
|
||
</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 text-indigo-500 flex items-center justify-center mb-5 border border-indigo-100">
|
||
<Wand2 className="w-8 h-8" />
|
||
</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
|
||
? '请导入已有模板,或电脑端进行设计。'
|
||
: '创建模板开始可视化设计,或导入已有的 JSON 模板文件。'}
|
||
</p>
|
||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8">
|
||
{!isMobile && (
|
||
<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" />
|
||
导入 JSON 模板
|
||
<input
|
||
type="file"
|
||
accept=".json"
|
||
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-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 && (
|
||
<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 pr-12 mb-3">
|
||
<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>
|
||
<h3 className="text-sm font-bold text-gray-900 mt-1 leading-snug">{tmpl.name}</h3>
|
||
<p className="text-[10px] text-gray-400">{tmpl.elements.length} 个元素</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>
|
||
)}
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
openPrintView(tmpl.id!);
|
||
}}
|
||
className={`shrink-0 whitespace-nowrap inline-flex items-center gap-1 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-[11px] font-bold transition shadow-xs cursor-pointer ${
|
||
isMobile ? 'mobile-primary-btn flex-1' : ''
|
||
}`}
|
||
>
|
||
<FileDown className="w-3.5 h-3.5 shrink-0" />
|
||
{isMobile ? '上传数据并导出 PDF' : '排版导出'}
|
||
</button>
|
||
{!isMobile && (
|
||
<>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleExportTemplate(tmpl);
|
||
}}
|
||
className="shrink-0 p-1.5 bg-gray-50 text-gray-500 hover:bg-gray-100 border border-gray-100 hover:border-gray-300 rounded-lg inline-flex items-center justify-center transition cursor-pointer"
|
||
title="导出 JSON"
|
||
>
|
||
<Download className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDeleteTemplate(tmpl.id!);
|
||
}}
|
||
className="shrink-0 p-1.5 bg-gray-50 text-rose-500 hover:text-white hover:bg-rose-600 border border-gray-100 rounded-lg inline-flex items-center justify-center transition cursor-pointer"
|
||
title="删除模板"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ========================================================
|
||
STEP 2: The visual drag-and-drop label designer
|
||
======================================================== */}
|
||
{workflowStep === 'template-design' && isMobile && hasTemplates && (
|
||
<div className="mobile-design-block bg-[#292929] text-[#e8e8e8] flex-1">
|
||
<p className="text-sm text-[#ccc] max-w-[280px] leading-relaxed">
|
||
可视化模板设计需要在电脑端操作。移动端可直接上传数据并生成 PDF。
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => openPrintView()}
|
||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-[#31a8ff] text-white text-sm font-semibold rounded-lg cursor-pointer"
|
||
>
|
||
<FileDown className="w-4 h-4" />
|
||
去上传数据
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setWorkflowStep('template-select')}
|
||
className="text-[12px] text-[#999] underline cursor-pointer"
|
||
>
|
||
返回模板列表
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{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" />
|
||
<span className="hidden md:inline text-[10px] text-[#666]">
|
||
Shift/Ctrl+点击多选 · V 移动 · T 文本 · 图片工具栏
|
||
</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={handleTemplateChange}
|
||
selectedElementIds={selectedElementIds}
|
||
onSelectElements={setSelectedElementIds}
|
||
/>
|
||
<PropSidebar
|
||
template={activeTemplate}
|
||
onChangeTemplate={handleTemplateChange}
|
||
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={paper}
|
||
onChangePaper={handlePaperChange}
|
||
rows={mappedRows}
|
||
draftExportRange={draftExportRange}
|
||
onChangeDraftExportRange={setDraftExportRange}
|
||
cutLine={cutLine}
|
||
onChangeCutLine={handleCutLineChange}
|
||
dataStale={dataStale}
|
||
dataApplied={appliedExportData !== null}
|
||
onApplyData={handleApplyData}
|
||
appliedSelectedCount={appliedLayoutResult.selectedCount}
|
||
appliedTotalPages={appliedLayoutResult.totalPages}
|
||
appliedGridCols={appliedLayoutResult.gridCols}
|
||
appliedGridRows={appliedLayoutResult.gridRows}
|
||
draftSelectedCount={selectedCount}
|
||
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>
|
||
|
||
{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>
|
||
);
|
||
}
|