init commit

This commit is contained in:
iqudoo
2026-06-12 19:34:27 +08:00
commit 89b59ecc1b
38 changed files with 17719 additions and 0 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"liveServer.settings.port": 5501
}

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# label-designer
> 标签批量设置排版并生成PDF文档

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>智能标签排版设计与打印生成器</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6
metadata.json Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "批量标签排版设计与打印生成器",
"description": "支持可视化绘制条码、二维码及多行文本标签模版支持Excel/CSV数据批量导入与动态变量映射排版一键平衡居中生成A4多页精细网格裁切图层实现工业级无卷曲实体打印。",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}

4938
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"jsbarcode": "^3.12.3",
"jspdf": "^4.2.1",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"qrcode": "^1.5.4",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"@types/qrcode": "^1.5.6",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3"
}
}

3101
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1182
src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,204 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { AlertCircle, CheckCircle2, Info, X } from 'lucide-react';
export type AppDialogVariant = 'info' | 'success' | 'warning' | 'error';
interface AlertOptions {
title?: string;
variant?: AppDialogVariant;
confirmLabel?: string;
}
interface ConfirmOptions extends AlertOptions {
cancelLabel?: string;
}
interface DialogRequest {
mode: 'alert' | 'confirm';
message: string;
title: string;
variant: AppDialogVariant;
confirmLabel: string;
cancelLabel: string;
resolve: (value: boolean) => void;
}
interface AppDialogContextValue {
showAlert: (message: string, options?: AlertOptions) => Promise<void>;
showConfirm: (message: string, options?: ConfirmOptions) => Promise<boolean>;
}
const AppDialogContext = createContext<AppDialogContextValue | null>(null);
const VARIANT_META: Record<
AppDialogVariant,
{ icon: React.ReactNode; title: string; buttonClass: string }
> = {
info: {
icon: <Info className="w-5 h-5 text-indigo-500" />,
title: '提示',
buttonClass: 'bg-indigo-600 hover:bg-indigo-700 text-white',
},
success: {
icon: <CheckCircle2 className="w-5 h-5 text-emerald-500" />,
title: '成功',
buttonClass: 'bg-emerald-600 hover:bg-emerald-700 text-white',
},
warning: {
icon: <AlertCircle className="w-5 h-5 text-amber-500" />,
title: '请注意',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
},
error: {
icon: <AlertCircle className="w-5 h-5 text-rose-500" />,
title: '出错了',
buttonClass: 'bg-rose-600 hover:bg-rose-700 text-white',
},
};
function AppDialogModal({
dialog,
onClose,
}: {
dialog: DialogRequest;
onClose: (confirmed: boolean) => void;
}) {
const confirmBtnRef = useRef<HTMLButtonElement>(null);
const meta = VARIANT_META[dialog.variant];
useEffect(() => {
confirmBtnRef.current?.focus();
}, []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose(dialog.mode === 'alert');
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [dialog.mode, onClose]);
return (
<div
className="fixed inset-0 z-[9500] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4"
onClick={() => onClose(dialog.mode === 'alert')}
role="presentation"
>
<div
className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden"
onClick={(e) => e.stopPropagation()}
role="alertdialog"
aria-modal="true"
aria-labelledby="app-dialog-title"
aria-describedby="app-dialog-message"
>
<div className="flex items-start gap-3 px-5 py-4 border-b border-gray-100">
<span className="shrink-0 mt-0.5">{meta.icon}</span>
<div className="flex-1 min-w-0 pr-2">
<h3 id="app-dialog-title" className="text-sm font-bold text-gray-900">
{dialog.title}
</h3>
</div>
<button
type="button"
onClick={() => onClose(dialog.mode === 'alert')}
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition cursor-pointer shrink-0"
aria-label="关闭"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-5 py-4">
<p id="app-dialog-message" className="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">
{dialog.message}
</p>
</div>
<div className="flex justify-end gap-2 px-5 py-4 border-t border-gray-100 bg-gray-50/80">
{dialog.mode === 'confirm' && (
<button
type="button"
onClick={() => onClose(false)}
className="px-4 py-2 border border-gray-200 text-gray-600 hover:bg-white rounded-lg text-sm font-medium cursor-pointer"
>
{dialog.cancelLabel}
</button>
)}
<button
ref={confirmBtnRef}
type="button"
onClick={() => onClose(true)}
className={`px-4 py-2 rounded-lg text-sm font-semibold cursor-pointer ${meta.buttonClass}`}
>
{dialog.confirmLabel}
</button>
</div>
</div>
</div>
);
}
export function AppDialogProvider({ children }: { children: React.ReactNode }) {
const [dialog, setDialog] = useState<DialogRequest | null>(null);
const showAlert = useCallback((message: string, options?: AlertOptions) => {
const variant = options?.variant ?? 'info';
return new Promise<void>((resolve) => {
setDialog({
mode: 'alert',
message,
title: options?.title ?? VARIANT_META[variant].title,
variant,
confirmLabel: options?.confirmLabel ?? '知道了',
cancelLabel: '',
resolve: () => resolve(),
});
});
}, []);
const showConfirm = useCallback((message: string, options?: ConfirmOptions) => {
const variant = options?.variant ?? 'warning';
return new Promise<boolean>((resolve) => {
setDialog({
mode: 'confirm',
message,
title: options?.title ?? VARIANT_META[variant].title,
variant,
confirmLabel: options?.confirmLabel ?? '确定',
cancelLabel: options?.cancelLabel ?? '取消',
resolve,
});
});
}, []);
const closeDialog = useCallback((confirmed: boolean) => {
setDialog((current) => {
current?.resolve(confirmed);
return null;
});
}, []);
return (
<AppDialogContext.Provider value={{ showAlert, showConfirm }}>
{children}
{dialog && <AppDialogModal dialog={dialog} onClose={closeDialog} />}
</AppDialogContext.Provider>
);
}
export function useAppDialog() {
const ctx = useContext(AppDialogContext);
if (!ctx) {
throw new Error('useAppDialog must be used within AppDialogProvider');
}
return ctx;
}

View File

@@ -0,0 +1,57 @@
import React, { useEffect, useRef } from 'react';
import JsBarcode from 'jsbarcode';
interface BarcodeRendererProps {
value: string;
format?: 'CODE128' | 'CODE39' | 'EAN13' | 'ITF';
width?: number; // element width in px or relative factors
height?: number; // actual barcode line height in px
showText?: boolean;
fontSize?: number;
}
export const BarcodeRenderer: React.FC<BarcodeRendererProps> = ({
value,
format = 'CODE128',
width = 1,
height = 30,
showText = false,
fontSize = 10,
}) => {
const svgRef = useRef<SVGSVGElement | null>(null);
useEffect(() => {
if (!svgRef.current) return;
try {
// Avoid raw empty text crashes
const textToRender = value ? String(value).trim() : 'SAMPLE';
JsBarcode(svgRef.current, textToRender, {
format: format,
width: Math.max(0.6, width),
height: Math.max(10, height),
displayValue: showText,
fontSize: fontSize,
margin: 2,
background: 'transparent',
});
} catch (error) {
console.warn('Barcode generation failed:', error);
// Fallback display if EAN13 requirement or formatting fails
try {
JsBarcode(svgRef.current, 'INVALID', {
format: 'CODE128',
width: 1,
height: Math.max(10, height),
displayValue: true,
fontSize: 9,
margin: 2,
});
} catch (innerErr) {
// No-op
}
}
}, [value, format, width, height, showText, fontSize]);
return <svg ref={svgRef} className="max-w-full h-auto mx-auto select-none" />;
};

View File

@@ -0,0 +1,89 @@
import React, { useState, useEffect, useMemo } from 'react';
import { TemplateElement } from '../types';
import { ContentResolveMode } from '../utils';
import { renderLabelToDataUrl } from '../labelRenderer';
interface CanvasLabelImageProps {
widthMm: number;
heightMm: number;
elements: TemplateElement[];
rowData: any;
rowIndex: number;
showBorder?: boolean;
transparentBackground?: boolean;
variableDefaults?: Record<string, string> | null;
/** design=设计预览空变量显示占位output=排版/打印(空值隐藏图形元素) */
mode?: ContentResolveMode;
}
export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
widthMm,
heightMm,
elements,
rowData,
rowIndex,
showBorder = false,
transparentBackground = false,
variableDefaults = null,
mode = 'design' as ContentResolveMode,
}) => {
const [dataUrl, setDataUrl] = useState<string>('');
const [initialLoading, setInitialLoading] = useState<boolean>(true);
const elementsKey = useMemo(() => JSON.stringify(elements), [elements]);
const rowDataKey = useMemo(() => JSON.stringify(rowData), [rowData]);
useEffect(() => {
let active = true;
renderLabelToDataUrl({
widthMm,
heightMm,
elements,
rowData,
rowIndex,
showBorder,
transparentBackground,
variableDefaults,
mode,
})
.then((url) => {
if (active) {
setDataUrl(url);
setInitialLoading(false);
}
})
.catch((err) => {
console.error('Label graphics rendering error:', err);
if (active) setInitialLoading(false);
});
return () => {
active = false;
};
}, [widthMm, heightMm, elementsKey, rowDataKey, rowIndex, showBorder, transparentBackground, mode, elements, variableDefaults]);
if (initialLoading && !dataUrl) {
return (
<div
className="flex items-center justify-center animate-pulse text-gray-400 font-medium text-[10px] bg-transparent"
style={{ width: '100%', height: '100%', minHeight: '30px' }}
>
<span></span>
</div>
);
}
return (
<img
src={dataUrl}
alt="Label Preview"
className="w-full h-full object-fill pointer-events-none select-none block"
style={{
width: '100%',
height: '100%',
imageRendering: 'auto',
}}
/>
);
};

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { ChevronDown } from 'lucide-react';
export interface CollapsiblePanelSectionProps {
sectionClassName?: string;
collapsed: boolean;
onToggle: () => void;
icon: React.ReactNode;
title: string;
headerExtra?: React.ReactNode;
bodyClassName?: string;
children: React.ReactNode;
}
export const CollapsiblePanelSection: React.FC<CollapsiblePanelSectionProps> = ({
sectionClassName = '',
collapsed,
onToggle,
icon,
title,
headerExtra,
bodyClassName = '',
children,
}) => (
<div
className={`ps-panel-section ${sectionClassName} ${collapsed ? 'collapsed' : ''}`.trim()}
>
<button
type="button"
className="ps-panel-header ps-panel-header-toggle"
onClick={onToggle}
title={collapsed ? '展开' : '收起'}
aria-expanded={!collapsed}
>
<ChevronDown
className={`w-3 h-3 shrink-0 text-[#888] transition-transform duration-150 ${
collapsed ? '-rotate-90' : ''
}`}
/>
{icon}
<span className="truncate">{title}</span>
{headerExtra && <span className="ml-auto flex items-center gap-2 min-w-0">{headerExtra}</span>}
</button>
{!collapsed && (
<div className={`ps-panel-scroll ${bodyClassName}`.trim()}>{children}</div>
)}
</div>
);

View File

@@ -0,0 +1,312 @@
import React, { useRef, useState } from 'react';
import * as XLSX from 'xlsx';
import { ImportData, RecordRow } from '../types';
import { FileSpreadsheet, Upload, Download, Trash2 } from 'lucide-react';
import { useAppDialog } from './AppDialog';
export function downloadDataTemplate(templateName: string, templateVariables: string[]) {
const columns = templateVariables.length > 0 ? templateVariables : ['数据'];
const ws = XLSX.utils.aoa_to_sheet([columns, columns.map(() => '')]);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '数据');
const safeName = templateName.replace(/[^\w\u4e00-\u9fa5-]+/g, '_').slice(0, 30) || '标签';
XLSX.writeFile(wb, `${safeName}_数据模板.xlsx`);
}
interface DataImporterProps {
importData: ImportData | null;
onDataLoaded: (data: ImportData) => void;
onClear: () => void;
templateName: string;
templateVariables: string[];
variant?: 'default' | 'ps' | 'mobile';
}
export const DataImporter: React.FC<DataImporterProps> = ({
importData,
onDataLoaded,
onClear,
templateName,
templateVariables,
variant = 'default',
}) => {
const { showAlert } = useAppDialog();
const isPs = variant === 'ps';
const isMobile = variant === 'mobile';
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [dragActive, setDragActive] = useState<boolean>(false);
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
processFile(e.dataTransfer.files[0]);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
processFile(e.target.files[0]);
}
};
const processFile = (file: File) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: 'array' });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const jsonData = XLSX.utils.sheet_to_json<any[]>(worksheet, { header: 1, defval: '' });
if (jsonData.length === 0) {
await showAlert('表格为空或格式不正确', { variant: 'warning' });
return;
}
const rawColumns = jsonData[0] as string[];
const columns = rawColumns.map((c, idx) => (c ? String(c).trim() : `列_${idx + 1}`)).filter(Boolean);
const rows: RecordRow[] = [];
for (let i = 1; i < jsonData.length; i++) {
const rawRow = jsonData[i] as any[];
if (rawRow.every((cell) => cell === undefined || cell === null || String(cell).trim() === '')) {
continue;
}
const row: RecordRow = {};
columns.forEach((col, colIdx) => {
row[col] = rawRow[colIdx] !== undefined ? String(rawRow[colIdx]).trim() : '';
});
rows.push(row);
}
onDataLoaded({
fileName: file.name,
sheets: workbook.SheetNames,
currentSheet: firstSheetName,
columns,
rows,
});
} catch (err) {
console.error(err);
await showAlert('解析文件出错,请确保是标准的 Excel 或 CSV 文件', { variant: 'error' });
}
};
reader.readAsArrayBuffer(file);
};
const handleSheetChange = (sheetName: string) => {
if (!fileInputRef.current?.files?.[0]) return;
const file = fileInputRef.current.files[0];
const reader = new FileReader();
reader.onload = async (e) => {
try {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: 'array' });
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json<any[]>(worksheet, { header: 1, defval: '' });
if (jsonData.length === 0) return;
const rawColumns = jsonData[0] as string[];
const columns = rawColumns.map((c, idx) => (c ? String(c).trim() : `列_${idx + 1}`)).filter(Boolean);
const rows: RecordRow[] = [];
for (let i = 1; i < jsonData.length; i++) {
const rawRow = jsonData[i] as any[];
if (rawRow.every((cell) => cell === undefined || cell === null || String(cell).trim() === '')) {
continue;
}
const row: RecordRow = {};
columns.forEach((col, colIdx) => {
row[col] = rawRow[colIdx] !== undefined ? String(rawRow[colIdx]).trim() : '';
});
rows.push(row);
}
onDataLoaded({
fileName: file.name,
sheets: workbook.SheetNames,
currentSheet: sheetName,
columns,
rows,
});
} catch {
await showAlert('切换工作表时出错', { variant: 'error' });
}
};
reader.readAsArrayBuffer(file);
};
const rows = importData?.rows || [];
const columns = importData?.columns || [];
const wrapperClass = isMobile
? ''
: isPs
? 'space-y-3'
: 'bg-white border border-gray-200 rounded-2xl p-6 shadow-sm flex flex-col gap-5';
return (
<div className={wrapperClass}>
{/* {!isPs && (
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h3 className="text-sm font-bold text-gray-900 leading-none">批量数据源导入</h3>
<p className="text-[11px] text-gray-400 mt-1">上传 Excel 或 CSV绑定当前模板变量</p>
</div>
</div>
)} */}
{!isMobile && (
<div className={`flex flex-wrap gap-2 ${isPs ? '' : 'justify-end'}`}>
<button
type="button"
onClick={() => downloadDataTemplate(templateName, templateVariables)}
className={
isMobile
? 'mobile-secondary-btn w-full'
: isPs
? 'ps-btn text-[10px] w-full justify-center'
: 'inline-flex items-center gap-1.5 px-3 py-1.5 border border-gray-200 text-gray-600 hover:bg-gray-50 rounded-lg text-xs font-semibold cursor-pointer'
}
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
)}
{!importData ? (
<div
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={
isMobile
? `mobile-upload-zone ${dragActive ? 'active' : ''}`
: isPs
? `border border-dashed rounded py-8 px-4 text-center cursor-pointer transition flex flex-col items-center gap-2 ${
dragActive
? 'border-[#31a8ff] bg-[#1a3a4f]/30'
: 'border-[#4a4a4a] hover:border-[#31a8ff]/60 hover:bg-[#1e1e1e]'
}`
: `border-2 border-dashed rounded-2xl py-10 px-6 text-center cursor-pointer transition flex flex-col items-center gap-3 ${
dragActive
? 'border-indigo-500 bg-indigo-50/20'
: 'border-gray-200 hover:border-indigo-400 hover:bg-gray-50/50'
}`
}
>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileChange}
className="hidden"
/>
<Upload
className={`${isMobile ? 'w-8 h-8' : 'w-6 h-6'} ${
isPs ? 'text-[#777]' : isMobile ? 'text-indigo-400' : 'text-gray-400'
}`}
/>
<div>
<p
className={`${
isMobile
? 'text-[15px] font-semibold text-slate-700'
: isPs
? 'text-xs text-[#ccc]'
: 'text-xs text-gray-700'
}`}
>
{isMobile ? '点击选择 Excel / CSV' : '点击上传或拖拽文件到此处'}
</p>
{isMobile && (
<p className="text-xs text-slate-400 leading-relaxed">
.xlsx.xls.csv
</p>
)}
</div>
</div>
) : (
<div
className={
isMobile
? 'mobile-file-card'
: isPs
? 'flex flex-wrap items-center justify-between p-2.5 bg-[#1e1e1e] border border-[#3a3a3a] rounded gap-3'
: 'flex flex-wrap items-center justify-between p-3.5 bg-gray-50 rounded-xl gap-4 border border-gray-100'
}
>
<div className="flex items-center gap-2 overflow-hidden min-w-0">
<FileSpreadsheet
className={`w-5 h-5 shrink-0 ${isPs ? 'text-emerald-400' : 'text-emerald-600'}`}
/>
<div className="overflow-hidden min-w-0">
<p
className={`truncate ${
isMobile ? 'text-sm text-slate-800' : isPs ? 'text-xs text-[#e8e8e8]' : 'text-gray-800'
}`}
>
{importData.fileName}
</p>
<p
className={`mt-0.5 ${
isMobile ? 'text-xs text-slate-500' : isPs ? 'text-[10px] text-[#777]' : 'text-[10px] text-gray-400'
}`}
>
{rows.length} · {columns.length}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{importData.sheets.length > 1 && (
<select
value={importData.currentSheet}
onChange={(e) => handleSheetChange(e.target.value)}
className={
isMobile
? 'mobile-field text-sm py-2'
: isPs
? 'ps-field text-[10px] py-1'
: 'px-2.5 py-1 text-xs bg-white border border-gray-100 rounded-lg'
}
>
{importData.sheets.map((sheet) => (
<option key={sheet} value={sheet}>
{sheet}
</option>
))}
</select>
)}
<button
type="button"
onClick={onClear}
className={
isMobile
? 'mobile-text-btn danger'
: isPs
? 'ps-btn text-[10px] text-rose-300 hover:text-rose-200'
: 'flex items-center gap-1 text-xs font-semibold text-rose-600 hover:bg-rose-50 px-2.5 py-1.5 rounded-lg cursor-pointer'
}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,89 @@
import React from 'react';
import { Filter } from 'lucide-react';
import { ExportRangeConfig, LabelTemplate, PaperConfig, RecordRow } from '../types';
import { buildPrintablePages } from '../utils';
import { PageInput } from './PreviewGrid';
interface DataRangeFieldsProps {
variant?: 'ps' | 'mobile';
exportRange: ExportRangeConfig;
onChangeExportRange: (range: ExportRangeConfig) => void;
rows: RecordRow[];
paper: PaperConfig;
template: LabelTemplate;
disabled?: boolean;
}
export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
variant = 'ps',
exportRange,
onChangeExportRange,
rows,
paper,
template,
disabled = false,
}) => {
const isMobile = variant === 'mobile';
const rangeStats = buildPrintablePages(rows, paper, template, exportRange);
const patchRange = (patch: Partial<ExportRangeConfig>) => {
onChangeExportRange({ ...exportRange, ...patch });
};
return (
<div className={isMobile ? 'space-y-4' : 'space-y-2'}>
{!isMobile && <div className="ps-section-title"></div>}
{isMobile && (
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-indigo-500 shrink-0" />
<span className="text-sm font-semibold text-slate-800"></span>
</div>
)}
<p className={isMobile ? 'text-xs text-slate-500 leading-relaxed' : 'text-[10px] text-[#888] leading-relaxed'}>
1
</p>
<div>
{isMobile && <label className="mobile-field-label"></label>}
{!isMobile && <label className="ps-field-label"></label>}
<select
value={exportRange.mode}
disabled={disabled}
onChange={(e) => patchRange({ mode: e.target.value as ExportRangeConfig['mode'] })}
className={isMobile ? 'mobile-field' : 'ps-field text-[11px]'}
>
<option value="all"></option>
<option value="odd"></option>
<option value="even"></option>
<option value="custom"></option>
</select>
</div>
{exportRange.mode === 'custom' && (
<div>
{isMobile ? (
<label className="mobile-field-label"></label>
) : (
<label className="ps-field-label"></label>
)}
<PageInput
type="text"
value={exportRange.custom ?? ''}
disabled={disabled}
onCommit={(val) => patchRange({ custom: val })}
placeholder="如 1,3,5-10"
inputClassName={isMobile ? 'mobile-field font-mono' : 'ps-field font-mono text-[11px]'}
/>
</div>
)}
<p className={isMobile ? 'text-xs text-slate-500' : 'text-[10px] text-[#777]'}>
{rangeStats.selectedCount}
{rangeStats.selectedCount < rows.length && ` / 共 ${rows.length}`}
{isMobile && rangeStats.selectedCount > 0 && (
<span>
{' '}
· {rangeStats.totalPages} PDF
</span>
)}
</p>
</div>
);
};

