900 lines
33 KiB
TypeScript
900 lines
33 KiB
TypeScript
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>
|
||
);
|
||
};
|