init commit
This commit is contained in:
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>
|
||||
);
|
||||
Reference in New Issue
Block a user