View File

@@ -0,0 +1,282 @@
import React, { useState } from 'react';
import { Database, Layout, Settings } from 'lucide-react';
import { ImportData, PaperConfig, LabelTemplate, RecordRow, ExportRangeConfig, CutLineConfig } from '../types';
import { DataImporter } from './DataImporter';
import { VariableMappingPanel } from './VariableMappingPanel';
import { PaperSettingsPanel } from './PreviewGrid';
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
import { DataRangeFields } from './DataRangeFields';
interface ExportSidebarProps {
importData: ImportData | null;
onDataLoaded: (data: ImportData) => void;
onClear: () => void;
templateName: string;
templateVariables: string[];
dataColumns: string[];
variableColumnMapping: Record<string, string>;
onChangeMapping: (mapping: Record<string, string>) => void;
paper: PaperConfig;
onChangePaper: (updated: PaperConfig) => void;
template: LabelTemplate;
rows: RecordRow[];
draftExportRange: ExportRangeConfig;
onChangeDraftExportRange: (range: ExportRangeConfig) => void;
cutLine: CutLineConfig;
onChangeCutLine: (cutLine: CutLineConfig) => void;
previewReady: boolean;
dataStale: boolean;
previewLayoutStale: boolean;
dataApplied: boolean;
onApplyData: () => void;
onRedrawPreview: () => void;
draftSelectedCount: number;
/** 移动端精简:仅数据、映射、数据范围 */
compact?: boolean;
}
export const ExportSidebar: React.FC<ExportSidebarProps> = ({
importData,
onDataLoaded,
onClear,
templateName,
templateVariables,
dataColumns,
variableColumnMapping,
onChangeMapping,
paper,
onChangePaper,
template,
rows,
draftExportRange,
onChangeDraftExportRange,
cutLine,
onChangeCutLine,
previewReady,
dataStale,
previewLayoutStale,
dataApplied,
onApplyData,
onRedrawPreview,
draftSelectedCount,
compact = false,
}) => {
const [dataCollapsed, setDataCollapsed] = useState(false);
const [layoutCollapsed, setLayoutCollapsed] = useState(true);
const hasData = rows.length > 0;
const dataRangeSection = (
<DataRangeFields
variant={compact ? 'ps' : 'ps'}
exportRange={draftExportRange}
onChangeExportRange={onChangeDraftExportRange}
rows={rows}
paper={paper}
template={template}
disabled={rows.length === 0}
/>
);
const applyDataSection = (
<div className="space-y-2">
{/* {dataStale && (
<p className="text-[10px] text-amber-500/90">数据、映射或数据范围已变更,请先应用数据</p>
)}
{previewLayoutStale && previewReady && !dataStale && (
<p className="text-[10px] text-amber-500/90">模板或排版已变更,请重新绘制预览</p>
)} */}
<button
type="button"
onClick={onApplyData}
disabled={rows.length === 0 || draftSelectedCount === 0}
className={`w-full inline-flex items-center justify-center gap-1.5 mt-2 px-3 py-1.5 text-[11px] font-semibold transition cursor-pointer ${
rows.length > 0 && draftSelectedCount > 0
? dataStale || !dataApplied
? 'bg-[#31a8ff] hover:bg-[#2890d8] text-white'
: 'bg-[#383838] hover:bg-[#444] border border-[#4a4a4a] text-[#ccc]'
: 'bg-[#444] text-[#666] cursor-not-allowed'
}`}
>
</button>
<p className="text-[10px] text-[#666] leading-relaxed">
PDF 使
</p>
</div>
);
if (compact) {
return (
<aside className="ps-export-sidebar mobile-export-panel">
<div className="mobile-export-scroll ps-panel-scroll p-3 space-y-4 flex-1">
<div>
<div className="text-[11px] font-semibold text-[#e8e8e8] flex items-center gap-1.5 mb-2">
<Database className="w-3.5 h-3.5 text-[#31a8ff]" />
</div>
<DataImporter
variant="ps"
importData={importData}
onDataLoaded={onDataLoaded}
onClear={onClear}
templateName={templateName}
templateVariables={templateVariables}
/>
</div>
{hasData && (
<>
{dataRangeSection}
<div>
<VariableMappingPanel
variant="ps"
variables={templateVariables}
columns={dataColumns}
mapping={variableColumnMapping}
onChangeMapping={onChangeMapping}
variableDefaults={template.variableDefaults}
/>
</div>
{applyDataSection}
</>
)}
</div>
</aside>
);
}
return (
<aside className="ps-export-sidebar">
<CollapsiblePanelSection
sectionClassName="export-data-panel"
collapsed={dataCollapsed}
onToggle={() => setDataCollapsed((v) => !v)}
icon={<Database className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />}
title="数据源与变量绑定"
bodyClassName="ps-panel-body p-3 min-h-0 space-y-3"
>
<DataImporter
variant="ps"
importData={importData}
onDataLoaded={onDataLoaded}
onClear={onClear}
templateName={templateName}
templateVariables={templateVariables}
/>
{hasData && (
<>
{dataRangeSection}
<VariableMappingPanel
variant="ps"
variables={templateVariables}
columns={dataColumns}
mapping={variableColumnMapping}
onChangeMapping={onChangeMapping}
variableDefaults={template.variableDefaults}
/>
{applyDataSection}
</>
)}
</CollapsiblePanelSection>
<CollapsiblePanelSection
sectionClassName="export-layout-panel"
collapsed={layoutCollapsed}
onToggle={() => setLayoutCollapsed((v) => !v)}
icon={<Layout className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />}
title="整页排版 & PDF 设置"
bodyClassName="ps-panel-body p-3 min-h-0 space-y-3"
>
<PaperSettingsPanel
theme="ps"
paper={paper}
onChangePaper={onChangePaper}
template={template}
rows={rows}
exportRange={draftExportRange}
totalRowCount={rows.length}
/>
<div className="ps-section-divider space-y-2">
<div className="ps-section-title">
<Settings className="w-3 h-3" />
线
</div>
<label className="flex items-center gap-2 cursor-pointer text-[11px] text-[#ccc] select-none">
<input
type="checkbox"
checked={cutLine.enabled}
onChange={(e) => onChangeCutLine({ ...cutLine, enabled: e.target.checked })}
className="ps-checkbox"
/>
<span> PDF 线</span>
</label>
<fieldset
disabled={!cutLine.enabled}
className={`space-y-2 border-0 p-0 m-0 min-w-0 ${!cutLine.enabled ? 'opacity-55' : ''}`}
>
<div>
<label className="ps-field-label">线</label>
<select
value={cutLine.style}
onChange={(e) =>
onChangeCutLine({
...cutLine,
style: e.target.value as CutLineConfig['style'],
})
}
className="ps-field text-[11px]"
>
<option value="dashed">线</option>
<option value="solid">线</option>
</select>
</div>
<div>
<label className="ps-field-label">线 (mm)</label>
<input
type="number"
min="0.02"
max="2"
step="0.02"
value={cutLine.width}
onChange={(e) =>
onChangeCutLine({
...cutLine,
width: Math.max(0.02, Math.min(2, Number(e.target.value) || 0.1)),
})
}
className="ps-field font-mono text-[11px]"
/>
<div
className="mt-2 h-8 rounded border border-[#3a3a3a] bg-[#1e1e1e] flex items-center justify-center"
title="裁切线预览"
>
<div
className="w-[80%]"
style={{
borderTop: `${Math.max(1, cutLine.width * 8)}px ${cutLine.style} ${cutLine.color}`,
}}
/>
</div>
</div>
<div>
<label className="ps-field-label"></label>
<div className="flex items-center gap-2">
<input
type="color"
value={cutLine.color}
onChange={(e) => onChangeCutLine({ ...cutLine, color: e.target.value })}
className="color-input shrink-0"
/>
<input
type="text"
value={cutLine.color}
onChange={(e) => onChangeCutLine({ ...cutLine, color: e.target.value })}
className="ps-field font-mono text-[11px] flex-1 min-w-0"
/>
</div>
</div>
</fieldset>
</div>
</CollapsiblePanelSection>
</aside>
);
};

View File

