init commit
This commit is contained in:
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"liveServer.settings.port": 5501
|
||||||
|
}
|
||||||
13
index.html
Normal file
13
index.html
Normal 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
6
metadata.json
Normal 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
4938
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal 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
3101
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1182
src/App.tsx
Normal file
1182
src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
204
src/components/AppDialog.tsx
Normal file
204
src/components/AppDialog.tsx
Normal 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;
|
||||||
|
}
|
||||||
57
src/components/BarcodeRenderer.tsx
Normal file
57
src/components/BarcodeRenderer.tsx
Normal 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" />;
|
||||||
|
};
|
||||||
89
src/components/CanvasLabelImage.tsx
Normal file
89
src/components/CanvasLabelImage.tsx
Normal 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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
src/components/CollapsiblePanelSection.tsx
Normal file
48
src/components/CollapsiblePanelSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
312
src/components/DataImporter.tsx
Normal file
312
src/components/DataImporter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
89
src/components/DataRangeFields.tsx
Normal file
89
src/components/DataRangeFields.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
282
src/components/ExportSidebar.tsx
Normal file
282
src/components/ExportSidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
968
src/components/LabelDesigner.tsx
Normal file
968
src/components/LabelDesigner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
157
src/components/MobileExportPropsPanel.tsx
Normal file
157
src/components/MobileExportPropsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
291
src/components/MobileExportView.tsx
Normal file
291
src/components/MobileExportView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
712
src/components/PreviewGrid.tsx
Normal file
712
src/components/PreviewGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1673
src/components/PropSidebar.tsx
Normal file
1673
src/components/PropSidebar.tsx
Normal file
File diff suppressed because it is too large
Load Diff
35
src/components/QRCodeRenderer.tsx
Normal file
35
src/components/QRCodeRenderer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
145
src/components/VariableMappingPanel.tsx
Normal file
145
src/components/VariableMappingPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
129
src/components/VisibilityRuleDialog.tsx
Normal file
129
src/components/VisibilityRuleDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
75
src/components/VisibilityRuleForm.tsx
Normal file
75
src/components/VisibilityRuleForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
58
src/components/VisibilityRuleSummary.tsx
Normal file
58
src/components/VisibilityRuleSummary.tsx
Normal 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
153
src/elementAlign.ts
Normal 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
221
src/elementBox.ts
Normal 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
23
src/hooks/useIsMobile.ts
Normal 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` 断点一致:< 768px 视为移动端 */
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
return useMediaQuery('(max-width: 767px)');
|
||||||
|
}
|
||||||
1169
src/index.css
Normal file
1169
src/index.css
Normal file
File diff suppressed because it is too large
Load Diff
446
src/labelRenderer.ts
Normal file
446
src/labelRenderer.ts
Normal 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;
|
||||||
|
/** 渲染倍率,默认 16;PDF 导出可用较低倍率以减轻内存与耗时 */
|
||||||
|
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
13
src/main.tsx
Normal 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
166
src/pdfExport.ts
Normal 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
168
src/types.ts
Normal 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;
|
||||||
|
/** 背景不透明度 0–100,100 为完全不透明;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
691
src/utils.ts
Normal 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
26
tsconfig.json
Normal 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
24
vite.config.ts
Normal 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 : {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user