init commit

This commit is contained in:
iqudoo
2026-06-12 19:34:27 +08:00
commit 89b59ecc1b
38 changed files with 17719 additions and 0 deletions

View 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>
);
};