@@ -0,0 +1,968 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { LabelTemplate, TemplateElement } from '../types';
import { mmToPx, shouldRenderElement } from '../utils';
import { CanvasLabelImage } from './CanvasLabelImage';
import {
ZoomIn,
ZoomOut,
Type,
Barcode,
QrCode,
Image as ImageIcon,
Move,
Trash2,
} from 'lucide-react';
interface LabelDesignerProps {
template: LabelTemplate;
onChange: (updated: LabelTemplate) => void;
selectedElementIds: string[];
onSelectElements: (ids: string[]) => void;
}
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode';
type ResizeCorner = 'nw' | 'ne' | 'sw' | 'se';
const MIN_ELEM_W = 2;
const MIN_ELEM_H = 2;
function applyCornerResize(
startX: number,
startY: number,
startW: number,
startH: number,
deltaXMm: number,
deltaYMm: number,
corner: ResizeCorner
) {
let x = startX;
let y = startY;
let w = startW;
let h = startH;
if (corner.includes('e')) w = startW + deltaXMm;
if (corner.includes('w')) {
w = startW - deltaXMm;
x = startX + deltaXMm;
}
if (corner.includes('s')) h = startH + deltaYMm;
if (corner.includes('n')) {
h = startH - deltaYMm;
y = startY + deltaYMm;
}
if (w < MIN_ELEM_W) {
if (corner.includes('w')) x += w - MIN_ELEM_W;
w = MIN_ELEM_W;
}
if (h < MIN_ELEM_H) {
if (corner.includes('n')) y += h - MIN_ELEM_H;
h = MIN_ELEM_H;
}
return {
x: Math.round(x * 10) / 10,
y: Math.round(y * 10) / 10,
w: Math.round(w * 10) / 10,
h: Math.round(h * 10) / 10,
};
}
const MARQUEE_MIN_PX = 4;
const DRAG_START_THRESHOLD_PX = 4;
function rectFullyContains(
outer: { left: number; top: number; right: number; bottom: number },
inner: { left: number; top: number; right: number; bottom: number }
) {
return (
outer.left <= inner.left &&
outer.top <= inner.top &&
outer.right >= inner.right &&
outer.bottom >= inner.bottom
);
}
function applyQrcodeSquareResize(
startX: number,
startY: number,
startW: number,
startH: number,
corner: ResizeCorner,
deltaXMm: number,
deltaYMm: number
) {
const rect = applyCornerResize(
startX,
startY,
startW,
startH,
deltaXMm,
deltaYMm,
corner
);
const size = Math.max(rect.w, rect.h, MIN_ELEM_W);
let x = startX;
let y = startY;
if (corner.includes('w')) x = startX + startW - size;
if (corner.includes('n')) y = startY + startH - size;
return {
x: Math.round(x * 10) / 10,
y: Math.round(y * 10) / 10,
w: Math.round(size * 10) / 10,
h: Math.round(size * 10) / 10,
};
}
export const LabelDesigner: React.FC<LabelDesignerProps> = ({
template,
onChange,
selectedElementIds,
onSelectElements,
}) => {
const selectedElementId = selectedElementIds[0] || null;
const [zoom, setZoom] = useState<number>(2.5);
const [activeTool, setActiveTool] = useState<ActiveTool>('move');
const canvasAreaRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLDivElement | null>(null);
const imageInputRef = useRef<HTMLInputElement | null>(null);
const previewDefaults = useMemo(
() => template.variableDefaults ?? null,
[template.variableDefaults]
);
const [localElements, setLocalElements] = useState<TemplateElement[] | null>(null);
const localElementsRef = useRef<TemplateElement[] | null>(null);
/** 拖拽/缩放过程中冻结底图合成,仅更新选区框位置,避免每帧重绘闪动 */
const [frozenCanvasElements, setFrozenCanvasElements] = useState<TemplateElement[] | null>(null);
const dragRafRef = useRef<number | null>(null);
const pendingDragElementsRef = useRef<TemplateElement[] | null>(null);
const [dragState, setDragState] = useState<{
elementId: string;
type: 'drag' | 'resize';
resizeCorner?: ResizeCorner;
startX: number;
startY: number;
startElemX: number;
startElemY: number;
startElemW: number;
startElemH: number;
groupStartPositions?: Record<string, { x: number; y: number }>;
} | null>(null);
const [marqueeState, setMarqueeState] = useState<{
startX: number;
startY: number;
currentX: number;
currentY: number;
additive: boolean;
} | null>(null);
const ignoreCanvasClickRef = useRef(false);
const dragMovedRef = useRef(false);
const marqueeRef = useRef(marqueeState);
marqueeRef.current = marqueeState;
const selectedIdsRef = useRef(selectedElementIds);
selectedIdsRef.current = selectedElementIds;
const templateElementsRef = useRef(template.elements);
templateElementsRef.current = template.elements;
const addImageElement = useCallback(
(src: string = '') => {
const id = `image_${Date.now()}`;
const n = template.elements.length + 1;
const newElement: TemplateElement = {
id,
type: 'image',
name: `图片 ${n}`,
x: 5,
y: 5,
width: 30,
height: 30,
content: src,
fontSize: 12,
textAlign: 'center',
fontWeight: 'normal',
barcodeFormat: 'CODE128',
showText: false,
fontFamily: 'sans',
backgroundColor: 'transparent',
imageFit: 'contain',
};
onChange({
...template,
elements: [...template.elements, newElement],
});
onSelectElements([id]);
setActiveTool('move');
},
[template, onChange, onSelectElements]
);
const addElement = useCallback(
(type: 'text' | 'barcode' | 'qrcode') => {
let newElement: TemplateElement;
const id = `${type}_${Date.now()}`;
const n = template.elements.length + 1;
if (type === 'text') {
newElement = {
id,
type: 'text',
name: `文本 ${n}`,
x: 5,
y: 5,
width: 30,
height: 8,
content: '文本内容',
fontSize: 12,
textAlign: 'left',
verticalAlign: 'middle',
wordWrap: true,
fontWeight: 'normal',
barcodeFormat: 'CODE128',
showText: false,
fontFamily: 'sans',
backgroundColor: 'transparent',
};
} else if (type === 'barcode') {
newElement = {
id,
type: 'barcode',
name: `条形码 ${n}`,
x: 5,
y: 15,
width: 50,
height: 12,
content: '123456789',
fontSize: 10,
textAlign: 'center',
fontWeight: 'normal',
barcodeFormat: 'CODE128',
showText: true,
fontFamily: 'mono',
backgroundColor: 'transparent',
};
} else {
newElement = {
id,
type: 'qrcode',
name: `二维码 ${n}`,
x: 40,
y: 10,
width: 20,
height: 20,
content: 'https://example.com',
fontSize: 10,
textAlign: 'center',
fontWeight: 'normal',
barcodeFormat: 'CODE128',
showText: false,
fontFamily: 'sans',
backgroundColor: 'transparent',
};
}
onChange({
...template,
elements: [...template.elements, newElement],
});
onSelectElements([id]);
setActiveTool('move');
},
[template, onChange, onSelectElements]
);
const deleteSelectedElements = useCallback(() => {
const idSet = new Set(
selectedElementIds.length > 0
? selectedElementIds
: selectedElementId
? [selectedElementId]
: []
);
if (idSet.size === 0) return;
const remaining = template.elements.filter((el) => !idSet.has(el.id) || el.locked);
onChange({ ...template, elements: remaining });
onSelectElements(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
}, [selectedElementIds, selectedElementId, template, onChange, onSelectElements]);
const handleToolClick = (tool: ActiveTool) => {
if (tool === 'move') {
setActiveTool('move');
} else {
addElement(tool);
}
};
const handleImageToolClick = () => {
imageInputRef.current?.click();
};
const handleImageFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !file.type.startsWith('image/')) {
addImageElement('');
e.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => addImageElement(reader.result as string);
reader.onerror = () => addImageElement('');
reader.readAsDataURL(file);
e.target.value = '';
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const activeTag = document.activeElement?.tagName.toLowerCase();
if (activeTag === 'input' || activeTag === 'textarea' || activeTag === 'select') return;
if (e.key === 'v' || e.key === 'V') setActiveTool('move');
if (e.key === 't' || e.key === 'T') addElement('text');
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
e.preventDefault();
setActiveTool('move');
onSelectElements(template.elements.filter((el) => !el.locked).map((el) => el.id));
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
deleteSelectedElements();
return;
}
const moveIds =
selectedElementIds.length > 0 ? selectedElementIds : selectedElementId ? [selectedElementId] : [];
if (moveIds.length === 0) return;
const increment = e.shiftKey ? 2 : 0.5;
let deltaX = 0;
let deltaY = 0;
if (e.key === 'ArrowUp') deltaY = -increment;
else if (e.key === 'ArrowDown') deltaY = increment;
else if (e.key === 'ArrowLeft') deltaX = -increment;
else if (e.key === 'ArrowRight') deltaX = increment;
else return;
const idSet = new Set(moveIds);
e.preventDefault();
onChange({
...template,
elements: template.elements.map((el) => {
if (!idSet.has(el.id) || el.locked) return el;
return {
...el,
x: Math.round((el.x + deltaX) * 10) / 10,
y: Math.round((el.y + deltaY) * 10) / 10,
};
}),
});
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [
selectedElementId,
selectedElementIds,
template,
onChange,
addElement,
onSelectElements,
deleteSelectedElements,
]);
useEffect(() => {
const area = canvasAreaRef.current;
if (!area) return;
const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.25 : 0.25;
setZoom((z) => Math.min(5, Math.max(0.5, Math.round((z + delta) * 100) / 100)));
}
};
area.addEventListener('wheel', handleWheel, { passive: false });
return () => area.removeEventListener('wheel', handleWheel);
}, []);
const marqueeActive = marqueeState !== null;
useEffect(() => {
if (!marqueeActive) return;
const handleMouseMove = (e: MouseEvent) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
setMarqueeState((prev) => (prev ? { ...prev, currentX, currentY } : null));
};
const handleMouseUp = () => {
const state = marqueeRef.current;
setMarqueeState(null);
if (!state) return;
const left = Math.min(state.startX, state.currentX);
const top = Math.min(state.startY, state.currentY);
const right = Math.max(state.startX, state.currentX);
const bottom = Math.max(state.startY, state.currentY);
const isTiny =
right - left < MARQUEE_MIN_PX && bottom - top < MARQUEE_MIN_PX;
ignoreCanvasClickRef.current = true;
if (isTiny) {
if (!state.additive) {
onSelectElements([]);
}
return;
}
const marqueeRect = { left, top, right, bottom };
const elements = localElementsRef.current || templateElementsRef.current;
const hitIds = elements
.filter((el) => {
if (el.locked) return false;
const elRect = {
left: mmToPx(el.x, zoom),
top: mmToPx(el.y, zoom),
right: mmToPx(el.x + el.width, zoom),
bottom: mmToPx(el.y + el.height, zoom),
};
return rectFullyContains(marqueeRect, elRect);
})
.map((el) => el.id);
if (state.additive) {
const merged = new Set([...selectedIdsRef.current, ...hitIds]);
onSelectElements([...merged]);
} else {
onSelectElements(hitIds);
}
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [marqueeActive, zoom, onSelectElements]);
useEffect(() => {
if (!dragState) return;
dragMovedRef.current = false;
const handleMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - dragState.startX;
const deltaY = e.clientY - dragState.startY;
if (
dragState.type === 'drag' &&
(Math.abs(deltaX) > DRAG_START_THRESHOLD_PX || Math.abs(deltaY) > DRAG_START_THRESHOLD_PX)
) {
dragMovedRef.current = true;
}
const MM_PER_PX = 1 / (3.78 * zoom);
const deltaXMm = deltaX * MM_PER_PX;
const deltaYMm = deltaY * MM_PER_PX;
if (dragState.type === 'drag') {
const groupStarts = dragState.groupStartPositions;
if (!groupStarts || Object.keys(groupStarts).length === 0) return;
const updated = template.elements.map((el) => {
const start = groupStarts[el.id];
if (!start || el.locked) return el;
return {
...el,
x: Math.round((start.x + deltaXMm) * 10) / 10,
y: Math.round((start.y + deltaYMm) * 10) / 10,
};
});
pendingDragElementsRef.current = updated;
if (dragRafRef.current === null) {
dragRafRef.current = requestAnimationFrame(() => {
dragRafRef.current = null;
const next = pendingDragElementsRef.current;
if (next) {
setLocalElements(next);
localElementsRef.current = next;
}
});
}
} else if (dragState.type === 'resize') {
const elem = template.elements.find((el) => el.id === dragState.elementId);
if (!elem || elem.locked) return;
const corner = dragState.resizeCorner ?? 'se';
const rect =
elem.type === 'qrcode'
? applyQrcodeSquareResize(
dragState.startElemX,
dragState.startElemY,
dragState.startElemW,
dragState.startElemH,
corner,
deltaXMm,
deltaYMm
)
: applyCornerResize(
dragState.startElemX,
dragState.startElemY,
dragState.startElemW,
dragState.startElemH,
deltaXMm,
deltaYMm,
corner
);
const updated = template.elements.map((el) =>
el.id === dragState.elementId
? { ...el, x: rect.x, y: rect.y, width: rect.w, height: rect.h }
: el
);
pendingDragElementsRef.current = updated;
if (dragRafRef.current === null) {
dragRafRef.current = requestAnimationFrame(() => {
dragRafRef.current = null;
const next = pendingDragElementsRef.current;
if (next) {
setLocalElements(next);
localElementsRef.current = next;
}
});
}
}
};
const handleMouseUp = () => {
if (dragRafRef.current !== null) {
cancelAnimationFrame(dragRafRef.current);
dragRafRef.current = null;
}
if (pendingDragElementsRef.current) {
localElementsRef.current = pendingDragElementsRef.current;
pendingDragElementsRef.current = null;
}
if (localElementsRef.current) {
onChange({ ...template, elements: localElementsRef.current });
localElementsRef.current = null;
setLocalElements(null);
}
setFrozenCanvasElements(null);
setDragState(null);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [dragState, template, zoom, onChange]);
const buildGroupStartPositions = (element: TemplateElement) => {
const moveIds =
selectedElementIds.includes(element.id) && selectedElementIds.length > 1
? selectedElementIds
: [element.id];
const groupStartPositions: Record<string, { x: number; y: number }> = {};
for (const id of moveIds) {
const el = template.elements.find((item) => item.id === id);
if (el && !el.locked) {
groupStartPositions[id] = { x: el.x, y: el.y };
}
}
return groupStartPositions;
};
const startMarquee = (e: React.MouseEvent) => {
if (activeTool !== 'move' || e.button !== 0) return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const startX = e.clientX - rect.left;
const startY = e.clientY - rect.top;
e.preventDefault();
e.stopPropagation();
setMarqueeState({
startX,
startY,
currentX: startX,
currentY: startY,
additive: e.shiftKey || e.ctrlKey || e.metaKey,
});
};
const handleWorkspaceMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (activeTool !== 'move' || e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('[data-label-element]') || target.closest('.ps-handle')) return;
startMarquee(e);
};
const handleElementMouseDown = (
e: React.MouseEvent,
element: TemplateElement,
actionType: 'drag' | 'resize'
) => {
if (e.button !== 0) return;
e.stopPropagation();
if (e.shiftKey || e.ctrlKey || e.metaKey) {
return;
}
handleDragStart(e, element, actionType);
};
const handleElementClick = (e: React.MouseEvent, element: TemplateElement) => {
e.stopPropagation();
if (dragMovedRef.current) {
dragMovedRef.current = false;
return;
}
const isMulti = e.shiftKey || e.ctrlKey || e.metaKey;
if (isMulti) {
if (selectedElementIds.includes(element.id)) {
onSelectElements(selectedElementIds.filter((x) => x !== element.id));
} else {
onSelectElements([...selectedElementIds, element.id]);
}
} else {
onSelectElements([element.id]);
}
};
const handleDragStart = (e: React.MouseEvent, element: TemplateElement, actionType: 'drag' | 'resize') => {
e.preventDefault();
if (!selectedElementIds.includes(element.id)) {
onSelectElements([element.id]);
}
if (element.locked) return;
setFrozenCanvasElements(template.elements);
setDragState({
elementId: element.id,
type: actionType,
startX: e.clientX,
startY: e.clientY,
startElemX: element.x,
startElemY: element.y,
startElemW: element.width,
startElemH: element.height,
groupStartPositions: buildGroupStartPositions(element),
});
};
const handleResizeStart = (
e: React.MouseEvent,
element: TemplateElement,
corner: ResizeCorner
) => {
e.stopPropagation();
e.preventDefault();
onSelectElements([element.id]);
if (element.locked) return;
setFrozenCanvasElements(template.elements);
setDragState({
elementId: element.id,
type: 'resize',
resizeCorner: corner,
startX: e.clientX,
startY: e.clientY,
startElemX: element.x,
startElemY: element.y,
startElemW: element.width,
startElemH: element.height,
});
};
const fitToView = () => {
const area = canvasAreaRef.current;
if (!area) return;
const pad = 80;
const availW = area.clientWidth - pad;
const availH = area.clientHeight - pad;
const docW = mmToPx(template.width, 1);
const docH = mmToPx(template.height, 1);
const fitZoom = Math.min(availW / docW, availH / docH, 5);
setZoom(Math.max(0.5, Math.round(fitZoom * 100) / 100));
};
const selectedElemObj = (localElements || template.elements).find(
(el) => el.id === selectedElementId
);
const filterVisibleElements = useCallback(
(elements: TemplateElement[]) =>
elements.filter((el) =>
shouldRenderElement(el, null, 0, previewDefaults, 'design')
),
[previewDefaults]
);
const canvasPreviewElements = filterVisibleElements(
frozenCanvasElements ?? template.elements
);
const overlayElements = filterVisibleElements(localElements ?? template.elements);
const tools: { id: ActiveTool; icon: React.ReactNode; label: string; shortcut?: string }[] = [
{ id: 'move', icon: <Move className="w-[18px] h-[18px]" />, label: '移动工具', shortcut: 'V' },
{ id: 'text', icon: <Type className="w-[18px] h-[18px]" />, label: '文本工具', shortcut: 'T' },
{ id: 'barcode', icon: <Barcode className="w-[18px] h-[18px]" />, label: '条形码' },
{ id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' },
];
return (
<div className="flex flex-1 min-w-0 min-h-0">
<div className="ps-toolbar flex flex-col">
{tools.map((tool) => (
<button
key={tool.id}
className={`ps-tool-btn ${activeTool === tool.id ? 'active' : ''}`}
onClick={() => handleToolClick(tool.id)}
title={`${tool.label}${tool.shortcut ? ` (${tool.shortcut})` : ''}`}
>
{tool.icon}
</button>
))}
<button
className="ps-tool-btn"
onClick={handleImageToolClick}
title="图片工具"
>
<ImageIcon className="w-[18px] h-[18px]" />
</button>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageFile}
/>
<div className="flex-1" />
{selectedElementIds.length > 0 && (
<button
className="ps-tool-btn hover:!bg-red-900/40 hover:!text-red-400"
onClick={deleteSelectedElements}
title="删除选中元素 (Del)"
>
<Trash2 className="w-[18px] h-[18px]" />
</button>
)}
</div>
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<div
ref={canvasAreaRef}
className="flex-1 overflow-auto ps-workspace-bg flex items-center justify-center"
onMouseDown={handleWorkspaceMouseDown}
onClick={(e) => {
if (ignoreCanvasClickRef.current) {
ignoreCanvasClickRef.current = false;
return;
}
if (e.target === e.currentTarget) {
onSelectElements([]);
}
}}
>
<div
ref={canvasRef}
className="relative bg-white shadow-[0_2px_20px_rgba(0,0,0,0.5)] select-none"
style={{
width: `${mmToPx(template.width, zoom)}px`,
height: `${mmToPx(template.height, zoom)}px`,
}}
>
<div className="absolute -top-5 left-0 text-[10px] font-mono text-[#aaa]">
{template.width} mm
</div>
<div
className="absolute -left-8 top-0 text-[10px] font-mono text-[#aaa]"
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
>
{template.height} mm
</div>
{/* 整页统一渲染:半透明背景按图层层级与下方元素真实合成 */}
<div className="absolute inset-0 overflow-hidden pointer-events-none z-0">
<CanvasLabelImage
widthMm={template.width}
heightMm={template.height}
elements={canvasPreviewElements}
rowData={null}
rowIndex={0}
showBorder={false}
variableDefaults={previewDefaults}
/>
</div>
{activeTool === 'move' && (
<div
className="absolute inset-0 z-[1] cursor-crosshair"
aria-hidden
onMouseDown={startMarquee}
/>
)}
{marqueeState && (
<div
className="ps-marquee-select"
style={{
left: `${Math.min(marqueeState.startX, marqueeState.currentX)}px`,
top: `${Math.min(marqueeState.startY, marqueeState.currentY)}px`,
width: `${Math.abs(marqueeState.currentX - marqueeState.startX)}px`,
height: `${Math.abs(marqueeState.currentY - marqueeState.startY)}px`,
}}
/>
)}
{overlayElements.map((element, index) => {
const isLocked = !!element.locked;
const isSelected = !isLocked && selectedElementIds.includes(element.id);
const isPrimary = selectedElementIds.indexOf(element.id) === 0;
const elemX = mmToPx(element.x, zoom);
const elemY = mmToPx(element.y, zoom);
const elemW = mmToPx(element.width, zoom);
const elemH = mmToPx(element.height, zoom);
return (
<div
key={element.id}
data-label-element
className={`absolute group ${
isLocked
? 'pointer-events-none'
: isSelected
? 'ps-selection-ring cursor-move'
: 'cursor-move hover:outline hover:outline-1 hover:outline-[#31a8ff]/40'
}`}
style={{
left: `${elemX}px`,
top: `${elemY}px`,
width: `${elemW}px`,
height: `${elemH}px`,
zIndex: isSelected ? index + 1000 : index + 10,
}}
onMouseDown={
isLocked ? undefined : (e) => handleElementMouseDown(e, element, 'drag')
}
onClick={isLocked ? undefined : (e) => handleElementClick(e, element)}
>
{selectedElementIds.length > 1 && isSelected && (
<div
className={`absolute -top-5 right-0 text-[9px] font-bold px-1 py-0.5 z-50 text-white min-w-[14px] text-center ${
isPrimary ? 'bg-[#31a8ff]' : 'bg-emerald-600'
}`}
>
{selectedElementIds.indexOf(element.id) + 1}
</div>
)}
{isSelected && (
<>
<div
className="ps-handle top-0 left-0 -translate-x-1/2 -translate-y-1/2 cursor-nw-resize"
onMouseDown={(e) => handleResizeStart(e, element, 'nw')}
title="拖拽调整尺寸"
/>
<div
className="ps-handle top-0 right-0 translate-x-1/2 -translate-y-1/2 cursor-ne-resize"
onMouseDown={(e) => handleResizeStart(e, element, 'ne')}
title="拖拽调整尺寸"
/>
<div
className="ps-handle bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-sw-resize"
onMouseDown={(e) => handleResizeStart(e, element, 'sw')}
title="拖拽调整尺寸"
/>
<div
className="ps-handle bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-se-resize"
onMouseDown={(e) => handleResizeStart(e, element, 'se')}
title="拖拽调整尺寸"
/>
</>
)}
{!isLocked && (
<div className="absolute top-[-16px] left-0 bg-[#3c3c3c] text-[#ccc] text-[9px] px-1 py-px opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
{element.name}
</div>
)}
</div>
);
})}
</div>
</div>
<div className="ps-status-bar">
<div className="flex items-center gap-3">
<span>
: {template.width} × {template.height} mm
</span>
{selectedElemObj && (
<span className="text-[#31a8ff]">
| {selectedElemObj.name} X:{selectedElemObj.x} Y:{selectedElemObj.y} W:
{selectedElemObj.width} H:{selectedElemObj.height} mm
</span>
)}
{selectedElementIds.length > 1 && (
<span className="text-emerald-400">
| {selectedElementIds.length}
</span>
)}
{activeTool === 'move' && (
<span className="text-[#888] hidden sm:inline">
| · Shift/Ctrl+ · Ctrl+A
</span>
)}
</div>
<div className="flex items-center gap-3">
<span className="hidden sm:inline text-[10px]">
使 · · Ctrl+
</span>
<div className="ps-zoom-control">
<button className="ps-zoom-btn" onClick={() => setZoom(Math.max(0.5, zoom - 0.25))} title="缩小">
<ZoomOut className="w-3 h-3" />
</button>
<span className="px-1.5 text-[10px] font-mono text-[#ccc] min-w-[42px] text-center select-none">
{Math.round(zoom * 100)}%
</span>
<button className="ps-zoom-btn" onClick={() => setZoom(Math.min(5, zoom + 0.25))} title="放大">
<ZoomIn className="w-3 h-3" />
</button>
</div>
<button
onClick={fitToView}
className="text-[10px] text-[#aaa] hover:text-white px-1.5 py-0.5 border border-[#4a4a4a] hover:border-[#666] transition cursor-pointer"
title="适合窗口"
>
</button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,157 @@
import React, { useState } from 'react';
import { ChevronDown, Settings } from 'lucide-react';
import {
CutLineConfig,
ExportRangeConfig,
LabelTemplate,
PaperConfig,
RecordRow,
} from '../types';
import { PaperSettingsPanel } from './PreviewGrid';
interface MobileExportPropsPanelProps {
template: LabelTemplate;
paper: PaperConfig;
onChangePaper: (paper: PaperConfig) => void;
rows: RecordRow[];
exportRange: ExportRangeConfig;
cutLine: CutLineConfig;
onChangeCutLine: (cutLine: CutLineConfig) => void;
locked?: boolean;
}
export const MobileExportPropsPanel: React.FC<MobileExportPropsPanelProps> = ({
template,
paper,
onChangePaper,
rows,
exportRange,
cutLine,
onChangeCutLine,
locked = false,
}) => {
const [open, setOpen] = useState(false);
const paperLabel =
paper.type === 'custom' ? `${paper.width}×${paper.height} mm` : paper.type;
const summary = `${paperLabel} · ${paper.orientation === 'portrait' ? '纵向' : '横向'}${
cutLine.enabled ? ' · 裁切线' : ''
}`;
const toggleOpen = () => {
if (locked) return;
setOpen((value) => !value);
};
return (
<section
className={`mobile-export-card mobile-export-props-card ${open ? 'is-open' : ''} ${
locked ? 'mobile-export-card-locked' : ''
}`}
aria-disabled={locked}
>
<button
type="button"
className="mobile-export-card-head mobile-export-props-head"
onClick={toggleOpen}
aria-expanded={open && !locked}
disabled={locked}
>
<Settings className="w-4 h-4 text-indigo-500 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0 text-left">
<h2></h2>
<p>{locked ? '上传数据后可配置纸张与裁切线' : summary}</p>
</div>
{!locked && (
<ChevronDown
className={`w-5 h-5 text-slate-400 shrink-0 mt-0.5 transition-transform duration-200 ${
open ? 'rotate-180' : ''
}`}
/>
)}
</button>
{open && !locked && (
<div className="mobile-export-card-body">
<PaperSettingsPanel
theme="mobile"
paper={paper}
onChangePaper={onChangePaper}
template={template}
rows={rows}
exportRange={exportRange}
totalRowCount={rows.length}
/>
<div className="space-y-3 pt-1 mt-3">
<h4 className="mobile-paper-section-title">线</h4>
<label className="flex items-center gap-3 min-h-[44px] cursor-pointer select-none">
<input
type="checkbox"
checked={cutLine.enabled}
onChange={(e) => onChangeCutLine({ ...cutLine, enabled: e.target.checked })}
className="w-5 h-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-slate-700"> PDF 线</span>
</label>
<fieldset
disabled={!cutLine.enabled}
className={`space-y-3 border-0 p-0 m-0 min-w-0 ${!cutLine.enabled ? 'opacity-50' : ''}`}
>
<div>
<label className="mobile-field-label">线</label>
<select
value={cutLine.style}
onChange={(e) =>
onChangeCutLine({
...cutLine,
style: e.target.value as CutLineConfig['style'],
})
}
className="mobile-field"
>
<option value="dashed">线</option>
<option value="solid">线</option>
</select>
</div>
<div>
<label className="mobile-field-label">线 (mm)</label>
<input
type="number"
min="0.02"
max="2"
step="0.02"
value={cutLine.width}
onChange={(e) =>
onChangeCutLine({
...cutLine,
width: Math.max(0.02, Math.min(2, Number(e.target.value) || 0.1)),
})
}
className="mobile-field font-mono"
/>
</div>
<div>
<label className="mobile-field-label"></label>
<div className="flex items-center gap-2">
<input
type="color"
value={cutLine.color}
onChange={(e) => onChangeCutLine({ ...cutLine, color: e.target.value })}
className="color-input shrink-0"
aria-label="裁切线颜色"
/>
<input
type="text"
value={cutLine.color}
onChange={(e) => onChangeCutLine({ ...cutLine, color: e.target.value })}
className="mobile-field font-mono flex-1 min-w-0"
/>
</div>
</div>
</fieldset>
</div>
</div>
)}
</section>
);
};

View File

@@ -0,0 +1,291 @@
import React from 'react';
import {
ChevronLeft,
FileDown,
Loader2,
CheckCircle2,
Circle,
Database,
Link2,
Download,
} from 'lucide-react';
import {
ImportData,
LabelTemplate,
RecordRow,
ExportRangeConfig,
PaperConfig,
CutLineConfig,
} from '../types';
import { isVariableMappingConfigured } from '../utils';
import { DataImporter, downloadDataTemplate } from './DataImporter';
import { VariableMappingPanel } from './VariableMappingPanel';
import { MobileExportPropsPanel } from './MobileExportPropsPanel';
import { DataRangeFields } from './DataRangeFields';
interface MobileExportViewProps {
template: LabelTemplate;
templateName: string;
templateVariables: string[];
importData: ImportData | null;
onDataLoaded: (data: ImportData) => void;
onClear: () => void;
dataColumns: string[];
variableColumnMapping: Record<string, string>;
onChangeMapping: (mapping: Record<string, string>) => void;
paper: PaperConfig;
onChangePaper: (paper: PaperConfig) => void;
rows: RecordRow[];
draftExportRange: ExportRangeConfig;
onChangeDraftExportRange: (range: ExportRangeConfig) => void;
cutLine: CutLineConfig;
onChangeCutLine: (cutLine: CutLineConfig) => void;
dataStale: boolean;
dataApplied: boolean;
onApplyData: () => void;
draftSelectedCount: number;
appliedSelectedCount: number;
appliedTotalPages: number;
pdfGenerating: boolean;
onBack: () => void;
onExportPdf: () => void;
}
function StepBadge({
done,
active,
number,
}: {
done: boolean;
active: boolean;
number: number;
}) {
if (done) {
return (
<span className="mobile-step-badge done">
<CheckCircle2 className="w-3.5 h-3.5" />
</span>
);
}
return (
<span className={`mobile-step-badge ${active ? 'active' : ''}`}>
{active ? number : <Circle className="w-3 h-3 opacity-50" />}
</span>
);
}
export const MobileExportView: React.FC<MobileExportViewProps> = ({
template,
templateName,
templateVariables,
importData,
onDataLoaded,
onClear,
dataColumns,
variableColumnMapping,
onChangeMapping,
paper,
onChangePaper,
rows,
draftExportRange,
onChangeDraftExportRange,
cutLine,
onChangeCutLine,
dataStale,
dataApplied,
onApplyData,
draftSelectedCount,
appliedSelectedCount,
appliedTotalPages,
pdfGenerating,
onBack,
onExportPdf,
}) => {
const hasData = rows.length > 0;
const mappedCount = templateVariables.filter((v) =>
isVariableMappingConfigured(variableColumnMapping, v, dataColumns)
).length;
const needsMapping = templateVariables.length > 0;
const mappingComplete = !needsMapping || (hasData && mappedCount === templateVariables.length);
const currentStep = !hasData ? 1 : !mappingComplete ? 2 : 3;
const exportBlockedReason = !hasData
? '请先上传 Excel 数据'
: !mappingComplete
? `还有 ${templateVariables.length - mappedCount} 个变量未关联`
: dataStale || !dataApplied
? '请先应用数据'
: appliedSelectedCount === 0
? '当前数据范围内无标签'
: null;
return (
<div className="mobile-export-view">
<header className="mobile-export-header">
<button type="button" onClick={onBack} className="mobile-export-back" aria-label="返回">
<ChevronLeft className="w-5 h-5" />
</button>
<div className="mobile-export-header-text min-w-0 flex-1">
<p className="mobile-export-title truncate">{templateName}</p>
<p className="mobile-export-subtitle">
{template.width}×{template.height} mm
{hasData && dataApplied && !dataStale && (
<span>
{' '}
· {appliedSelectedCount} · {appliedTotalPages}
</span>
)}
</p>
</div>
</header>
<div className="mobile-export-steps" aria-label="导出步骤">
<div className={`mobile-export-step ${currentStep >= 1 ? 'active' : ''} ${hasData ? 'done' : ''}`}>
<StepBadge done={hasData} active={currentStep === 1} number={1} />
<span></span>
</div>
<div className="mobile-export-step-line" />
<div
className={`mobile-export-step ${currentStep >= 2 ? 'active' : ''} ${mappingComplete && hasData ? 'done' : ''}`}
>
<StepBadge done={mappingComplete && hasData} active={currentStep === 2} number={2} />
<span></span>
</div>
<div className={`mobile-export-step ${currentStep >= 3 ? 'active' : ''}`}>
<StepBadge done={dataApplied && !dataStale && mappingComplete} active={currentStep === 3} number={3} />
<span></span>
</div>
</div>
<main className="mobile-export-content">
<section className="mobile-export-card">
<div className="mobile-export-card-head">
<Database className="w-4 h-4 text-indigo-500 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h2> 1 · </h2>
<p>{hasData ? '已上传,可设置数据范围' : '上传 Excel 或 CSV 文件'}</p>
</div>
<button
type="button"
onClick={() => downloadDataTemplate(templateName, templateVariables)}
className="mobile-export-icon-btn"
aria-label="下载数据模板"
>
<Download className="w-5 h-5" />
</button>
</div>
</div>
</div>
<div className="mobile-export-card-body space-y-5">
<DataImporter
variant="mobile"
importData={importData}
onDataLoaded={onDataLoaded}
onClear={onClear}
templateName={templateName}
templateVariables={templateVariables}
/>
{hasData && (
<>
<DataRangeFields
variant="mobile"
exportRange={draftExportRange}
onChangeExportRange={onChangeDraftExportRange}
rows={rows}
paper={paper}
template={template}
/>
<div className="space-y-2 pt-1 border-t border-slate-100">
{dataStale && (
<p className="text-xs text-amber-600"></p>
)}
<button
type="button"
onClick={onApplyData}
disabled={draftSelectedCount === 0}
className={`w-full inline-flex items-center justify-center gap-2 min-h-[44px] px-4 rounded-xl text-sm font-semibold transition cursor-pointer ${
draftSelectedCount > 0
? dataStale || !dataApplied
? 'bg-indigo-600 text-white active:bg-indigo-700'
: 'bg-slate-100 text-slate-600 border border-slate-200'
: 'bg-slate-100 text-slate-400 cursor-not-allowed'
}`}
>
<Database className="w-4 h-4 shrink-0" />
</button>
</div>
</>
)}
</div>
</section>
{hasData && (
<section className="mobile-export-card">
<div className="mobile-export-card-head">
<Link2 className="w-4 h-4 text-indigo-500 shrink-0" />
<div className="flex-1 min-w-0">
<h2> 2 · </h2>
<p>
{needsMapping
? `已关联 ${mappedCount}/${templateVariables.length}`
: '此模板无需变量映射'}
</p>
</div>
</div>
<div className="mobile-export-card-body">
<VariableMappingPanel
variant="mobile"
variables={templateVariables}
columns={dataColumns}
mapping={variableColumnMapping}
onChangeMapping={onChangeMapping}
variableDefaults={template.variableDefaults}
/>
</div>
</section>
)}
<MobileExportPropsPanel
template={template}
paper={paper}
onChangePaper={onChangePaper}
rows={rows}
exportRange={draftExportRange}
cutLine={cutLine}
onChangeCutLine={onChangeCutLine}
locked={!hasData}
/>
</main>
<footer className="mobile-export-footer">
{exportBlockedReason && !pdfGenerating && (
<p className="mobile-export-footer-hint">{exportBlockedReason}</p>
)}
<button
type="button"
onClick={onExportPdf}
disabled={!!exportBlockedReason || pdfGenerating}
className="mobile-export-submit"
>
{pdfGenerating ? (
<>
<Loader2 className="w-5 h-5 animate-spin shrink-0" />
PDF
</>
) : (
<>
<FileDown className="w-5 h-5 shrink-0" />
{exportBlockedReason
? '生成 PDF'
: `生成 PDF${appliedSelectedCount} 枚 · ${appliedTotalPages} 页)`}
</>
)}
</button>
</footer>
</div>
);
};

View File

@@ -0,0 +1,712 @@
import React, { useMemo, useState, useEffect } from 'react';
import { PaperConfig, LabelTemplate, RecordRow, PaperType, CutLineConfig, ExportRangeConfig } from '../types';
import {
DEFAULT_EXPORT_RANGE,
DEFAULT_CUT_LINE_CONFIG,
buildPrintablePages,
type PrintableLabel,
} from '../utils';
import { CanvasLabelImage } from './CanvasLabelImage';
import { FileDown, RefreshCcw } from 'lucide-react';
interface PageInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
value: string | number;
onCommit: (val: string) => void;
inputClassName?: string;
}
export const PageInput: React.FC<PageInputProps> = ({ value, onCommit, inputClassName = '', ...props }) => {
const [localVal, setLocalVal] = useState<string>(String(value));
useEffect(() => {
setLocalVal(String(value));
}, [value]);
const handleBlur = () => {
if (localVal !== String(value)) {
onCommit(localVal);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur();
}
};
return (
<input
{...props}
value={localVal}
onChange={(e) => setLocalVal(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={inputClassName}
/>
);
};
interface LayoutStats {
gridCols: number;
gridRows: number;
labelsPerPage: number;
totalPages: number;
selectedCount: number;
}
interface PaperSettingsPanelProps {
paper: PaperConfig;
onChangePaper: (updated: PaperConfig) => void;
template: LabelTemplate;
rows?: RecordRow[];
layoutStats?: LayoutStats;
totalRowCount?: number;
theme?: 'light' | 'ps' | 'mobile';
exportRange?: ExportRangeConfig;
}
const paperEquals = (a: PaperConfig, b: PaperConfig) =>
a.type === b.type &&
a.width === b.width &&
a.height === b.height &&
a.orientation === b.orientation &&
a.marginTop === b.marginTop &&
a.marginRight === b.marginRight &&
a.marginBottom === b.marginBottom &&
a.marginLeft === b.marginLeft &&
a.columnGap === b.columnGap &&
a.rowGap === b.rowGap &&
a.flow === b.flow;
export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
paper,
onChangePaper,
template,
rows = [],
layoutStats,
totalRowCount,
theme = 'light',
exportRange,
}) => {
const [draftPaper, setDraftPaper] = useState(paper);
const panelRef = React.useRef<HTMLDivElement>(null);
useEffect(() => {
setDraftPaper(paper);
}, [paper]);
const commitDraftIfChanged = () => {
if (!paperEquals(draftPaper, paper)) {
onChangePaper(draftPaper);
}
};
const handlePanelBlur = (e: React.FocusEvent<HTMLDivElement>) => {
const next = e.relatedTarget as Node | null;
if (next && panelRef.current?.contains(next)) return;
commitDraftIfChanged();
};
const isPs = theme === 'ps';
const isMobile = theme === 'mobile';
const fieldClass = isMobile
? 'mobile-field font-mono text-center'
: isPs
? 'ps-field font-mono text-center'
: 'w-full px-2.5 py-1.5 bg-gray-50 border border-gray-100 rounded-lg text-xs text-center font-mono';
const selectClass = isMobile
? 'mobile-field'
: isPs
? 'ps-field'
: 'w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg font-medium text-gray-700 text-xs';
const labelClass = isMobile
? 'mobile-field-label'
: isPs
? 'ps-field-label'
: 'block text-[10px] uppercase font-bold text-gray-400 mb-1';
const sectionTitleClass = isMobile
? 'font-bold text-indigo-500 text-sm'
: isPs
? 'ps-section-title'
: 'font-bold text-[#31a8ff] text-xs';
const rangeForStats = exportRange ?? DEFAULT_EXPORT_RANGE;
const draftLayoutStats = useMemo(
() => buildPrintablePages(rows, draftPaper, template, rangeForStats),
[rows, draftPaper, template, rangeForStats]
);
const gridInfo = useMemo(() => {
if (layoutStats && !exportRange) {
return { cols: layoutStats.gridCols, rows: layoutStats.gridRows };
}
return { cols: draftLayoutStats.gridCols, rows: draftLayoutStats.gridRows };
}, [layoutStats, exportRange, draftLayoutStats]);
const labelsPerPage = layoutStats?.labelsPerPage ?? draftLayoutStats.labelsPerPage;
const totalPages = layoutStats?.totalPages ?? draftLayoutStats.totalPages;
const selectedCount = layoutStats?.selectedCount ?? draftLayoutStats.selectedCount;
const allRowCount = totalRowCount ?? rows.length;
const autoCenterMargins = () => {
const paperW = draftPaper.orientation === 'portrait' ? draftPaper.width : draftPaper.height;
const paperH = draftPaper.orientation === 'portrait' ? draftPaper.height : draftPaper.width;
const lw = template.width;
const lh = template.height;
const minMargin = 8;
const usableWInit = paperW - minMargin * 2;
const usableHInit = paperH - minMargin * 2;
const maxCols = Math.floor((usableWInit + draftPaper.columnGap) / (lw + draftPaper.columnGap)) || 1;
const maxRows = Math.floor((usableHInit + draftPaper.rowGap) / (lh + draftPaper.rowGap)) || 1;
const gridWidth = maxCols * lw + (maxCols - 1) * draftPaper.columnGap;
const gridHeight = maxRows * lh + (maxRows - 1) * draftPaper.rowGap;
const balancedLR = Math.max(minMargin, Math.round(((paperW - gridWidth) / 2) * 10) / 10);
const balancedTB = Math.max(minMargin, Math.round(((paperH - gridHeight) / 2) * 10) / 10);
const next = {
...draftPaper,
marginLeft: balancedLR,
marginRight: balancedLR,
marginTop: balancedTB,
marginBottom: balancedTB,
};
setDraftPaper(next);
onChangePaper(next);
};
const applyPaper = (next: PaperConfig) => {
setDraftPaper(next);
if (isMobile) onChangePaper(next);
};
const handlePaperTypeChange = (type: PaperType) => {
let w = 210;
let h = 297;
if (type === 'A5') {
w = 148;
h = 210;
} else if (type === 'A6') {
w = 105;
h = 148;
} else if (type === 'custom') {
w = draftPaper.width;
h = draftPaper.height;
}
applyPaper({ ...draftPaper, type, width: w, height: h });
};
const patchDraftPaper = (patch: Partial<PaperConfig>) => {
applyPaper({ ...draftPaper, ...patch });
};
return (
<div
ref={panelRef}
onBlur={isMobile ? undefined : handlePanelBlur}
className={`space-y-4 text-xs ${isPs || isMobile ? '' : 'bg-white border border-gray-200 rounded-2xl p-6 shadow-sm'
}`}
>
{!isPs && !isMobile && (
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-bold text-gray-900 leading-none"> & PDF </h3>
</div>
)}
{isPs && (
<p className="text-[10px] text-[#666] leading-relaxed">
</p>
)}
<div className={isPs || isMobile ? 'space-y-4' : 'grid grid-cols-1 md:grid-cols-4 gap-6'}>
<div className="space-y-2">
<h4 className={sectionTitleClass}></h4>
<div>
<label className={labelClass}></label>
<select
value={draftPaper.type}
onChange={(e) => handlePaperTypeChange(e.target.value as PaperType)}
className={selectClass}
>
<option value="A4">A4 (210×297 mm)</option>
<option value="A5">A5 (148×210 mm)</option>
<option value="A6">A6 (105×148 mm)</option>
<option value="custom"></option>
</select>
</div>
{draftPaper.type === 'custom' && (
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}> (mm)</label>
<PageInput
type="number"
value={draftPaper.width}
onCommit={(val) => patchDraftPaper({ width: Math.max(10, Number(val) || 10) })}
inputClassName={fieldClass}
/>
</div>
<div>
<label className={labelClass}> (mm)</label>
<PageInput
type="number"
value={draftPaper.height}
onCommit={(val) => patchDraftPaper({ height: Math.max(10, Number(val) || 10) })}
inputClassName={fieldClass}
/>
</div>
</div>
)}
<div>
<label className={labelClass}></label>
{isPs ? (
<div className="ps-toggle-group">
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'portrait' })}
className={`ps-toggle-btn flex-1 text-[10px] ${draftPaper.orientation === 'portrait' ? 'active' : ''}`}
>
</button>
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'landscape' })}
className={`ps-toggle-btn flex-1 text-[10px] ${draftPaper.orientation === 'landscape' ? 'active' : ''}`}
>
</button>
</div>
) : isMobile ? (
<div className="flex bg-slate-100 p-1 rounded-xl">
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'portrait' })}
className={`flex-1 py-2.5 text-sm font-semibold rounded-lg transition ${draftPaper.orientation === 'portrait'
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500'
} cursor-pointer`}
>
</button>
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'landscape' })}
className={`flex-1 py-2.5 text-sm font-semibold rounded-lg transition ${draftPaper.orientation === 'landscape'
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500'
} cursor-pointer`}
>
</button>
</div>
) : (
<div className="flex bg-gray-100 p-0.5 rounded-lg">
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'portrait' })}
className={`flex-1 py-1.5 text-[11px] font-semibold rounded ${draftPaper.orientation === 'portrait' ? 'bg-white text-gray-800 shadow-xs' : 'text-gray-500'
} cursor-pointer`}
>
</button>
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'landscape' })}
className={`flex-1 py-1.5 text-[11px] font-semibold rounded ${draftPaper.orientation === 'landscape' ? 'bg-white text-gray-800 shadow-xs' : 'text-gray-500'
} cursor-pointer`}
>
</button>
</div>
)}
</div>
</div>
<div className="space-y-2">
<h4 className={sectionTitleClass}> (mm)</h4>
<div className="grid grid-cols-2 gap-2">
{(
[
['上边距', 'marginTop', draftPaper.marginTop],
['下边距', 'marginBottom', draftPaper.marginBottom],
['左边距', 'marginLeft', draftPaper.marginLeft],
['右边距', 'marginRight', draftPaper.marginRight],
] as const
).map(([label, key, val]) => (
<div key={key}>
<label className={labelClass}>{label}</label>
<PageInput
type="number"
min="0"
max="100"
value={val}
onCommit={(v) => patchDraftPaper({ [key]: Number(v) || 0 })}
inputClassName={fieldClass}
/>
</div>
))}
<div>
<label className={labelClass}></label>
<button
type="button"
onClick={autoCenterMargins}
className={
isPs
? 'ps-btn w-full text-[10px]'
: isMobile
? 'mobile-secondary-btn w-full'
: 'w-full inline-flex items-center justify-center gap-1 bg-white hover:bg-gray-100 border border-gray-200 text-gray-600 py-1.5 px-3 rounded-lg text-[10px] font-bold cursor-pointer'
}
>
<RefreshCcw className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className={sectionTitleClass}> (mm)</h4>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}></label>
<PageInput
type="number"
step="0.5"
min="0"
max="50"
value={draftPaper.columnGap}
onCommit={(val) => patchDraftPaper({ columnGap: Number(val) || 0 })}
inputClassName={fieldClass}
/>
</div>
<div>
<label className={labelClass}></label>
<PageInput
type="number"
step="0.5"
min="0"
max="50"
value={draftPaper.rowGap}
onCommit={(val) => patchDraftPaper({ rowGap: Number(val) || 0 })}
inputClassName={fieldClass}
/>
</div>
</div>
<div>
<label className={labelClass}></label>
<select
value={draftPaper.flow}
onChange={(e) => patchDraftPaper({ flow: e.target.value as PaperConfig['flow'] })}
className={selectClass}
>
<option value="left-right-top-bottom"> </option>
<option value="top-bottom-left-right"> </option>
</select>
</div>
</div>
</div>
</div>
);
};
export interface LayoutPreviewProps {
paper: PaperConfig;
template: LabelTemplate;
printablePages: (PrintableLabel | null)[][];
gridCols: number;
labelsPerPage: number;
totalPages: number;
selectedCount: number;
totalRowCount: number;
/** 未刷新前根据当前配置计算的页数/枚数(用于工具栏提示) */
draftTotalPages: number;
draftSelectedCount: number;
previewReady: boolean;
previewLayoutStale: boolean;
cutLine: CutLineConfig;
activeRowIndex: number;
onSelectRowIndex: (idx: number) => void;
compact?: boolean;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
paper,
template,
printablePages,
gridCols,
labelsPerPage,
totalPages,
selectedCount,
totalRowCount,
draftTotalPages,
draftSelectedCount,
previewReady,
previewLayoutStale,
cutLine,
activeRowIndex,
onSelectRowIndex,
compact = false,
collapsed = false,
onToggleCollapsed,
}) => {
const [previewScale, setPreviewScale] = useState<number>(compact ? 1.4 : 3.3);
const [showAllPages, setShowAllPages] = useState(false);
const MAX_PREVIEW_PAGES = 3;
const gridInfo = useMemo(
() => ({ cols: gridCols, rows: Math.ceil(labelsPerPage / gridCols) || 1 }),
[gridCols, labelsPerPage]
);
const currentPaperW = paper.orientation === 'portrait' ? paper.width : paper.height;
const currentPaperH = paper.orientation === 'portrait' ? paper.height : paper.width;
const colGapPx = Math.max(0, paper.columnGap * previewScale);
const rowGapPx = Math.max(0, paper.rowGap * previewScale);
const visiblePages = showAllPages ? printablePages : printablePages.slice(0, MAX_PREVIEW_PAGES);
const hasMorePages = printablePages.length > MAX_PREVIEW_PAGES;
return (
<div
className={`flex-1 flex flex-col min-h-0 min-w-0 layout-preview-panel ${compact && collapsed ? 'layout-preview-collapsed' : ''
}`}
>
<div className="ps-preview-toolbar">
<div className="flex items-center gap-2 text-[11px] text-[#aaa] min-w-0 flex-1">
<FileDown className="w-3.5 h-3.5 text-[#31a8ff] shrink-0" />
<span className="font-semibold text-[#e8e8e8]"></span>
<span className="text-[#666]">|</span>
<span>
{previewReady ? totalPages : draftTotalPages} PDF
</span>
<span className="text-[#666]">|</span>
<span>
{(previewReady ? selectedCount : draftSelectedCount) < totalRowCount
? `${previewReady ? selectedCount : draftSelectedCount}/${totalRowCount}`
: `${totalRowCount}`}
</span>
{previewLayoutStale && previewReady && (
<span className="text-amber-500/90"></span>
)}
{previewReady && hasMorePages && !showAllPages && (
<span className="text-amber-500/90"> {MAX_PREVIEW_PAGES} </span>
)}
</div>
<div className="flex items-center gap-3 shrink-0">
{compact && onToggleCollapsed && (
<button
type="button"
onClick={onToggleCollapsed}
className="ps-btn text-[10px] px-2 py-1"
>
{collapsed ? '展开' : '收起'}
</button>
)}
<div className={`flex items-center gap-2 mobile-preview-zoom ${compact ? 'hidden' : ''}`}>
<span className="text-[10px] text-[#777]"></span>
<button
type="button"
onClick={() => setPreviewScale(Math.max(1.0, Math.round((previewScale - 0.4) * 10) / 10))}
disabled={previewScale <= 1.0}
className="ps-preview-zoom-btn"
>
-
</button>
<span className="text-[10px] font-mono text-[#31a8ff] min-w-[40px] text-center">
{Math.round((previewScale / 3.3) * 100)}%
</span>
<button
type="button"
onClick={() => setPreviewScale(Math.min(5.0, Math.round((previewScale + 0.4) * 10) / 10))}
disabled={previewScale >= 5.0}
className="ps-preview-zoom-btn"
>
+
</button>
<button
type="button"
onClick={() => setPreviewScale(2.0)}
className={`ps-preview-preset-btn ${previewScale === 2.0 ? 'active' : ''}`}
>
</button>
<button
type="button"
onClick={() => setPreviewScale(3.3)}
className={`ps-preview-preset-btn ${previewScale === 3.3 ? 'active' : ''}`}
>
100%
</button>
</div>
{hasMorePages && (
<button
type="button"
onClick={() => setShowAllPages((v) => !v)}
className="ps-btn text-[10px]"
>
{showAllPages ? '收起预览' : `展开全部 ${totalPages}`}
</button>
)}
</div>
</div>
<div className="flex-1 overflow-auto ps-workspace-bg p-6 min-h-0 ps-preview-workspace">
{!previewReady ? (
<div className="h-full flex flex-col items-center justify-center text-center px-8 gap-3">
<p className="text-sm text-[#aaa]"></p>
<p className="text-xs text-[#666] max-w-[320px] leading-relaxed">
</p>
</div>
) : (
<div className="space-y-8 pb-8 flex flex-col items-center">
{visiblePages.map((pageRows, pageIdx) => (
<div
key={pageIdx}
className="bg-white rounded shadow-2xl overflow-hidden relative border border-[#222] flex flex-col shrink-0"
style={{
width: `${currentPaperW * previewScale}px`,
}}
>
<div className="bg-[#f0f0f0] border-b border-gray-200 py-1.5 px-3 flex justify-between items-center text-[10px] font-semibold text-gray-500 select-none shrink-0">
<span> {pageIdx + 1} / {totalPages} </span>
<span>
{currentPaperW} × {currentPaperH} mm · {paper.orientation === 'portrait' ? '纵向' : '横向'}
</span>
</div>
<div
className="bg-white flex items-start justify-start relative select-none overflow-hidden shrink-0"
style={{
width: `${currentPaperW * previewScale}px`,
height: `${currentPaperH * previewScale}px`,
padding: `${paper.marginTop * previewScale}px ${paper.marginRight * previewScale}px ${paper.marginBottom * previewScale}px ${paper.marginLeft * previewScale}px`,
boxSizing: 'border-box',
}}
>
<div
className="grid bg-white"
style={{
gridTemplateColumns: `repeat(${gridInfo.cols}, ${template.width * previewScale}px)`,
gridAutoRows: `${template.height * previewScale}px`,
columnGap: `${colGapPx}px`,
rowGap: `${rowGapPx}px`,
width: 'max-content',
}}
>
{pageRows.map((item, cellIdx) => {
if (!item) {
return (
<div
key={cellIdx}
className="bg-white border border-dashed border-gray-200 flex items-center justify-center text-gray-300 font-mono text-[9px] box-border"
style={{
width: `${template.width * previewScale}px`,
height: `${template.height * previewScale}px`,
}}
>
</div>
);
}
const isActive = item.sourceIndex === activeRowIndex;
return (
<div
key={cellIdx}
onClick={() => onSelectRowIndex(item.sourceIndex)}
className={`bg-white text-center relative overflow-hidden flex flex-col justify-center cursor-pointer box-border ${isActive ? 'ring-2 ring-inset ring-[#31a8ff] z-10' : ''
}`}
style={{
width: `${template.width * previewScale}px`,
height: `${template.height * previewScale}px`,
}}
>
<CanvasLabelImage
widthMm={template.width}
heightMm={template.height}
elements={template.elements}
rowData={item.row}
rowIndex={item.sourceIndex}
showBorder={false}
mode="output"
/>
{cutLine.enabled && (
<div
className="absolute inset-0 pointer-events-none box-border"
style={{
border: `${Math.max(1, cutLine.width * previewScale)}px ${cutLine.style} ${cutLine.color}`,
}}
/>
)}
</div>
);
})}
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="ps-status-bar">
<span>
{previewReady
? `${gridInfo.cols}× ${gridInfo.rows} 行 · 每页 ${labelsPerPage}`
: `待绘制 · 预计 ${draftTotalPages}`}
</span>
<span>
{previewReady ? '点击标签可高亮对应数据行' : '数据变更后请在右侧面板手动刷新绘制'}
</span>
</div>
</div>
);
};
/** @deprecated 使用 ExportSidebar + LayoutPreview 组合 */
export interface PreviewGridProps extends LayoutPreviewProps {
paper: PaperConfig;
onChangePaper: (updated: PaperConfig) => void;
}
export const PreviewGrid: React.FC<PreviewGridProps> = ({
paper,
onChangePaper,
template,
rows,
activeRowIndex,
onSelectRowIndex,
}) => {
const layout = buildPrintablePages(rows, paper, template, DEFAULT_EXPORT_RANGE);
return (
<div className="space-y-6">
<PaperSettingsPanel
paper={paper}
onChangePaper={onChangePaper}
template={template}
layoutStats={layout}
totalRowCount={rows.length}
/>
<LayoutPreview
paper={paper}
template={template}
printablePages={layout.pages}
gridCols={layout.gridCols}
labelsPerPage={layout.labelsPerPage}
totalPages={layout.totalPages}
selectedCount={layout.selectedCount}
totalRowCount={rows.length}
draftTotalPages={layout.totalPages}
draftSelectedCount={layout.selectedCount}
previewReady
previewLayoutStale={false}
cutLine={template.cutLine ?? DEFAULT_CUT_LINE_CONFIG}
activeRowIndex={activeRowIndex}
onSelectRowIndex={onSelectRowIndex}
/>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
import React, { useEffect, useRef } from 'react';
import QRCode from 'qrcode';
interface QRCodeRendererProps {
value: string;
sizeMmValue: number; // element size in mm
}
export const QRCodeRenderer: React.FC<QRCodeRendererProps> = ({ value, sizeMmValue }) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (!canvasRef.current) return;
const valStr = value ? String(value).trim() : 'SAMPLE QR';
const numSizePx = Math.round(sizeMmValue * 3.78) || 80;
QRCode.toCanvas(canvasRef.current, valStr, {
width: numSizePx,
margin: 0,
color: {
dark: '#000000',
light: '#ffffff',
},
}).catch((err) => {
console.error('QR code generation failed:', err);
});
}, [value, sizeMmValue]);
return (
<div className="flex items-center justify-center overflow-hidden inline-block bg-white border border-gray-100/10 rounded">
<canvas ref={canvasRef} className="block select-none" />
</div>
);
};

View File

@@ -0,0 +1,145 @@
import React from 'react';
import { VARIABLE_MAPPING_USE_DEFAULT } from '../utils';
interface VariableMappingPanelProps {
variables: string[];
columns: string[];
mapping: Record<string, string>;
onChangeMapping: (mapping: Record<string, string>) => void;
variableDefaults?: Record<string, string> | null;
variant?: 'default' | 'ps' | 'mobile';
}
export const VariableMappingPanel: React.FC<VariableMappingPanelProps> = ({
variables,
columns,
mapping,
onChangeMapping,
variableDefaults,
variant = 'default',
}) => {
const isPs = variant === 'ps';
const isMobile = variant === 'mobile';
const emptyClass = isMobile
? 'text-sm text-slate-500 leading-relaxed'
: isPs
? 'text-[10px] text-[#888] leading-relaxed'
: 'text-xs text-gray-500';
const warnClass = isMobile
? 'text-sm text-amber-700 leading-relaxed'
: isPs
? 'text-[10px] text-amber-400/90 leading-relaxed'
: 'text-xs text-amber-800';
const cardClass = isMobile
? ''
: isPs
? 'space-y-3'
: 'bg-white border border-gray-200 rounded-2xl p-5 shadow-sm space-y-4';
const rowClass = isMobile
? 'mobile-mapping-row'
: isPs
? 'flex flex-col gap-1.5 text-[10px]'
: 'flex flex-col gap-1.5 px-4 py-2.5 bg-gray-50/50 text-xs';
const selectClass = isMobile
? 'mobile-field mobile-field-plain w-full'
: isPs
? 'ps-field w-full text-[10px] py-1'
: 'w-full px-2 py-1.5 bg-white border border-gray-200 rounded-lg text-xs';
if (variables.length === 0) {
return (
<div className={isPs ? '' : 'bg-white border border-gray-200 rounded-2xl p-5 shadow-sm'}>
<p className={emptyClass}>
使 {'{变量}'}
</p>
</div>
);
}
// if (columns.length === 0) {
// return (
// <div className={isPs ? '' : 'bg-amber-50 border border-amber-100 rounded-2xl p-5 shadow-sm'}>
// <p className={warnClass}>
// 模板包含 {variables.length} 个变量,请先导入 Excel 数据后再配置列映射。
// </p>
// </div>
// );
// }
return (
<div className={cardClass}>
{/* {!isPs && (
<div className="flex items-center gap-2">
<div className="p-2 bg-indigo-50 text-indigo-600 rounded-xl">
<Link2 className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-gray-900">变量与数据列映射</h3>
<p className="text-[11px] text-gray-400 mt-0.5">
将模板中的 {'{变量}'} 关联到 Excel 列;同名列已自动匹配
</p>
</div>
</div>
)} */}
{/* {isPs && (
<div className="ps-section-title">
变量映射
</div>
)} */}
<div
className={
isMobile
? 'mobile-mapping-list'
: isPs
? 'space-y-1.5'
: 'divide-y divide-gray-100 overflow-hidden'
}
>
{variables.map((varName) => {
const mappedCol = mapping[varName] ?? '';
const defaultVal = variableDefaults?.[varName]?.trim();
const useDefaultLabel = defaultVal
? `使用默认值(${defaultVal}`
: '使用默认值';
return (
<div key={varName} className={rowClass}>
<span
className={`font-mono font-bold break-all ${
isMobile ? 'text-sm text-indigo-600' : isPs ? 'text-[#31a8ff]' : 'text-indigo-700'
}`}
>{`{${varName}}`}</span>
<select
value={mappedCol}
onChange={(e) => onChangeMapping({ ...mapping, [varName]: e.target.value })}
className={selectClass}
>
<option value=""></option>
<option value={VARIABLE_MAPPING_USE_DEFAULT}>{useDefaultLabel}</option>
{columns.map((col) => (
<option key={col} value={col}>
{col}
</option>
))}
</select>
{mappedCol === VARIABLE_MAPPING_USE_DEFAULT && !defaultVal && (
<p
className={
isMobile
? 'text-xs text-amber-600 leading-relaxed'
: isPs
? 'text-[9px] text-amber-400/90 leading-relaxed'
: 'text-[10px] text-amber-700 leading-relaxed'
}
>
</p>
)}
</div>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,129 @@
import React, { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import { VisibilityRule } from '../types';
import { visibilityOperatorNeedsValue } from '../utils';
import { VisibilityRuleForm, type VisibilityRuleDraft } from './VisibilityRuleForm';
const EMPTY_DRAFT: VisibilityRuleDraft = {
variable: '',
operator: 'not_empty',
value: '',
};
interface VisibilityRuleDialogProps {
open: boolean;
mode: 'add' | 'edit';
initialRule?: VisibilityRule;
variableOptions: string[];
onClose: () => void;
onConfirm: (rule: VisibilityRule) => void;
}
export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
open,
mode,
initialRule,
variableOptions,
onClose,
onConfirm,
}) => {
const [draft, setDraft] = useState<VisibilityRuleDraft>(EMPTY_DRAFT);
useEffect(() => {
if (!open) return;
if (mode === 'edit' && initialRule) {
setDraft({
variable: initialRule.variable,
operator: initialRule.operator,
value: initialRule.value ?? '',
});
} else {
setDraft(EMPTY_DRAFT);
}
}, [open, mode, initialRule]);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [open, onClose]);
if (!open) return null;
const canConfirm = draft.variable.trim().length > 0;
const title = mode === 'add' ? '添加显示条件' : '编辑显示条件';
const confirmLabel = mode === 'add' ? '确定添加' : '保存';
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const variable = draft.variable.trim();
if (!variable) return;
onConfirm({
variable,
operator: draft.operator,
value: visibilityOperatorNeedsValue(draft.operator)
? draft.value?.trim() || undefined
: undefined,
});
onClose();
};
return (
<div className="ps-modal-overlay" onClick={onClose} role="presentation">
<div
className="ps-modal"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="visibility-rule-dialog-title"
>
<div className="ps-modal-header">
<h3 id="visibility-rule-dialog-title" className="ps-modal-title">
{title}
</h3>
<button
type="button"
onClick={onClose}
className="ps-modal-close"
aria-label="关闭"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="ps-modal-body">
{variableOptions.length === 0 ? (
<p className="text-[11px] text-[#888] leading-relaxed">
</p>
) : (
<VisibilityRuleForm
rule={draft}
onChange={(patch) => setDraft((d) => ({ ...d, ...patch }))}
variableOptions={variableOptions}
/>
)}
</div>
<div className="ps-modal-footer">
<button type="button" onClick={onClose} className="ps-btn text-[11px]">
</button>
<button
type="submit"
disabled={!canConfirm || variableOptions.length === 0}
className="ps-btn ps-btn-primary text-[11px]"
>
{confirmLabel}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,75 @@
import React from 'react';
import {
VisibilityRule,
VisibilityOperator,
VISIBILITY_OPERATOR_LABELS,
} from '../types';
import { visibilityOperatorNeedsValue } from '../utils';
export interface VisibilityRuleDraft {
variable: string;
operator: VisibilityOperator;
value?: string;
}
interface VisibilityRuleFormProps {
rule: VisibilityRuleDraft;
onChange: (patch: Partial<VisibilityRule>) => void;
variableOptions: string[];
}
export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
rule,
onChange,
variableOptions,
}) => (
<div className="space-y-3">
<div>
<label className="ps-field-label"></label>
<select
value={rule.variable}
onChange={(e) => onChange({ variable: e.target.value })}
className="ps-field text-[11px] w-full mt-0.5"
>
{!rule.variable && <option value=""></option>}
{variableOptions.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
<div>
<label className="ps-field-label"></label>
<select
value={rule.operator}
onChange={(e) => {
const operator = e.target.value as VisibilityOperator;
onChange({
operator,
value: visibilityOperatorNeedsValue(operator) ? rule.value : undefined,
});
}}
className="ps-field text-[11px] w-full mt-0.5"
>
{(Object.keys(VISIBILITY_OPERATOR_LABELS) as VisibilityOperator[]).map((op) => (
<option key={op} value={op}>
{VISIBILITY_OPERATOR_LABELS[op]}
</option>
))}
</select>
</div>
{visibilityOperatorNeedsValue(rule.operator) && (
<div>
<label className="ps-field-label"></label>
<input
type="text"
value={rule.value ?? ''}
onChange={(e) => onChange({ value: e.target.value })}
placeholder="比较目标值"
className="ps-field font-mono text-[11px] w-full mt-0.5"
/>
</div>
)}
</div>
);

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { Pencil, Trash2 } from 'lucide-react';
import { VisibilityRule } from '../types';
import { formatVisibilityRuleDescription } from '../utils';
interface VisibilityRuleSummaryProps {
rule: VisibilityRule;
onEdit: () => void;
onDelete: () => void;
}
/** 将 {变量} 高亮展示 */
function renderRuleDescription(rule: VisibilityRule) {
const text = formatVisibilityRuleDescription(rule);
const parts = text.split(/(\{[^}]+\})/g);
return parts.map((part, i) =>
part.startsWith('{') && part.endsWith('}') ? (
<span key={i} className="var-token">
{part}
</span>
) : (
<span key={i}>{part}</span>
)
);
}
export const VisibilityRuleSummary: React.FC<VisibilityRuleSummaryProps> = ({
rule,
onEdit,
onDelete,
}) => (
<div className="visibility-rule-summary">
<button
type="button"
onClick={onEdit}
className="visibility-rule-summary-btn"
title="编辑条件"
>
<p className="visibility-rule-summary-text">{renderRuleDescription(rule)}</p>
</button>
<button
type="button"
onClick={onEdit}
title="编辑"
className="shrink-0 p-1 rounded text-[#666] hover:text-[#31a8ff] hover:bg-[#333] transition-colors"
>
<Pencil className="w-3 h-3" />
</button>
<button
type="button"
onClick={onDelete}
title="删除条件"
className="shrink-0 p-1 rounded text-[#666] hover:text-red-400 hover:bg-[#3a2020] transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
);

153
src/elementAlign.ts Normal file
View File

@@ -0,0 +1,153 @@
import { TemplateElement } from './types';
const round1 = (n: number) => Math.round(n * 10) / 10;
export type AlignMode =
| 'left'
| 'centerH'
| 'right'
| 'top'
| 'centerV'
| 'bottom';
function patchElements(
elements: TemplateElement[],
patches: Map<string, Partial<TemplateElement>>
): TemplateElement[] {
return elements.map((el) => {
const patch = patches.get(el.id);
return patch ? { ...el, ...patch } : el;
});
}
export function getSelectionBounds(selected: TemplateElement[]) {
const minX = Math.min(...selected.map((e) => e.x));
const minY = Math.min(...selected.map((e) => e.y));
const maxX = Math.max(...selected.map((e) => e.x + e.width));
const maxY = Math.max(...selected.map((e) => e.y + e.height));
return { minX, minY, maxX, maxY };
}
/** 选中元素对齐到共同选区边界 */
export function alignToSelectionBounds(
elements: TemplateElement[],
selectedIds: string[],
mode: AlignMode
): TemplateElement[] {
const selected = elements.filter((e) => selectedIds.includes(e.id) && !e.locked);
if (selected.length < 2) return elements;
const { minX, minY, maxX, maxY } = getSelectionBounds(selected);
const patches = new Map<string, Partial<TemplateElement>>();
for (const el of selected) {
switch (mode) {
case 'left':
patches.set(el.id, { x: round1(minX) });
break;
case 'centerH':
patches.set(el.id, { x: round1(minX + (maxX - minX - el.width) / 2) });
break;
case 'right':
patches.set(el.id, { x: round1(maxX - el.width) });
break;
case 'top':
patches.set(el.id, { y: round1(minY) });
break;
case 'centerV':
patches.set(el.id, { y: round1(minY + (maxY - minY - el.height) / 2) });
break;
case 'bottom':
patches.set(el.id, { y: round1(maxY - el.height) });
break;
}
}
return patchElements(elements, patches);
}
/** 将选中元素作为整体,相对画布区域平移对齐 */
export function alignSelectionToCanvas(
elements: TemplateElement[],
selectedIds: string[],
canvasWidth: number,
canvasHeight: number,
mode: AlignMode
): TemplateElement[] {
const selected = elements.filter((e) => selectedIds.includes(e.id) && !e.locked);
if (selected.length === 0) return elements;
const { minX, minY, maxX, maxY } = getSelectionBounds(selected);
const groupW = maxX - minX;
const groupH = maxY - minY;
let deltaX = 0;
let deltaY = 0;
switch (mode) {
case 'left':
deltaX = -minX;
break;
case 'centerH':
deltaX = (canvasWidth - groupW) / 2 - minX;
break;
case 'right':
deltaX = canvasWidth - maxX;
break;
case 'top':
deltaY = -minY;
break;
case 'centerV':
deltaY = (canvasHeight - groupH) / 2 - minY;
break;
case 'bottom':
deltaY = canvasHeight - maxY;
break;
}
const patches = new Map<string, Partial<TemplateElement>>();
for (const el of selected) {
patches.set(el.id, {
x: round1(el.x + deltaX),
y: round1(el.y + deltaY),
});
}
return patchElements(elements, patches);
}
/** 在选区跨度内水平/垂直等距平铺(至少 2 个元素,保持首尾贴齐选区) */
export function tileSelectedEvenly(
elements: TemplateElement[],
selectedIds: string[],
axis: 'horizontal' | 'vertical'
): TemplateElement[] {
const selected = elements
.filter((e) => selectedIds.includes(e.id) && !e.locked)
.sort((a, b) => (axis === 'horizontal' ? a.x - b.x : a.y - b.y));
if (selected.length < 2) return elements;
const patches = new Map<string, Partial<TemplateElement>>();
const first = selected[0];
const last = selected[selected.length - 1];
if (axis === 'horizontal') {
const spanStart = first.x;
const spanEnd = last.x + last.width;
const totalWidth = selected.reduce((sum, el) => sum + el.width, 0);
const gap = (spanEnd - spanStart - totalWidth) / (selected.length - 1);
let cursor = spanStart;
for (const el of selected) {
patches.set(el.id, { x: round1(cursor) });
cursor += el.width + gap;
}
} else {
const spanStart = first.y;
const spanEnd = last.y + last.height;
const totalHeight = selected.reduce((sum, el) => sum + el.height, 0);
const gap = (spanEnd - spanStart - totalHeight) / (selected.length - 1);
let cursor = spanStart;
for (const el of selected) {
patches.set(el.id, { y: round1(cursor) });
cursor += el.height + gap;
}
}
return patchElements(elements, patches);
}

221
src/elementBox.ts Normal file
View File

@@ -0,0 +1,221 @@
import { TemplateElement, ElementSide } from './types';
import { resolveElementBackgroundFill } from './utils';
export interface CornerRadiiPx {
tl: number;
tr: number;
br: number;
bl: number;
}
export interface BorderSides {
top: boolean;
right: boolean;
bottom: boolean;
left: boolean;
}
export function resolveCornerRadiiPx(
elem: TemplateElement,
scale: number,
w: number,
h: number
): CornerRadiiPx {
const maxR = Math.min(w, h) / 2;
const baseMm = elem.borderRadius ?? 0;
const radiusFor = (specificMm?: number): number => {
const mm = specificMm !== undefined && specificMm !== null ? specificMm : baseMm;
if (mm <= 0) return 0;
return Math.min(maxR, mm * scale);
};
return {
tl: radiusFor(elem.borderRadiusTL),
tr: radiusFor(elem.borderRadiusTR),
br: radiusFor(elem.borderRadiusBR),
bl: radiusFor(elem.borderRadiusBL),
};
}
export function resolveBorderSides(elem: TemplateElement): BorderSides {
const width = elem.borderWidth ?? 0;
if (width <= 0) {
return { top: false, right: false, bottom: false, left: false };
}
const sides = elem.borderSides ?? {};
return {
top: sides.top !== false,
right: sides.right !== false,
bottom: sides.bottom !== false,
left: sides.left !== false,
};
}
/** 构建顺时针闭合圆角矩形路径 */
export function traceRoundedRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: CornerRadiiPx
) {
const { tl, tr, br, bl } = r;
ctx.beginPath();
ctx.moveTo(x + tl, y);
ctx.lineTo(x + w - tr, y);
if (tr > 0) ctx.arcTo(x + w, y, x + w, y + tr, tr);
else ctx.lineTo(x + w, y);
ctx.lineTo(x + w, y + h - br);
if (br > 0) ctx.arcTo(x + w, y + h, x + w - br, y + h, br);
else ctx.lineTo(x + w, y + h);
ctx.lineTo(x + bl, y + h);
if (bl > 0) ctx.arcTo(x, y + h, x, y + h - bl, bl);
else ctx.lineTo(x, y + h);
ctx.lineTo(x, y + tl);
if (tl > 0) ctx.arcTo(x, y, x + tl, y, tl);
else ctx.lineTo(x, y);
ctx.closePath();
}
export function drawElementFill(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
elem: TemplateElement,
radii: CornerRadiiPx
) {
const fill = resolveElementBackgroundFill(elem.backgroundColor, elem.backgroundOpacity);
if (!fill) return;
traceRoundedRect(ctx, x, y, w, h, radii);
ctx.fillStyle = fill;
ctx.fill();
}
export function drawElementBorders(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
radii: CornerRadiiPx,
sides: BorderSides,
elem: TemplateElement,
scale: number
) {
const widthMm = elem.borderWidth ?? 0;
if (widthMm <= 0) return;
const color = elem.borderColor || '#111827';
const lw = Math.max(1, widthMm * scale);
const { tl, tr, br, bl } = radii;
const inset = lw / 2;
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'butt';
ctx.lineJoin = 'round';
if (sides.top) {
ctx.beginPath();
if (sides.left && tl > 0) {
ctx.arc(x + tl, y + tl, Math.max(0, tl - inset), Math.PI, 1.5 * Math.PI);
} else {
ctx.moveTo(x, y + inset);
}
if (sides.right && tr > 0) {
ctx.lineTo(x + w - tr, y + inset);
ctx.arc(x + w - tr, y + tr, Math.max(0, tr - inset), 1.5 * Math.PI, 2 * Math.PI);
} else {
ctx.lineTo(x + w, y + inset);
}
ctx.stroke();
}
if (sides.right) {
ctx.beginPath();
const topStart = sides.top && tr > 0;
if (topStart) {
ctx.moveTo(x + w - inset, y + tr);
} else {
ctx.moveTo(x + w - inset, y);
}
if (sides.bottom && br > 0) {
ctx.lineTo(x + w - inset, y + h - br);
ctx.arc(x + w - br, y + h - br, Math.max(0, br - inset), 0, 0.5 * Math.PI);
} else {
ctx.lineTo(x + w - inset, y + h);
}
ctx.stroke();
}
if (sides.bottom) {
ctx.beginPath();
if (sides.right && br > 0) {
ctx.moveTo(x + w - inset, y + h - br);
ctx.arc(x + w - br, y + h - br, Math.max(0, br - inset), 0, 0.5 * Math.PI);
} else {
ctx.moveTo(x + w - inset, y + h - inset);
}
if (sides.left && bl > 0) {
ctx.lineTo(x + bl, y + h - inset);
ctx.arc(x + bl, y + h - bl, Math.max(0, bl - inset), 0.5 * Math.PI, Math.PI);
} else {
ctx.lineTo(x, y + h - inset);
}
ctx.stroke();
}
if (sides.left) {
ctx.beginPath();
const bottomStart = sides.bottom && bl > 0;
if (bottomStart) {
ctx.moveTo(x + inset, y + h - bl);
} else {
ctx.moveTo(x + inset, y + h);
}
if (sides.top && tl > 0) {
ctx.lineTo(x + inset, y + tl);
ctx.arc(x + tl, y + tl, Math.max(0, tl - inset), Math.PI, 1.5 * Math.PI);
} else {
ctx.lineTo(x + inset, y);
}
ctx.stroke();
}
}
export function drawElementChrome(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
elem: TemplateElement,
scale: number
) {
const radii = resolveCornerRadiiPx(elem, scale, w, h);
const sides = resolveBorderSides(elem);
drawElementFill(ctx, x, y, w, h, elem, radii);
drawElementBorders(ctx, x, y, w, h, radii, sides, elem, scale);
return { radii, sides };
}
export function toggleSide(
current: Partial<Record<ElementSide, boolean>> | undefined,
side: ElementSide
): Partial<Record<ElementSide, boolean>> {
const next = { ...current };
const enabled = current?.[side] !== false;
next[side] = !enabled;
return next;
}
export function isSideEnabled(
sides: Partial<Record<ElementSide, boolean>> | undefined,
side: ElementSide
): boolean {
return sides?.[side] !== false;
}

23
src/hooks/useIsMobile.ts Normal file
View File

@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mql = window.matchMedia(query);
const onChange = () => setMatches(mql.matches);
onChange();
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, [query]);
return matches;
}
/** 与 Tailwind `md` 断点一致:&lt; 768px 视为移动端 */
export function useIsMobile(): boolean {
return useMediaQuery('(max-width: 767px)');
}

1169
src/index.css Normal file

File diff suppressed because it is too large Load Diff

446
src/labelRenderer.ts Normal file
View File

@@ -0,0 +1,446 @@
import JsBarcode from 'jsbarcode';
import QRCode from 'qrcode';
import { TemplateElement, RecordRow } from './types';
import {
resolveElementValue,
isGraphicContentEmpty,
shouldRenderElement,
ContentResolveMode,
} from './utils';
import {
resolveCornerRadiiPx,
resolveBorderSides,
traceRoundedRect,
drawElementFill,
drawElementBorders,
drawElementChrome,
} from './elementBox';
const GENERATOR_TRANSPARENT_BG = '#00000000';
function measureLineWidth(ctx: CanvasRenderingContext2D, line: string): number {
return ctx.measureText(line).width;
}
function getTextLineSpan(
textX: number,
lineWidth: number,
textAlign: CanvasTextAlign
): { left: number; right: number } {
if (textAlign === 'center') {
return { left: textX - lineWidth / 2, right: textX + lineWidth / 2 };
}
if (textAlign === 'right') {
return { left: textX - lineWidth, right: textX };
}
return { left: textX, right: textX + lineWidth };
}
function drawTextLineDecorations(
ctx: CanvasRenderingContext2D,
elem: TemplateElement,
textX: number,
lineY: number,
line: string,
fontSizePx: number,
textAlign: CanvasTextAlign
) {
if (!elem.textUnderline && !elem.textLineThrough) return;
const lineWidth = measureLineWidth(ctx, line);
const { left, right } = getTextLineSpan(textX, lineWidth, textAlign);
const color = elem.textColor || '#111827';
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = Math.max(1, fontSizePx * 0.06);
ctx.beginPath();
if (elem.textUnderline) {
const y = lineY + fontSizePx * 0.35;
ctx.moveTo(left, y);
ctx.lineTo(right, y);
}
if (elem.textLineThrough) {
const y = lineY;
ctx.moveTo(left, y);
ctx.lineTo(right, y);
}
ctx.stroke();
ctx.restore();
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Image load failed'));
img.src = src;
});
}
function drawImageWithFit(
ctx: CanvasRenderingContext2D,
img: HTMLImageElement,
x: number,
y: number,
w: number,
h: number,
fit: 'contain' | 'cover' | 'fill' = 'contain'
) {
if (fit === 'fill') {
ctx.drawImage(img, x, y, w, h);
return;
}
const imgRatio = img.width / img.height;
const boxRatio = w / h;
if (fit === 'contain') {
let dw = w;
let dh = h;
let dx = x;
let dy = y;
if (imgRatio > boxRatio) {
dh = w / imgRatio;
dy = y + (h - dh) / 2;
} else {
dw = h * imgRatio;
dx = x + (w - dw) / 2;
}
ctx.drawImage(img, dx, dy, dw, dh);
return;
}
let sx = 0;
let sy = 0;
let sw = img.width;
let sh = img.height;
if (imgRatio > boxRatio) {
sw = img.height * boxRatio;
sx = (img.width - sw) / 2;
} else {
sh = img.width / boxRatio;
sy = (img.height - sh) / 2;
}
ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h);
}
function getPaddedRect(
x: number,
y: number,
w: number,
h: number,
paddingMm: number,
scale: number
) {
const paddingPx = Math.round(paddingMm * scale);
return {
x: x + paddingPx,
y: y + paddingPx,
w: Math.max(1, w - 2 * paddingPx),
h: Math.max(1, h - 2 * paddingPx),
};
}
function drawImagePlaceholder(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
message: string
) {
ctx.fillStyle = '#2a2a2a';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = '#555';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
ctx.setLineDash([]);
ctx.fillStyle = '#888';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(message, x + w / 2, y + h / 2);
}
export interface RenderLabelOptions {
widthMm: number;
heightMm: number;
elements: TemplateElement[];
rowData: RecordRow | null;
rowIndex: number;
showBorder?: boolean;
transparentBackground?: boolean;
variableDefaults?: Record<string, string> | null;
mode?: ContentResolveMode;
/** 渲染倍率,默认 16PDF 导出可用较低倍率以减轻内存与耗时 */
scale?: number;
/** 输出格式,默认 png */
imageFormat?: 'png' | 'jpeg';
jpegQuality?: number;
}
/** 离屏渲染标签为 PNG data URL */
export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise<string> {
const {
widthMm,
heightMm,
elements,
rowData,
rowIndex,
showBorder = false,
transparentBackground = false,
variableDefaults = null,
mode = 'design',
scale: scaleOption = 16,
imageFormat = 'png',
jpegQuality = 0.92,
} = options;
const scale = scaleOption;
const canvas = document.createElement('canvas');
canvas.width = Math.max(50, Math.round(widthMm * scale));
canvas.height = Math.max(50, Math.round(heightMm * scale));
const ctx = canvas.getContext('2d');
if (!ctx) return '';
if (!transparentBackground) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
if (showBorder) {
ctx.strokeStyle = '#cccccc';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.strokeRect(0, 0, canvas.width, canvas.height);
ctx.setLineDash([]);
}
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, canvas.width, canvas.height);
ctx.clip();
for (const elem of elements) {
const ex = Math.round(elem.x * scale);
const ey = Math.round(elem.y * scale);
const ew = Math.round(elem.width * scale);
const eh = Math.round(elem.height * scale);
if (!shouldRenderElement(elem, rowData, rowIndex, variableDefaults, mode)) {
continue;
}
const value = resolveElementValue(elem, rowData, rowIndex, variableDefaults, mode);
if (isGraphicContentEmpty(elem, value, mode)) {
continue;
}
if (elem.type === 'text') {
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
const sides = resolveBorderSides(elem);
ctx.save();
traceRoundedRect(ctx, ex, ey, ew, eh, radii);
ctx.clip();
drawElementFill(ctx, ex, ey, ew, eh, elem, radii);
const paddingMm = elem.padding || 0;
const paddingPx = Math.round(paddingMm * scale);
const writableW = Math.max(1, ew - 2 * paddingPx);
const writableH = Math.max(1, eh - 2 * paddingPx);
const fontStyle = elem.fontStyle === 'italic' ? 'italic ' : '';
const fontWeight = elem.fontWeight === 'bold' ? 'bold ' : '';
const fontSizePx = Math.max(6, Math.max(1, Math.round(elem.fontSize * 0.352777 * scale)));
const fontFamily =
elem.fontFamily === 'mono'
? 'JetBrains Mono, SFMono-Regular, Consolas, Courier New, monospace'
: elem.fontFamily === 'serif'
? 'Georgia, Times New Roman, serif'
: 'Inter, system-ui, -apple-system, Arial, sans-serif';
ctx.font = `${fontStyle}${fontWeight}${fontSizePx}px ${fontFamily}`.trim();
ctx.fillStyle = elem.textColor || '#111827';
const letterSpacingPx = Math.max(0, (elem.letterSpacing ?? 0) * scale);
if (letterSpacingPx > 0 && 'letterSpacing' in ctx) {
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing =
`${letterSpacingPx}px`;
}
const wordWrap = elem.wordWrap !== false;
const lines: string[] = [];
const paragraphs = String(value).split(/\r?\n/);
for (const paragraph of paragraphs) {
if (!wordWrap) {
lines.push(paragraph);
continue;
}
let currentLine = '';
for (let i = 0; i < paragraph.length; i++) {
const char = paragraph[i];
const testLine = currentLine + char;
const metrics = ctx.measureText(testLine);
if (metrics.width > writableW - 4 && i > 0 && currentLine.length > 0) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine = testLine;
}
}
if (currentLine || lines.length === 0) {
lines.push(currentLine);
}
}
const lineHeight = fontSizePx * 1.25;
const totalTextHeight = lines.length * lineHeight;
ctx.textBaseline = 'middle';
let textX = ex + paddingPx;
let textAlign: CanvasTextAlign = 'left';
if (elem.textAlign === 'center') {
textX = ex + paddingPx + writableW / 2;
textAlign = 'center';
} else if (elem.textAlign === 'right') {
textX = ex + paddingPx + (writableW - 2);
textAlign = 'right';
} else {
textX = ex + paddingPx + 2;
}
ctx.textAlign = textAlign;
const vAlign = elem.verticalAlign ?? 'middle';
let startY: number;
if (vAlign === 'top') {
startY = ey + paddingPx + lineHeight / 2;
} else if (vAlign === 'bottom') {
startY = ey + eh - paddingPx - totalTextHeight + lineHeight / 2;
} else {
startY = ey + paddingPx + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2;
}
const textScaleX = Math.max(0.1, Math.min(5, elem.textScaleX ?? 1));
const textScaleY = Math.max(0.1, Math.min(5, elem.textScaleY ?? 1));
const transformOriginX = ex + paddingPx + writableW / 2;
const transformOriginY = ey + paddingPx + writableH / 2;
ctx.save();
if (textScaleX !== 1 || textScaleY !== 1) {
ctx.translate(transformOriginX, transformOriginY);
ctx.scale(textScaleX, textScaleY);
ctx.translate(-transformOriginX, -transformOriginY);
}
for (let i = 0; i < lines.length; i++) {
const lineY = startY + i * lineHeight;
if (lineY - lineHeight / 2 <= ey + eh - paddingPx) {
const line = lines[i];
ctx.fillText(line, textX, lineY);
drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign);
}
}
ctx.restore();
if (letterSpacingPx > 0 && 'letterSpacing' in ctx) {
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = '0px';
}
ctx.restore();
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
} else if (elem.type === 'barcode') {
try {
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
const inner = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale);
const tempCanvas = document.createElement('canvas');
const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : '';
if (!barcodeValue) continue;
JsBarcode(tempCanvas, barcodeValue, {
format: elem.barcodeFormat || 'CODE128',
width: 2,
height: 40,
displayValue: elem.showText ?? false,
fontSize: 14,
margin: 2,
background: GENERATOR_TRANSPARENT_BG,
});
ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
} catch (error) {
console.warn('Barcode drawing failed', error);
if (mode === 'design') {
const inner = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale);
ctx.fillStyle = '#f3f4f6';
ctx.fillRect(inner.x, inner.y, inner.w, inner.h);
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 2;
ctx.strokeRect(inner.x, inner.y, inner.w, inner.h);
ctx.fillStyle = '#b91c1c';
ctx.font = '10px Courier New';
ctx.textAlign = 'center';
ctx.fillText('ERR BARCODE', inner.x + inner.w / 2, inner.y + inner.h / 2);
}
}
} else if (elem.type === 'qrcode') {
try {
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
const inner = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale);
const qrValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE QR' : '';
if (!qrValue) continue;
const tempCanvas = document.createElement('canvas');
await QRCode.toCanvas(tempCanvas, qrValue, {
width: Math.max(64, inner.w),
margin: 0,
color: {
dark: '#000000',
light: GENERATOR_TRANSPARENT_BG,
},
});
ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
} catch (error) {
console.warn('QR code drawing failed', error);
if (mode === 'design') {
const inner = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale);
ctx.fillStyle = '#f3f4f6';
ctx.fillRect(inner.x, inner.y, inner.w, inner.h);
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 1;
ctx.strokeRect(inner.x, inner.y, inner.w, inner.h);
}
}
} else if (elem.type === 'image') {
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
const inner = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale);
const src = value ? String(value).trim() : '';
if (!src) {
if (mode === 'design') {
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片');
}
} else {
try {
const img = await loadImage(src);
drawImageWithFit(ctx, img, inner.x, inner.y, inner.w, inner.h, elem.imageFit ?? 'contain');
} catch {
if (mode === 'design') {
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '加载失败');
}
}
}
}
}
ctx.restore();
return imageFormat === 'jpeg'
? canvas.toDataURL('image/jpeg', jpegQuality)
: canvas.toDataURL('image/png');
}

