This commit is contained in:
iqudoo
2026-06-13 05:34:16 +08:00
parent c25319e0de
commit 2a43df00c6
2 changed files with 213 additions and 32 deletions

View File

@@ -1,16 +1,21 @@
import React, { useMemo, useState, useEffect } from 'react'; import React, { useMemo, useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { PaperConfig, LabelTemplate, RecordRow, PaperType, CutLineConfig, ExportRangeConfig } from '../types'; import { PaperConfig, LabelTemplate, RecordRow, PaperType, CutLineConfig, ExportRangeConfig } from '../types';
import { import {
computeAutoLayoutPaper,
DEFAULT_EXPORT_RANGE, DEFAULT_EXPORT_RANGE,
DEFAULT_CUT_LINE_CONFIG, DEFAULT_CUT_LINE_CONFIG,
buildPrintablePages, buildPrintablePages,
collectGridCutLinePositions, collectGridCutLinePositions,
getCutLineChannel, getCutLineChannel,
loadAutoLayoutMinPageMarginMm,
saveAutoLayoutMinPageMarginMm,
type PrintableLabel, type PrintableLabel,
} from '../utils'; } from '../utils';
import { CanvasLabelImage } from './CanvasLabelImage'; import { CanvasLabelImage } from './CanvasLabelImage';
import { CommitInput } from './CommitInput'; import { CommitInput } from './CommitInput';
import { FileDown, RefreshCcw } from 'lucide-react'; import { useAppDialog } from './AppDialog';
import { FileDown, RefreshCcw, X } from 'lucide-react';
interface PageInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> { interface PageInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
value: string | number; value: string | number;
@@ -69,7 +74,16 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
theme = 'light', theme = 'light',
exportRange, exportRange,
}) => { }) => {
const { showAlert } = useAppDialog();
const [draftPaper, setDraftPaper] = useState(paper); 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); const panelRef = React.useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -93,7 +107,7 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
const fieldClass = isMobile const fieldClass = isMobile
? 'mobile-field font-mono' ? 'mobile-field font-mono'
: isPs : isPs
? 'ps-field font-mono text-center' ? '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'; : '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 const selectClass = isMobile
? 'mobile-field' ? 'mobile-field'
@@ -129,32 +143,59 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
const selectedCount = layoutStats?.selectedCount ?? draftLayoutStats.selectedCount; const selectedCount = layoutStats?.selectedCount ?? draftLayoutStats.selectedCount;
const allRowCount = totalRowCount ?? rows.length; const allRowCount = totalRowCount ?? rows.length;
const autoCenterMargins = () => { const applyAutoLayoutWithMinMargin = (minMargin: number) => {
const paperW = draftPaper.orientation === 'portrait' ? draftPaper.width : draftPaper.height; const next = computeAutoLayoutPaper(draftPaper, template, minMargin);
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); setDraftPaper(next);
onChangePaper(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) => { const applyPaper = (next: PaperConfig) => {
setDraftPaper(next); setDraftPaper(next);
onChangePaper(next); onChangePaper(next);
@@ -180,7 +221,78 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
applyPaper({ ...draftPaper, ...patch }); 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 ( return (
<>
<div <div
ref={panelRef} ref={panelRef}
onBlur={isMobile ? undefined : handlePanelBlur} onBlur={isMobile ? undefined : handlePanelBlur}
@@ -326,10 +438,11 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
/> />
</div> </div>
))} ))}
<div className="col-span-2 flex justify-end -mt-0.5"> <div className="col-span-2 flex flex-col items-end gap-0.5 -mt-0.5">
<button <button
type="button" type="button"
onClick={autoCenterMargins} onClick={openAutoLayoutDialog}
title="设置最小页边距并自动居中排版"
className={ className={
isPs isPs
? 'text-[10px] text-[#777] hover:text-[#31a8ff] transition cursor-pointer' ? 'text-[10px] text-[#777] hover:text-[#31a8ff] transition cursor-pointer'
@@ -338,8 +451,19 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
: 'text-[10px] text-gray-500 hover:text-indigo-600 font-normal cursor-pointer' : 'text-[10px] text-gray-500 hover:text-indigo-600 font-normal cursor-pointer'
} }
> >
</button> </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>
</div> </div>
@@ -387,6 +511,8 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
</div> </div>
</div> </div>
{autoLayoutDialog}
</>
); );
}; };

View File

@@ -296,15 +296,70 @@ export function buildPrintablePages(
}; };
} }
/** 自动排版时允许的最小页边距 (mm) */
export const AUTO_LAYOUT_MIN_PAGE_MARGIN_MM = 10;
const AUTO_LAYOUT_MIN_MARGIN_STORAGE_KEY = 'label_designer_auto_layout_min_margin_mm';
/** 读取上次自动排版使用的最小页边距 (mm) */
export function loadAutoLayoutMinPageMarginMm(): number {
try {
const raw = localStorage.getItem(AUTO_LAYOUT_MIN_MARGIN_STORAGE_KEY);
if (raw === null) return AUTO_LAYOUT_MIN_PAGE_MARGIN_MM;
const n = Number(raw);
if (Number.isFinite(n) && n >= 0) return n;
} catch {
/* ignore */
}
return AUTO_LAYOUT_MIN_PAGE_MARGIN_MM;
}
/** 保存自动排版使用的最小页边距 (mm) */
export function saveAutoLayoutMinPageMarginMm(value: number): void {
try {
localStorage.setItem(AUTO_LAYOUT_MIN_MARGIN_STORAGE_KEY, String(value));
} catch {
/* ignore */
}
}
/** 按最小页边距居中计算四边页边距 */
export function computeAutoLayoutPaper(
paper: PaperConfig,
template: Pick<LabelTemplate, 'width' | 'height'>,
minMargin: number
): PaperConfig {
const paperW = paper.orientation === 'portrait' ? paper.width : paper.height;
const paperH = paper.orientation === 'portrait' ? paper.height : paper.width;
const lw = template.width;
const lh = template.height;
const usableWInit = paperW - minMargin * 2;
const usableHInit = paperH - minMargin * 2;
const maxCols = Math.floor((usableWInit + paper.columnGap) / (lw + paper.columnGap)) || 1;
const maxRows = Math.floor((usableHInit + paper.rowGap) / (lh + paper.rowGap)) || 1;
const gridWidth = maxCols * lw + (maxCols - 1) * paper.columnGap;
const gridHeight = maxRows * lh + (maxRows - 1) * paper.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);
return {
...paper,
marginLeft: balancedLR,
marginRight: balancedLR,
marginTop: balancedTB,
marginBottom: balancedTB,
};
}
export const DEFAULT_PAPER_CONFIG: PaperConfig = { export const DEFAULT_PAPER_CONFIG: PaperConfig = {
type: 'A4', type: 'A4',
width: 210, width: 210,
height: 297, height: 297,
orientation: 'portrait', orientation: 'portrait',
marginTop: 10, marginTop: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
marginBottom: 10, marginBottom: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
marginLeft: 10, marginLeft: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
marginRight: 10, marginRight: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
columnGap: 2, columnGap: 2,
rowGap: 2, rowGap: 2,
flow: 'left-right-top-bottom', flow: 'left-right-top-bottom',