import React, { useMemo, useState, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { PaperConfig, LabelTemplate, RecordRow, PaperType, CutLineConfig, ExportRangeConfig } from '../types'; import { computeAutoLayoutPaper, DEFAULT_EXPORT_RANGE, DEFAULT_CUT_LINE_CONFIG, buildPrintablePages, collectGridCutLinePositions, getCutLineChannel, formatCutLineSvgDashArray, getCutLineStrokeWidthMm, loadAutoLayoutMinPageMarginMm, saveAutoLayoutMinPageMarginMm, type PrintableLabel, } from '../utils'; import { CanvasLabelImage } from './CanvasLabelImage'; import { CommitInput } from './CommitInput'; import { useAppDialog } from './AppDialog'; import { FileDown, RefreshCcw, X } from 'lucide-react'; interface PageInputProps extends Omit, 'onChange'> { value: string | number; onCommit: (val: string) => void; inputClassName?: string; } export const PageInput: React.FC = ({ value, onCommit, inputClassName = '', ...props }) => ( ); 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 = ({ paper, onChangePaper, template, rows = [], layoutStats, totalRowCount, theme = 'light', exportRange, }) => { const { showAlert } = useAppDialog(); const [draftPaper, setDraftPaper] = useState(paper); const [autoLayoutOpen, setAutoLayoutOpen] = useState(false); const [lastAutoLayoutMinMargin, setLastAutoLayoutMinMargin] = useState( () => loadAutoLayoutMinPageMarginMm() ); const [autoLayoutMinMargin, setAutoLayoutMinMargin] = useState( () => String(loadAutoLayoutMinPageMarginMm()) ); const autoLayoutInputRef = useRef(null); const panelRef = React.useRef(null); useEffect(() => { setDraftPaper(paper); }, [paper]); const commitDraftIfChanged = () => { if (!paperEquals(draftPaper, paper)) { onChangePaper(draftPaper); } }; const handlePanelBlur = (e: React.FocusEvent) => { 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' : isPs ? 'ps-field font-mono' : '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 applyAutoLayoutWithMinMargin = (minMargin: number) => { const next = computeAutoLayoutPaper(draftPaper, template, minMargin); setDraftPaper(next); onChangePaper(next); }; const closeAutoLayoutDialog = () => setAutoLayoutOpen(false); const openAutoLayoutDialog = () => { setAutoLayoutMinMargin(String(lastAutoLayoutMinMargin)); setAutoLayoutOpen(true); }; useEffect(() => { if (!autoLayoutOpen) return; const timer = window.setTimeout(() => autoLayoutInputRef.current?.focus(), 0); return () => window.clearTimeout(timer); }, [autoLayoutOpen]); useEffect(() => { if (!autoLayoutOpen) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); closeAutoLayoutDialog(); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [autoLayoutOpen]); const confirmAutoLayout = async () => { const minMargin = Number(autoLayoutMinMargin); if (!Number.isFinite(minMargin) || minMargin < 0) { await showAlert('请输入不小于 0 的最小页边距(mm)。', { variant: 'warning' }); return; } const paperW = draftPaper.orientation === 'portrait' ? draftPaper.width : draftPaper.height; const paperH = draftPaper.orientation === 'portrait' ? draftPaper.height : draftPaper.width; if (minMargin * 2 >= paperW || minMargin * 2 >= paperH) { await showAlert('最小页边距过大,当前纸张尺寸无法排版。', { variant: 'warning' }); return; } applyAutoLayoutWithMinMargin(minMargin); saveAutoLayoutMinPageMarginMm(minMargin); setLastAutoLayoutMinMargin(minMargin); closeAutoLayoutDialog(); }; const applyPaper = (next: PaperConfig) => { setDraftPaper(next); 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) => { applyPaper({ ...draftPaper, ...patch }); }; const autoLayoutDialog = autoLayoutOpen && typeof document !== 'undefined' ? createPortal(
e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="auto-layout-dialog-title" >

自动排版

{ e.preventDefault(); void confirmAutoLayout(); }} >

将根据当前纸张与标签尺寸,在不低于最小页边距的前提下,自动计算左右、上下对称边距并居中排版。

setAutoLayoutMinMargin(e.target.value)} className="ps-field font-mono" />
, document.body ) : null; return ( <>
{!isPs && !isMobile && (

排版

)} {isPs && (

排版参数修改后将自动更新预览。

)}

纸张

{draftPaper.type === 'custom' && (
patchDraftPaper({ width: Math.max(10, Number(val) || 10) })} inputClassName={fieldClass} />
patchDraftPaper({ height: Math.max(10, Number(val) || 10) })} inputClassName={fieldClass} />
)}
{isPs ? (
) : isMobile ? (
) : (
)}

页边距 (mm)