13
src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import { AppDialogProvider } from './components/AppDialog.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AppDialogProvider>
<App />
</AppDialogProvider>
</StrictMode>,
);

166
src/pdfExport.ts Normal file
View File

@@ -0,0 +1,166 @@
import { jsPDF } from 'jspdf';
import type { PaperConfig, LabelTemplate, CutLineConfig } from './types';
import type { PrintableLabel } from './utils';
export interface PdfExportProgress {
done: number;
total: number;
page: number;
totalPages: number;
phase: 'render' | 'save';
}
export interface AsyncExportLabelsPdfOptions {
paper: PaperConfig;
template: LabelTemplate;
printablePages: (PrintableLabel | null)[][];
gridCols: number;
cutLine: CutLineConfig;
filename?: string;
imageFormat: 'PNG' | 'JPEG';
renderLabel: (label: PrintableLabel) => Promise<string>;
onProgress?: (progress: PdfExportProgress) => void;
}
const yieldToMain = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
const PAGE_RENDER_CONCURRENCY = 3;
function parseColorToRgb(color: string): [number, number, number] {
const c = color.trim();
if (c.startsWith('#')) {
const hex = c.slice(1);
if (hex.length === 3) {
return [
parseInt(hex[0] + hex[0], 16),
parseInt(hex[1] + hex[1], 16),
parseInt(hex[2] + hex[2], 16),
];
}
if (hex.length >= 6) {
return [
parseInt(hex.slice(0, 2), 16),
parseInt(hex.slice(2, 4), 16),
parseInt(hex.slice(4, 6), 16),
];
}
}
return [204, 204, 204];
}
function drawCutLineRect(
pdf: jsPDF,
x: number,
y: number,
w: number,
h: number,
cutLine: CutLineConfig
) {
const [r, g, b] = parseColorToRgb(cutLine.color);
pdf.setDrawColor(r, g, b);
pdf.setLineWidth(Math.max(0.02, cutLine.width));
if (cutLine.style === 'dashed') {
const dash = Math.max(0.5, cutLine.width * 4);
pdf.setLineDashPattern([dash, dash], 0);
} else {
pdf.setLineDashPattern([], 0);
}
pdf.rect(x, y, w, h, 'S');
pdf.setLineDashPattern([], 0);
}
/** 按页流式渲染并写入 PDF避免大批量时内存暴涨与长时间阻塞 */
export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions): Promise<void> {
const {
paper,
template,
printablePages,
gridCols,
cutLine,
filename = 'labels.pdf',
imageFormat,
renderLabel,
onProgress,
} = options;
const paperW = paper.orientation === 'portrait' ? paper.width : paper.height;
const paperH = paper.orientation === 'portrait' ? paper.height : paper.width;
const orientation = paperW > paperH ? 'landscape' : 'portrait';
const labelW = template.width;
const labelH = template.height;
const totalPages = printablePages.length;
const totalLabels = printablePages.reduce(
(sum, page) => sum + page.filter((item) => item !== null).length,
0
);
const pdf = new jsPDF({
orientation,
unit: 'mm',
format: [paperW, paperH],
compress: true,
});
let done = 0;
let progressTick = 0;
const report = (page: number, phase: PdfExportProgress['phase']) => {
onProgress?.({ done, total: totalLabels, page, totalPages, phase });
};
for (let pIdx = 0; pIdx < printablePages.length; pIdx++) {
if (pIdx > 0) {
pdf.addPage([paperW, paperH], orientation);
}
const pageRows = printablePages[pIdx];
report(pIdx + 1, 'render');
const pageJobs: { cellIdx: number; label: PrintableLabel }[] = [];
for (let cellIdx = 0; cellIdx < pageRows.length; cellIdx++) {
const item = pageRows[cellIdx];
if (!item) continue;
pageJobs.push({ cellIdx, label: item });
}
for (let i = 0; i < pageJobs.length; i += PAGE_RENDER_CONCURRENCY) {
const batch = pageJobs.slice(i, i + PAGE_RENDER_CONCURRENCY);
const rendered = await Promise.all(
batch.map(async (job) => ({
...job,
dataUrl: await renderLabel(job.label),
}))
);
for (const { cellIdx, dataUrl } of rendered) {
const col = cellIdx % gridCols;
const row = Math.floor(cellIdx / gridCols);
const x = paper.marginLeft + col * (labelW + paper.columnGap);
const y = paper.marginTop + row * (labelH + paper.rowGap);
pdf.addImage(dataUrl, imageFormat, x, y, labelW, labelH, undefined, 'FAST');
if (cutLine.enabled) {
drawCutLineRect(pdf, x, y, labelW, labelH, cutLine);
}
done++;
}
progressTick++;
if (progressTick >= 2) {
progressTick = 0;
report(pIdx + 1, 'render');
await yieldToMain();
}
}
report(pIdx + 1, 'render');
await yieldToMain();
}
report(totalPages, 'save');
await yieldToMain();
pdf.save(filename);
}

