Files
label-designer/src/components/PreviewGrid.tsx
2026-06-13 05:51:48 +08:00

900 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
value: string | number;
onCommit: (val: string) => void;
inputClassName?: string;
}
export const PageInput: React.FC<PageInputProps> = ({
value,
onCommit,
inputClassName = '',
...props
}) => (
<CommitInput value={value} onCommit={onCommit} className={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<PaperSettingsPanelProps> = ({
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<HTMLInputElement>(null);
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'
: 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<PaperConfig>) => {
applyPaper({ ...draftPaper, ...patch });
};
const autoLayoutDialog =
autoLayoutOpen && typeof document !== 'undefined'
? createPortal(
<div
className="ps-modal-overlay"
onClick={closeAutoLayoutDialog}
role="presentation"
>
<div
className="ps-modal"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="auto-layout-dialog-title"
>
<div className="ps-modal-header">
<h3 id="auto-layout-dialog-title" className="ps-modal-title">
</h3>
<button
type="button"
onClick={closeAutoLayoutDialog}
className="ps-modal-close"
aria-label="关闭"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
void confirmAutoLayout();
}}
>
<div className="ps-modal-body space-y-3">
<p className="text-[11px] text-[#888] leading-relaxed">
</p>
<div>
<label htmlFor="auto-layout-min-margin" className="ps-field-label">
(mm)
</label>
<input
id="auto-layout-min-margin"
ref={autoLayoutInputRef}
type="number"
min="0.1"
max="100"
step="0.1"
value={autoLayoutMinMargin}
onChange={(e) => setAutoLayoutMinMargin(e.target.value)}
className="ps-field font-mono"
/>
</div>
</div>
<div className="ps-modal-footer">
<button type="button" onClick={closeAutoLayoutDialog} className="ps-btn text-[11px]">
</button>
<button type="submit" className="ps-btn ps-btn-primary text-[11px]">
</button>
</div>
</form>
</div>
</div>,
document.body
)
: null;
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"></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 className="col-span-2 flex flex-col items-end gap-0.5 -mt-0.5">
<button
type="button"
onClick={openAutoLayoutDialog}
title="设置最小页边距并自动居中排版"
className={
isPs
? 'text-[10px] text-[#777] hover:text-[#31a8ff] transition cursor-pointer'
: isMobile
? 'text-xs text-slate-500 hover:text-indigo-600 font-normal py-1 cursor-pointer'
: 'text-[10px] text-gray-500 hover:text-indigo-600 font-normal cursor-pointer'
}
>
</button>
<span
className={
isPs
? 'text-[9px] text-[#666] leading-tight'
: isMobile
? 'text-[10px] text-slate-400 leading-tight'
: 'text-[9px] text-gray-400 leading-tight'
}
>
{lastAutoLayoutMinMargin} mm
</span>
</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>
{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<LayoutPreviewProps> = ({
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<number>(
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 (
<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 ${isMobileVariant ? 'mobile-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 className="truncate">
{previewReady ? totalPages : draftTotalPages}
</span>
{!isMobileVariant && (
<>
<span className="text-[#666]">|</span>
<span>
{(previewReady ? selectedCount : draftSelectedCount) < totalRowCount
? `${previewReady ? selectedCount : draftSelectedCount}/${totalRowCount}`
: `${totalRowCount}`}
</span>
</>
)}
{previewLayoutStale && previewReady && (
<span className="text-amber-500/90 shrink-0"></span>
)}
{previewReady && hasMorePages && !showAllPages && !isMobileVariant && (
<span className="text-amber-500/90"> {MAX_PREVIEW_PAGES} </span>
)}
</div>
<div className={`flex items-center gap-3 shrink-0 ${isMobileVariant ? 'hidden' : ''}`}>
{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 min-h-0 ps-preview-workspace ${
isMobileVariant ? 'p-3 mobile-preview-workspace' : 'p-6'
}`}
>
{!previewReady ? (
<div className="h-full flex flex-col items-center justify-center text-center px-6 gap-2">
<p className="text-sm text-[#aaa]"></p>
<p className="text-xs text-[#666] max-w-[280px] leading-relaxed">
{isMobileVariant
? '在下方「数据源」中上传 Excel预览将自动更新'
: '在右侧「数据源」中点击「应用数据」生成排版预览;纸张排版变更会自动更新'}
</p>
</div>
) : (
<div
className={`flex flex-col items-center ${
isMobileVariant ? 'space-y-4 pb-4' : 'space-y-8 pb-8'
}`}
>
{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 relative"
style={{
gridTemplateColumns: `repeat(${gridInfo.cols}, ${labelWPx}px)`,
gridAutoRows: `${labelHPx}px`,
columnGap: `${colGapPx}px`,
rowGap: `${rowGapPx}px`,
width: 'max-content',
}}
>
{cutLine.enabled && (
<svg
className="absolute pointer-events-none z-20"
style={{
left: -cutHalfStroke,
top: -cutHalfStroke,
width: pageCutLines.gridW + cutStrokeWidth,
height: pageCutLines.gridH + cutStrokeWidth,
overflow: 'visible',
}}
viewBox={`${-cutHalfStroke} ${-cutHalfStroke} ${pageCutLines.gridW + cutStrokeWidth} ${pageCutLines.gridH + cutStrokeWidth}`}
aria-hidden
>
{pageCutLines.ys.map((y) => (
<line
key={`h-${y}`}
x1={0}
y1={y}
x2={pageCutLines.gridW}
y2={y}
stroke={cutLine.color}
strokeWidth={cutStrokeWidth}
strokeDasharray={cutDash}
strokeLinecap="butt"
/>
))}
{pageCutLines.xs.map((x) => (
<line
key={`v-${x}`}
x1={x}
y1={0}
x2={x}
y2={pageCutLines.gridH}
stroke={cutLine.color}
strokeWidth={cutStrokeWidth}
strokeDasharray={cutDash}
strokeLinecap="butt"
/>
))}
</svg>
)}
{pageRows.map((item, cellIdx) => {
if (!item) {
return (
<div
key={cellIdx}
className="bg-white flex items-center justify-center text-gray-300 font-mono text-[9px]"
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 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',
}}
>
<div className="w-full h-full overflow-hidden relative">
<CanvasLabelImage
widthMm={template.width}
heightMm={template.height}
elements={template.elements}
rowData={item.row}
rowIndex={item.sourceIndex}
showBorder={false}
mode="output"
/>
</div>
</div>
);
})}
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className={`ps-status-bar ${isMobileVariant ? 'mobile-preview-status' : ''}`}>
<span>
{previewReady
? `${gridInfo.cols}×${gridInfo.rows} · 每页 ${labelsPerPage}`
: `预计 ${draftTotalPages}`}
</span>
{!isMobileVariant && (
<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>
);
};