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