168
src/types.ts Normal file
View File

@@ -0,0 +1,168 @@
export type TextFormat = 'text' | 'number' | 'percent' | 'currency';
export type ElementVisibilityMode = 'always' | 'conditional';
export type VisibilityOperator = 'empty' | 'not_empty' | 'gt' | 'lt' | 'eq' | 'neq';
export interface VisibilityRule {
variable: string;
operator: VisibilityOperator;
/** gt / lt / eq / neq 时的比较目标值 */
value?: string;
}
export const VISIBILITY_OPERATOR_LABELS: Record<VisibilityOperator, string> = {
empty: '为空',
not_empty: '不为空',
gt: '大于',
lt: '小于',
eq: '等于',
neq: '不等于',
};
export type ElementSide = 'top' | 'right' | 'bottom' | 'left';
export type ElementCorner = 'tl' | 'tr' | 'br' | 'bl';
export interface TemplateElement {
id: string;
type: 'text' | 'barcode' | 'qrcode' | 'image';
name: string;
x: number; // in mm
y: number; // in mm
width: number; // in mm
height: number; // in mm
content: string; // static text or variable binding, e.g. "{货位}" or "格式化文本 {货位}"
/** 显示控制conditional 时按 visibilityRules 判断 */
visibilityMode?: ElementVisibilityMode;
/** 显示控制条件;全部满足时才渲染(设计预览按变量默认值判断) */
visibilityRules?: VisibilityRule[];
/** @deprecated 使用 visibilityRules */
visibilityVariables?: string[];
/** @deprecated 使用 visibilityMode=conditional */
hideWhenNoData?: boolean;
/** @deprecated 使用 visibilityRules */
visibilityVariable?: string;
fontSize: number; // in pt
textAlign: 'left' | 'center' | 'right';
/** 文本垂直对齐,仅 type=text */
verticalAlign?: 'top' | 'middle' | 'bottom';
/** 是否自动换行,仅 type=text默认 true */
wordWrap?: boolean;
fontWeight: 'normal' | 'bold';
/** 斜体,仅 type=text */
fontStyle?: 'normal' | 'italic';
/** 下划线,仅 type=text */
textUnderline?: boolean;
/** 删除线,仅 type=text */
textLineThrough?: boolean;
/** 水平拉伸倍数1 为默认,仅 type=text */
textScaleX?: number;
/** 垂直拉伸倍数1 为默认,仅 type=text */
textScaleY?: number;
/** 字间距 (mm),仅 type=text */
letterSpacing?: number;
barcodeFormat: 'CODE128' | 'CODE39' | 'EAN13' | 'ITF';
showText: boolean; // For barcode human-readable text below
fontFamily: 'sans' | 'serif' | 'mono';
backgroundColor?: string;
/** 背景不透明度 0100100 为完全不透明0 或 backgroundColor 为 transparent 时不绘制背景 */
backgroundOpacity?: number;
textColor?: string;
/** 内边距 (mm),所有元素类型均有效 */
padding?: number;
locked?: boolean;
/** 图片缩放方式,仅 type=image 时有效 */
imageFit?: 'contain' | 'cover' | 'fill';
/** 文本显示格式,仅 type=text 时有效 */
textFormat?: TextFormat;
/** 数值/百分比/货币的小数位数,默认 2 */
decimalPlaces?: number;
/** 边框宽度 (mm)0 为无边框 */
borderWidth?: number;
borderColor?: string;
/** 指定哪些边显示边框,默认四边 */
borderSides?: Partial<Record<ElementSide, boolean>>;
/** 圆角半径 (mm),可被各角覆盖 */
borderRadius?: number;
/** 指定哪些角应用圆角,默认四角 */
borderRadiusCorners?: Partial<Record<ElementCorner, boolean>>;
borderRadiusTL?: number;
borderRadiusTR?: number;
borderRadiusBR?: number;
borderRadiusBL?: number;
}
export interface DataSourceMeta {
fileName: string;
sheets: string[];
currentSheet: string;
columns: string[];
rowCount: number;
}
export type CutLineStyle = 'dashed' | 'solid';
export interface CutLineConfig {
enabled: boolean;
style: CutLineStyle;
/** 线宽 (mm) */
width: number;
color: string;
}
export type ExportRangeMode = 'all' | 'odd' | 'even' | 'custom';
export interface ExportRangeConfig {
mode: ExportRangeMode;
/** 自定义范围1-based 序号,如 "1,3,5-10" */
custom?: string;
}
export interface LabelTemplate {
id?: string;
name?: string;
width: number; // in mm
height: number; // in mm
elements: TemplateElement[];
/** 模板声明的变量名(统一管理;内容/显示控制从此选取) */
variableList?: string[];
/** 设计预览用:变量名 → 默认展示值 */
variableDefaults?: Record<string, string>;
/** 整页排版配置(每模板独立) */
paper?: PaperConfig;
/** PDF 裁切参考线配置 */
cutLine?: CutLineConfig;
/** @deprecated 由 cutLine.enabled 替代 */
showPdfCutLines?: boolean;
/** @deprecated 数据范围仅存在于导出会话,不再写入模板 */
exportRange?: ExportRangeConfig;
/** @deprecated 仅用于迁移旧数据 */
dataSource?: ImportData;
/** @deprecated 模板与数据源已解耦,不再写入 */
dataSourceMeta?: DataSourceMeta;
}
export type PaperType = 'A4' | 'A5' | 'A6' | 'custom';
export interface PaperConfig {
type: PaperType;
width: number; // in mm
height: number; // in mm
orientation: 'portrait' | 'landscape';
marginTop: number; // in mm
marginRight: number; // in mm
marginBottom: number; // in mm
marginLeft: number; // in mm
columnGap: number; // in mm (horizontal spacing between labels)
rowGap: number; // in mm (vertical spacing between labels)
flow: 'top-bottom-left-right' | 'left-right-top-bottom'; // how cells flow
}
export interface RecordRow {
[key: string]: string;
}
export interface ImportData {
fileName: string;
sheets: string[];
currentSheet: string;
columns: string[];
rows: RecordRow[];
}