{( [ ['上边距', 'marginTop', draftPaper.marginTop], ['下边距', 'marginBottom', draftPaper.marginBottom], ['左边距', 'marginLeft', draftPaper.marginLeft], ['右边距', 'marginRight', draftPaper.marginRight], ] as const ).map(([label, key, val]) => (
patchDraftPaper({ [key]: Number(v) || 0 })} inputClassName={fieldClass} />
))}
上次最小页边距 {lastAutoLayoutMinMargin} mm

标签间距 (mm)

patchDraftPaper({ columnGap: Number(val) || 0 })} inputClassName={fieldClass} />
patchDraftPaper({ rowGap: Number(val) || 0 })} inputClassName={fieldClass} />
{autoLayoutDialog} ); }; 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; variant?: 'desktop' | 'mobile'; } export const LayoutPreview: React.FC = ({ paper, template, printablePages, gridCols, labelsPerPage, totalPages, selectedCount, totalRowCount, draftTotalPages, draftSelectedCount, previewReady, previewLayoutStale, cutLine, activeRowIndex, onSelectRowIndex, compact = false, collapsed = false, onToggleCollapsed, variant = 'desktop', }) => { const isMobileVariant = variant === 'mobile'; const [previewScale, setPreviewScale] = useState( isMobileVariant ? 1.15 : 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 labelWPx = template.width * previewScale; const labelHPx = template.height * previewScale; const cutStrokeWidth = getCutLineStrokeWidthMm(cutLine) * previewScale; const cutHalfStroke = cutStrokeWidth / 2; const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine); const cutChannelXPx = cutChannel.x * previewScale; const cutChannelYPx = cutChannel.y * previewScale; const cutDash = formatCutLineSvgDashArray(cutLine, previewScale); const pageCutLines = useMemo( () => collectGridCutLinePositions( 0, 0, labelWPx, labelHPx, gridInfo.cols, gridInfo.rows, colGapPx, rowGapPx ), [labelWPx, labelHPx, gridInfo.cols, gridInfo.rows, colGapPx, rowGapPx] ); const visiblePages = showAllPages ? printablePages : printablePages.slice(0, MAX_PREVIEW_PAGES); const hasMorePages = printablePages.length > MAX_PREVIEW_PAGES; return (
排版预览 | {previewReady ? totalPages : draftTotalPages} 页 {!isMobileVariant && ( <> | {(previewReady ? selectedCount : draftSelectedCount) < totalRowCount ? `${previewReady ? selectedCount : draftSelectedCount}/${totalRowCount} 行` : `${totalRowCount} 行`} )} {previewLayoutStale && previewReady && ( 待更新 )} {previewReady && hasMorePages && !showAllPages && !isMobileVariant && ( 预览前 {MAX_PREVIEW_PAGES} 页 )}
{compact && onToggleCollapsed && ( )}
预览比例 {Math.round((previewScale / 3.3) * 100)}%
{hasMorePages && ( )}
{!previewReady ? (

上传数据源并完成变量映射后

{isMobileVariant ? '在下方「数据源」中上传 Excel,预览将自动更新' : '在右侧「数据源」中点击「应用数据」生成排版预览;纸张排版变更会自动更新'}

) : (
{visiblePages.map((pageRows, pageIdx) => (
第 {pageIdx + 1} 页 / 共 {totalPages} 页 {currentPaperW} × {currentPaperH} mm · {paper.orientation === 'portrait' ? '纵向' : '横向'}
{cutLine.enabled && ( {pageCutLines.ys.map((y) => ( ))} {pageCutLines.xs.map((x) => ( ))} )} {pageRows.map((item, cellIdx) => { if (!item) { return (
空白
); } const isActive = item.sourceIndex === activeRowIndex; return (
onSelectRowIndex(item.sourceIndex)} className={`bg-white text-center relative flex flex-col justify-center cursor-pointer box-border ${isActive ? 'ring-2 ring-inset ring-[#31a8ff] z-10' : '' }`} style={{ width: `${labelWPx}px`, height: `${labelHPx}px`, padding: `${cutChannelYPx}px ${cutChannelXPx}px`, boxSizing: 'border-box', }} >
); })}
))}
)}
{previewReady ? `${gridInfo.cols}×${gridInfo.rows} · 每页 ${labelsPerPage} 枚` : `预计 ${draftTotalPages} 页`} {!isMobileVariant && ( {previewReady ? '点击标签可高亮对应数据行' : '数据变更后请在右侧面板手动刷新绘制'} )}
); }; /** @deprecated 使用 ExportSidebar + LayoutPreview 组合 */ export interface PreviewGridProps extends LayoutPreviewProps { paper: PaperConfig; onChangePaper: (updated: PaperConfig) => void; } export const PreviewGrid: React.FC = ({ paper, onChangePaper, template, rows, activeRowIndex, onSelectRowIndex, }) => { const layout = buildPrintablePages(rows, paper, template, DEFAULT_EXPORT_RANGE); return (
); };