691
src/utils.ts Normal file
View File

@@ -0,0 +1,691 @@
import {
LabelTemplate,
PaperConfig,
RecordRow,
ImportData,
DataSourceMeta,
TemplateElement,
TextFormat,
ExportRangeConfig,
CutLineConfig,
VisibilityRule,
VisibilityOperator,
VISIBILITY_OPERATOR_LABELS,
} from './types';
const IMPORT_DATA_STORAGE_PREFIX = 'label_import_data_v2_';
const GLOBAL_IMPORT_DATA_KEY = 'label_import_data_global_v2';
const VARIABLE_MAPPING_KEY = 'label_var_column_mapping_v2';
/** 变量映射:使用模板中配置的默认值(非数据列) */
export const VARIABLE_MAPPING_USE_DEFAULT = '__use_default__';
export function toDataSourceMeta(data: ImportData): DataSourceMeta {
return {
fileName: data.fileName,
sheets: data.sheets,
currentSheet: data.currentSheet,
columns: data.columns,
rowCount: data.rows.length,
};
}
export function saveImportDataForTemplate(templateId: string, data: ImportData): void {
try {
localStorage.setItem(IMPORT_DATA_STORAGE_PREFIX + templateId, JSON.stringify(data));
} catch (e) {
console.warn('Failed to save import data', e);
}
}
export function loadImportDataForTemplate(templateId: string): ImportData | null {
try {
const raw = localStorage.getItem(IMPORT_DATA_STORAGE_PREFIX + templateId);
if (raw) return JSON.parse(raw) as ImportData;
} catch (e) {}
return null;
}
export function clearImportDataForTemplate(templateId: string): void {
try {
localStorage.removeItem(IMPORT_DATA_STORAGE_PREFIX + templateId);
} catch (e) {}
}
export function saveGlobalImportData(data: ImportData): void {
try {
localStorage.setItem(GLOBAL_IMPORT_DATA_KEY, JSON.stringify(data));
} catch (e) {
console.warn('Failed to save global import data', e);
}
}
export function loadGlobalImportData(): ImportData | null {
try {
const raw = localStorage.getItem(GLOBAL_IMPORT_DATA_KEY);
if (raw) return JSON.parse(raw) as ImportData;
} catch (e) {}
return null;
}
export function clearGlobalImportData(): void {
try {
localStorage.removeItem(GLOBAL_IMPORT_DATA_KEY);
} catch (e) {}
}
export function saveVariableColumnMapping(mapping: Record<string, string>): void {
try {
localStorage.setItem(VARIABLE_MAPPING_KEY, JSON.stringify(mapping));
} catch (e) {}
}
export function loadVariableColumnMapping(): Record<string, string> {
try {
const raw = localStorage.getItem(VARIABLE_MAPPING_KEY);
if (raw) return JSON.parse(raw) as Record<string, string>;
} catch (e) {}
return {};
}
/** 变量名与数据列同名时自动建立映射 */
export function buildAutoVariableMapping(
variables: string[],
columns: string[]
): Record<string, string> {
const mapping: Record<string, string> = {};
for (const v of variables) {
if (columns.includes(v)) mapping[v] = v;
}
return mapping;
}
/** 变量是否已完成映射配置(含「使用默认值」) */
export function isVariableMappingConfigured(
mapping: Record<string, string>,
varName: string,
columns: string[]
): boolean {
const mapped = mapping[varName];
if (!mapped) return false;
if (mapped === VARIABLE_MAPPING_USE_DEFAULT) return true;
return columns.includes(mapped);
}
/** 按映射将数据行中的列值填入模板变量键;「使用默认值」时写入 variableDefaults */
export function applyRowVariableMapping(
row: RecordRow,
mapping: Record<string, string>,
variables: string[],
variableDefaults?: Record<string, string> | null
): RecordRow {
const result = { ...row };
for (const varName of variables) {
const mapped = mapping[varName];
if (mapped === VARIABLE_MAPPING_USE_DEFAULT) {
const def = variableDefaults?.[varName];
if (def !== undefined && String(def).trim() !== '') {
result[varName] = String(def).trim();
} else {
delete result[varName];
}
continue;
}
const col = mapped ?? varName;
if (row[col] !== undefined) {
result[varName] = row[col];
}
}
return result;
}
export const DEFAULT_EXPORT_RANGE: ExportRangeConfig = { mode: 'all' };
export const exportRangeEquals = (a: ExportRangeConfig, b: ExportRangeConfig) =>
a.mode === b.mode && (a.custom ?? '') === (b.custom ?? '');
export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = {
enabled: true,
style: 'dashed',
width: 0.1,
color: '#cccccc',
};
export interface PrintableLabel {
row: RecordRow;
/** 原始数据行索引0-based用于变量解析 */
sourceIndex: number;
}
/** 按导出范围筛选 0-based 行索引(保持升序) */
export function getExportRangeIndices(total: number, range: ExportRangeConfig): number[] {
if (total <= 0) return [];
switch (range.mode) {
case 'odd':
return Array.from({ length: total }, (_, i) => i).filter((i) => (i + 1) % 2 === 1);
case 'even':
return Array.from({ length: total }, (_, i) => i).filter((i) => (i + 1) % 2 === 0);
case 'custom':
return parseCustomExportRange(range.custom ?? '', total);
case 'all':
default:
return Array.from({ length: total }, (_, i) => i);
}
}
/** 解析自定义范围字符串1-based 序号) */
export function parseCustomExportRange(input: string, total: number): number[] {
const trimmed = input.trim();
if (!trimmed || total <= 0) return [];
const result = new Set<number>();
const parts = trimmed.split(/[,,、\s]+/).filter(Boolean);
for (const part of parts) {
const rangeMatch = part.match(/^(\d+)\s*-\s*(\d+)$/);
if (rangeMatch) {
const start = Math.max(1, parseInt(rangeMatch[1], 10));
const end = Math.min(total, parseInt(rangeMatch[2], 10));
if (!Number.isNaN(start) && !Number.isNaN(end)) {
for (let n = start; n <= end; n++) result.add(n - 1);
}
} else {
const n = parseInt(part, 10);
if (!Number.isNaN(n) && n >= 1 && n <= total) result.add(n - 1);
}
}
return Array.from(result).sort((a, b) => a - b);
}
export function buildPrintablePages(
rows: RecordRow[],
paper: PaperConfig,
labelSize: { width: number; height: number },
exportRange: ExportRangeConfig
) {
const gridInfo = calculateGrid(paper, labelSize);
const labelsPerPage = gridInfo.cols * gridInfo.rows;
const indices = getExportRangeIndices(rows.length, exportRange);
const selected: PrintableLabel[] = indices.map((i) => ({ row: rows[i], sourceIndex: i }));
const totalPages = Math.ceil(selected.length / labelsPerPage) || 1;
const pages: (PrintableLabel | null)[][] = [];
for (let p = 0; p < totalPages; p++) {
const pageCells: (PrintableLabel | null)[] = [];
const startIdx = p * labelsPerPage;
for (let cell = 0; cell < labelsPerPage; cell++) {
pageCells.push(selected[startIdx + cell] ?? null);
}
if (paper.flow === 'top-bottom-left-right') {
const reordered: (PrintableLabel | null)[] = Array(labelsPerPage).fill(null);
let ptr = 0;
for (let c = 0; c < gridInfo.cols; c++) {
for (let r = 0; r < gridInfo.rows; r++) {
const cellIndexInGrid = r * gridInfo.cols + c;
if (ptr < pageCells.length) {
reordered[cellIndexInGrid] = pageCells[ptr++];
}
}
}
pages.push(reordered);
} else {
pages.push(pageCells);
}
}
return {
pages,
gridCols: gridInfo.cols,
gridRows: gridInfo.rows,
labelsPerPage,
totalPages,
selectedCount: selected.length,
};
}
export const DEFAULT_PAPER_CONFIG: PaperConfig = {
type: 'A4',
width: 210,
height: 297,
orientation: 'portrait',
marginTop: 10,
marginBottom: 10,
marginLeft: 10,
marginRight: 10,
columnGap: 2,
rowGap: 2,
flow: 'left-right-top-bottom',
};
/** 补全模板默认排版字段,剥离已废弃的数据源字段 */
export function normalizeTemplate(tmpl: LabelTemplate): LabelTemplate {
const { dataSource, dataSourceMeta, showPdfCutLines, exportRange: _exportRange, ...rest } = tmpl;
const cutLine: CutLineConfig = {
...DEFAULT_CUT_LINE_CONFIG,
...rest.cutLine,
enabled: rest.cutLine?.enabled ?? showPdfCutLines !== false,
width:
typeof rest.cutLine?.width === 'number' && rest.cutLine.width > 0
? rest.cutLine.width
: DEFAULT_CUT_LINE_CONFIG.width,
};
return {
...rest,
paper: rest.paper ?? DEFAULT_PAPER_CONFIG,
cutLine,
};
}
/** 从模板对象中剥离数据相关字段,仅保留设计信息 */
export function stripTemplateForStorage(tmpl: LabelTemplate): LabelTemplate {
return normalizeTemplate(tmpl);
}
export const MM_TO_PX = 3.78;
export function mmToPx(mm: number, zoom: number = 1): number {
return imgRound(mm * MM_TO_PX * zoom);
}
function imgRound(val: number): number {
return Math.round(val * 100) / 100;
}
/** 从文本中提取 {变量名} 占位符(排除 #INDEX */
export function extractVariablesFromContent(content: string): string[] {
const vars = new Set<string>();
const matches = content.match(/\{([^}]+)\}/g);
if (matches) {
for (const m of matches) {
const name = m.slice(1, -1).trim();
if (name && name !== '#INDEX') vars.add(name);
}
}
return Array.from(vars);
}
/** 将元素背景色与不透明度解析为 canvas 可用的 rgba 字符串;无需绘制时返回 null */
export function resolveElementBackgroundFill(
backgroundColor?: string,
backgroundOpacity?: number
): string | null {
if (!backgroundColor || backgroundColor === 'transparent') return null;
const opacity = backgroundOpacity !== undefined ? backgroundOpacity : 100;
if (opacity <= 0) return null;
const alpha = Math.min(1, Math.max(0, opacity / 100));
const color = backgroundColor.trim();
if (color.startsWith('rgba(')) {
const m = color.match(/rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)/);
if (m) {
const a = Math.min(1, Math.max(0, parseFloat(m[4]) * alpha));
return `rgba(${m[1]},${m[2]},${m[3]},${a})`;
}
return color;
}
if (color.startsWith('rgb(')) {
const m = color.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/);
if (m) return `rgba(${m[1]},${m[2]},${m[3]},${alpha})`;
}
if (color.startsWith('#')) {
let hex = color.slice(1);
if (hex.length === 3) {
hex = hex.split('').map((c) => c + c).join('');
}
if (hex.length === 8) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = (parseInt(hex.slice(6, 8), 16) / 255) * alpha;
return `rgba(${r},${g},${b},${a})`;
}
if (hex.length === 6) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
}
return color;
}
/** 从模板汇总全部变量(声明列表 + 内容 + 显示控制) */
export function extractTemplateVariables(template: {
variableList?: string[];
elements: TemplateElement[];
}): string[] {
const vars = new Set<string>(template.variableList ?? []);
for (const elem of template.elements) {
for (const v of extractVariablesFromContent(elem.content)) {
vars.add(v);
}
for (const rule of resolveVisibilityRules(elem)) {
const name = rule.variable.trim();
if (name) vars.add(name);
}
}
return Array.from(vars).sort((a, b) => a.localeCompare(b, 'zh-CN'));
}
/** 被元素内容或显示条件引用的变量 */
export function getUsedTemplateVariables(template: { elements: TemplateElement[] }): string[] {
const vars = new Set<string>();
for (const elem of template.elements) {
for (const v of extractVariablesFromContent(elem.content)) {
vars.add(v);
}
for (const rule of resolveVisibilityRules(elem)) {
const name = rule.variable.trim();
if (name) vars.add(name);
}
}
return Array.from(vars);
}
export function isTemplateVariableInUse(
template: { elements: TemplateElement[] },
varName: string
): boolean {
return getUsedTemplateVariables(template).includes(varName);
}
/** 从模板声明列表移除未使用的变量(内容及显示条件未引用) */
export function removeUnusedTemplateVariable(
template: LabelTemplate,
varName: string
): LabelTemplate {
if (isTemplateVariableInUse(template, varName)) return template;
const variableList = (template.variableList ?? []).filter((v) => v !== varName);
const defaults = { ...(template.variableDefaults || {}) };
delete defaults[varName];
return {
...template,
variableList: variableList.length > 0 ? variableList : undefined,
variableDefaults: Object.keys(defaults).length > 0 ? defaults : undefined,
};
}
export function isConditionalVisibility(elem: TemplateElement): boolean {
if (elem.visibilityMode === 'conditional') return true;
if (elem.visibilityMode === 'always') return false;
return !!elem.hideWhenNoData;
}
export function visibilityOperatorNeedsValue(operator: VisibilityOperator): boolean {
return operator === 'gt' || operator === 'lt' || operator === 'eq' || operator === 'neq';
}
/** 将显示条件格式化为可读说明 */
export function formatVisibilityRuleDescription(rule: VisibilityRule): string {
const varLabel = `{${rule.variable}}`;
switch (rule.operator) {
case 'empty':
return `${varLabel} 为空`;
case 'not_empty':
return `${varLabel} 不为空`;
case 'gt':
return `${varLabel} 大于 ${rule.value ?? '—'}`;
case 'lt':
return `${varLabel} 小于 ${rule.value ?? '—'}`;
case 'eq':
return `${varLabel} 等于 ${rule.value ?? '—'}`;
case 'neq':
return `${varLabel} 不等于 ${rule.value ?? '—'}`;
default:
return `${varLabel} ${VISIBILITY_OPERATOR_LABELS[rule.operator]}`;
}
}
/** 解析显示控制规则(兼容旧 visibilityVariables 字段) */
export function resolveVisibilityRules(elem: TemplateElement): VisibilityRule[] {
if (elem.visibilityRules !== undefined) {
return elem.visibilityRules
.map((r) => ({
variable: r.variable.trim(),
operator: r.operator,
value: r.value,
}))
.filter((r) => r.variable);
}
const legacyVars = (elem.visibilityVariables ?? []).map((v) => v.trim()).filter(Boolean);
if (legacyVars.length > 0) {
return legacyVars.map((variable) => ({ variable, operator: 'not_empty' as const }));
}
const legacy = elem.visibilityVariable?.trim();
if (legacy) return [{ variable: legacy, operator: 'not_empty' }];
if (elem.hideWhenNoData) {
return extractVariablesFromContent(elem.content).map((variable) => ({
variable,
operator: 'not_empty' as const,
}));
}
return [];
}
function parseComparableNumber(value: string): number | null {
const num = Number(value.replace(/,/g, '').trim());
return Number.isNaN(num) ? null : num;
}
function compareVariableToTarget(
raw: string,
target: string,
operator: 'gt' | 'lt' | 'eq' | 'neq'
): boolean {
const numRaw = parseComparableNumber(raw);
const numTarget = parseComparableNumber(target);
if (numRaw !== null && numTarget !== null) {
if (operator === 'gt') return numRaw > numTarget;
if (operator === 'lt') return numRaw < numTarget;
if (operator === 'eq') return numRaw === numTarget;
return numRaw !== numTarget;
}
if (operator === 'eq') return raw === target;
if (operator === 'neq') return raw !== target;
return false;
}
/** 单条显示条件是否满足 */
export function evaluateVisibilityRule(
rule: VisibilityRule,
row: RecordRow | null,
defaults?: Record<string, string> | null,
mode: ContentResolveMode = 'output'
): boolean {
const raw = getVariableRawValue(rule.variable, row, defaults, mode);
const isEmpty = raw === undefined;
switch (rule.operator) {
case 'empty':
return isEmpty;
case 'not_empty':
return !isEmpty;
case 'gt':
case 'lt':
case 'eq': {
if (isEmpty) return false;
return compareVariableToTarget(raw!, (rule.value ?? '').trim(), rule.operator);
}
case 'neq': {
const target = (rule.value ?? '').trim();
if (isEmpty) return target !== '';
return compareVariableToTarget(raw!, target, 'neq');
}
default:
return true;
}
}
export type ContentResolveMode = 'design' | 'output';
export interface ResolveContentOptions {
mode?: ContentResolveMode;
/** 对单个变量替换值做格式化(如数值/百分比) */
formatSubst?: (raw: string) => string;
}
/** 将 {变量} 替换为行数据或设计默认值 */
export function resolveContent(
content: string,
row: RecordRow | null,
fallbackIndex: number = 0,
defaults?: Record<string, string> | null,
options?: ResolveContentOptions
): string {
if (!content) return '';
const mode = options?.mode ?? 'design';
return content.replace(/\{([^}]+)\}/g, (_, key) => {
if (key === '#INDEX') return String(fallbackIndex + 1);
let val: string | undefined;
if (row && row[key] !== undefined && String(row[key]).trim() !== '') {
val = String(row[key]);
} else if (defaults && defaults[key] !== undefined && String(defaults[key]).trim() !== '') {
val = String(defaults[key]);
}
if (val === undefined) {
return mode === 'design' ? `[${key}]` : '';
}
return options?.formatSubst ? options.formatSubst(val) : val;
});
}
/** 格式化单个数值用于文本显示 */
export function formatDisplayValue(
value: string,
format: Exclude<TextFormat, 'text'>,
decimalPlaces?: number
): string {
const trimmed = value.trim();
if (!trimmed) return '';
const num = Number(trimmed.replace(/,/g, ''));
if (Number.isNaN(num)) return value;
const places = decimalPlaces ?? 2;
const formatted = num.toLocaleString('zh-CN', {
minimumFractionDigits: places,
maximumFractionDigits: places,
});
switch (format) {
case 'number':
return formatted;
case 'percent':
return `${formatted}%`;
case 'currency':
return `¥${formatted}`;
default:
return value;
}
}
/** 读取变量原始值(仅行数据;设计模式可选用默认值) */
export function getVariableRawValue(
varName: string,
row: RecordRow | null,
defaults?: Record<string, string> | null,
mode: ContentResolveMode = 'output'
): string | undefined {
if (row && row[varName] !== undefined && String(row[varName]).trim() !== '') {
return String(row[varName]).trim();
}
if (mode === 'design' && defaults && defaults[varName] !== undefined && String(defaults[varName]).trim() !== '') {
return String(defaults[varName]).trim();
}
return undefined;
}
/** 是否应渲染元素(显示控制为 conditional 时按关联变量判断;设计模式使用变量默认值) */
export function shouldRenderElement(
elem: TemplateElement,
row: RecordRow | null,
rowIndex: number,
defaults?: Record<string, string> | null,
mode: ContentResolveMode = 'output'
): boolean {
if (!isConditionalVisibility(elem)) return true;
const rules = resolveVisibilityRules(elem);
if (rules.length > 0) {
return rules.every((rule) => evaluateVisibilityRule(rule, row, defaults, mode));
}
if (elem.visibilityMode === 'conditional' || elem.visibilityRules !== undefined) {
return true;
}
const value = resolveElementValue(elem, row, rowIndex, defaults, mode);
return !!value && String(value).trim() !== '';
}
/** 解析元素最终显示内容(含文本格式) */
export function resolveElementValue(
elem: TemplateElement,
row: RecordRow | null,
rowIndex: number,
defaults?: Record<string, string> | null,
mode: ContentResolveMode = 'design'
): string {
const formatSubst =
elem.type === 'text' && elem.textFormat && elem.textFormat !== 'text'
? (raw: string) =>
formatDisplayValue(
raw,
elem.textFormat as Exclude<TextFormat, 'text'>,
elem.decimalPlaces
)
: undefined;
return resolveContent(elem.content, row, rowIndex, defaults, { mode, formatSubst });
}
/** 输出模式下条码/二维码/图片内容为空时不渲染 */
export function isGraphicContentEmpty(
elem: TemplateElement,
value: string,
mode: ContentResolveMode
): boolean {
if (mode === 'design') return false;
if (elem.type !== 'barcode' && elem.type !== 'qrcode' && elem.type !== 'image') return false;
return !value || !String(value).trim();
}
// Generate columns and rows based on paper dimensions, label dimensions, gaps, and margins
export function calculateGrid(paper: PaperConfig, label: { width: number; height: number }) {
const paperW = paper.orientation === 'portrait' ? paper.width : paper.height;
const paperH = paper.orientation === 'portrait' ? paper.height : paper.width;
const usableW = paperW - paper.marginLeft - paper.marginRight;
const usableH = paperH - paper.marginTop - paper.marginBottom;
// Let's solve: cols * label.width + (cols - 1) * columnGap <= usableW
// cols * (label.width + columnGap) - columnGap <= usableW
// cols <= (usableW + columnGap) / (label.width + columnGap)
const cols = Math.max(1, Math.floor((usableW + paper.columnGap) / (label.width + paper.columnGap)));
const rows = Math.max(1, Math.floor((usableH + paper.rowGap) / (label.height + paper.rowGap)));
return { cols, rows };
}
export const SAMPLE_COLUMNS = ['货位编号', '物料名称', '规格型号', '批次号', '条形码', '二维码'];
export const SAMPLE_ROWS: RecordRow[] = [
{ '货位编号': 'A-01-05', '物料名称': '轴承支撑座', '规格型号': 'BF12-M8', '批次号': '2026061101', '条形码': 'LOC-A0105', '二维码': 'https://ais-dev.run.apps/cargo/A0105' },
{ '货位编号': 'A-01-06', '物料名称': '高精滚珠丝杠', '规格型号': '1605-300mm', '批次号': '2026061102', '条形码': 'LOC-A0106', '二维码': 'https://ais-dev.run.apps/cargo/A0106' },
{ '货位编号': 'B-02-11', '物料名称': '同步带轮', '规格型号': 'XL20-6.35B', '批次号': '2026061103', '条形码': 'LOC-B0211', '二维码': 'https://ais-dev.run.apps/cargo/B0211' },
{ '货位编号': 'B-02-12', '物料名称': '联轴器弹性体', '规格型号': 'D20L25-8x8', '批次号': '2026061104', '条形码': 'LOC-B0212', '二维码': 'https://ais-dev.run.apps/cargo/B0212' },
{ '货位编号': 'C-03-01', '物料名称': '防尘阻水风扇', '规格型号': '12038-220V', '批次号': '2026061105', '条形码': 'LOC-C0301', '二维码': 'https://ais-dev.run.apps/cargo/C0301' },
{ '货位编号': 'C-03-02', '物料名称': '直流开关电源', '规格型号': 'LRS-350-24', '批次号': '2026061106', '条形码': 'LOC-C0302', '二维码': 'https://ais-dev.run.apps/cargo/C0302' },
{ '货位编号': 'D-04-15', '物料名称': '工业继电器组', '规格型号': 'MY2NJ-DC24V', '批次号': '2026061107', '条形码': 'LOC-D0415', '二维码': 'https://ais-dev.run.apps/cargo/D0415' },
{ '货位编号': 'D-04-16', '物料名称': '可编程控制器', '规格型号': 'FX3U-14MT', '批次号': '2026061108', '条形码': 'LOC-D0416', '二维码': 'https://ais-dev.run.apps/cargo/D0416' }
];

26
tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

24
vite.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig(() => {
return {
base: './',
root: "./",
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
};
});