打印模块

This commit is contained in:
iqudoo
2026-06-14 03:16:39 +08:00
parent fff6cb98ae
commit 833eda5621
8 changed files with 904 additions and 282 deletions

View File

@@ -22,7 +22,12 @@ import type { TableCellCoord } from './tableUtils';
import { LayoutPreview } from './components/PreviewGrid';
import { CanvasLabelImage } from './components/CanvasLabelImage';
import { renderLabelToDataUrl } from './labelRenderer';
import { exportLabelsPdfAsync, type PdfExportProgress } from './pdfExport';
import { exportLabelsPdfAsync, exportLabelsPdfBlobAsync, type PdfExportProgress } from './pdfExport';
import {
getAvailablePrinters,
saveSelectedPrinterId,
submitLabelsPrint,
} from './labelPrint';
import { useIsMobile } from './hooks/useIsMobile';
import { useUndoRedo } from './hooks/useUndoRedo';
import { useAppDialog } from './components/AppDialog';
@@ -136,6 +141,7 @@ export default function App() {
const isMobile = useIsMobile();
const [pdfGenerating, setPdfGenerating] = useState(false);
const [printing, setPrinting] = useState(false);
const [pdfProgress, setPdfProgress] = useState<PdfExportProgress>({
done: 0,
total: 0,
@@ -778,6 +784,65 @@ export default function App() {
}
};
const printLabels = async (printerId: string) => {
if (!activeTemplate) return;
if (!appliedExportData) {
await showAlert('请先应用数据后再打印。', { variant: 'warning' });
return;
}
if (appliedLayoutResult.selectedCount === 0) {
await showAlert('当前数据范围内没有可排版的标签,请调整范围设置。', { variant: 'warning' });
return;
}
saveSelectedPrinterId(printerId);
setPrinting(true);
try {
const printers = await getAvailablePrinters();
const blob = await exportLabelsPdfBlobAsync({
paper,
template: activeTemplate,
printablePages: appliedLayoutResult.pages,
gridCols: appliedLayoutResult.gridCols,
cutLine,
imageFormat: pdfExportImage.jsPdfFormat,
renderLabel: (label) =>
renderLabelToDataUrl({
widthMm: activeTemplate.width,
heightMm: activeTemplate.height,
elements: activeTemplate.elements,
rowData: label.row,
rowIndex: label.sourceIndex,
showBorder: false,
mode: 'output',
scale: pdfExportImage.scale,
imageFormat: pdfExportImage.imageFormat,
jpegQuality: 0.9,
}),
});
const mode = await submitLabelsPrint(blob, printerId, printers);
if (mode === 'direct') {
await showAlert('已提交到所选打印机。', { variant: 'success' });
}
} catch (e) {
console.error('Print failed', e);
await showAlert('打印失败,请重试。', { variant: 'error' });
} finally {
setPrinting(false);
}
};
const printBlockedReason =
!appliedExportData
? '请先应用数据'
: appliedLayoutResult.selectedCount === 0
? '当前数据范围内无标签'
: previewLayoutStale
? '排版已变更,请重新绘制预览'
: null;
return (
<div
className={`flex flex-col font-sans h-dvh max-h-dvh overflow-hidden ${workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile)
@@ -1270,8 +1335,18 @@ export default function App() {
activeRowIndex={activeRowIndex}
onSelectRowIndex={setActiveRowIndex}
pdfGenerating={pdfGenerating}
printing={printing}
onBack={() => setWorkflowStep('template-select')}
onExportPdf={exportPdf}
onPrint={printLabels}
printDisabled={
!appliedExportData ||
appliedLayoutResult.selectedCount === 0 ||
previewLayoutStale ||
printing ||
pdfGenerating
}
printDisabledReason={printBlockedReason}
/>
)}
@@ -1365,6 +1440,16 @@ export default function App() {
onApplyData={handleApplyData}
onRedrawPreview={handleRedrawPreview}
draftSelectedCount={selectedCount}
printing={printing}
onPrint={printLabels}
printDisabled={
!appliedExportData ||
appliedLayoutResult.selectedCount === 0 ||
previewLayoutStale ||
printing ||
pdfGenerating
}
printDisabledReason={printBlockedReason}
/>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Database, Layout, Settings } from 'lucide-react';
import { Database, Layout, Printer, Settings } from 'lucide-react';
import { ImportData, PaperConfig, LabelTemplate, RecordRow, ExportRangeConfig, CutLineConfig } from '../types';
import { DataImporter } from './DataImporter';
import { VariableMappingPanel } from './VariableMappingPanel';
@@ -7,6 +7,7 @@ import { PaperSettingsPanel, PageInput } from './PreviewGrid';
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
import { DataRangeFields } from './DataRangeFields';
import { FieldSelect } from './PsSelect';
import { PrintModule } from './PrintModule';
interface ExportSidebarProps {
importData: ImportData | null;
@@ -32,6 +33,10 @@ interface ExportSidebarProps {
onApplyData: () => void;
onRedrawPreview: () => void;
draftSelectedCount: number;
printing: boolean;
onPrint: (printerId: string) => void;
printDisabled: boolean;
printDisabledReason?: string | null;
/** 移动端精简:仅数据、映射、数据范围 */
compact?: boolean;
}
@@ -60,12 +65,17 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
onApplyData,
onRedrawPreview,
draftSelectedCount,
printing,
onPrint,
printDisabled,
printDisabledReason = null,
compact = false,
}) => {
type ExportPanelKey = 'data' | 'layout';
type ExportPanelKey = 'data' | 'layout' | 'print';
const [activePanel, setActivePanel] = useState<ExportPanelKey | null>('data');
const dataCollapsed = activePanel !== 'data';
const layoutCollapsed = activePanel !== 'layout';
const printCollapsed = activePanel !== 'print';
const toggleExportPanel = (key: ExportPanelKey) => {
setActivePanel((prev) => (prev === key ? null : key));
};
@@ -283,6 +293,22 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
</fieldset>
</div>
</CollapsiblePanelSection>
<CollapsiblePanelSection
sectionClassName="export-print-panel"
collapsed={printCollapsed}
onToggle={() => toggleExportPanel('print')}
icon={<Printer className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />}
title="打印"
bodyClassName="ps-panel-body p-3 min-h-0 space-y-3"
>
<PrintModule
disabled={printDisabled}
disabledReason={printDisabledReason}
printing={printing}
onPrint={onPrint}
/>
</CollapsiblePanelSection>
</aside>
);
};

View File

@@ -6,6 +6,7 @@ import {
Database,
Layout,
Download,
Printer,
} from 'lucide-react';
import {
ImportData,
@@ -21,8 +22,9 @@ import { VariableMappingPanel } from './VariableMappingPanel';
import { MobileExportLayoutPanel } from './MobileExportLayoutPanel';
import { DataRangeFields } from './DataRangeFields';
import { LayoutPreview } from './PreviewGrid';
import { PrintModule } from './PrintModule';
type MobileExportModule = 'data' | 'layout';
type MobileExportModule = 'data' | 'layout' | 'print';
const MOBILE_EXPORT_MODULES: {
id: MobileExportModule;
@@ -31,6 +33,7 @@ const MOBILE_EXPORT_MODULES: {
}[] = [
{ id: 'data', label: '数据源', icon: <Database className="w-4 h-4" /> },
{ id: 'layout', label: '排版', icon: <Layout className="w-4 h-4" /> },
{ id: 'print', label: '打印', icon: <Printer className="w-4 h-4" /> },
];
interface MobileExportViewProps {
@@ -63,8 +66,12 @@ interface MobileExportViewProps {
activeRowIndex: number;
onSelectRowIndex: (idx: number) => void;
pdfGenerating: boolean;
printing: boolean;
onBack: () => void;
onExportPdf: () => void;
onPrint: (printerId: string) => void;
printDisabled: boolean;
printDisabledReason?: string | null;
}
export const MobileExportView: React.FC<MobileExportViewProps> = ({
@@ -97,8 +104,12 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
activeRowIndex,
onSelectRowIndex,
pdfGenerating,
printing,
onBack,
onExportPdf,
onPrint,
printDisabled,
printDisabledReason = null,
}) => {
const [mobileModule, setMobileModule] = useState<MobileExportModule>('data');
@@ -262,6 +273,17 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
/>
</div>
)}
{mobileModule === 'print' && (
<div className="ps-panel-body p-3 min-h-0">
<PrintModule
variant="mobile"
disabled={printDisabled}
disabledReason={printDisabledReason}
printing={printing}
onPrint={onPrint}
/>
</div>
)}
</div>
</div>
</div>

View File

@@ -227,293 +227,293 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
const autoLayoutDialog =
autoLayoutOpen && typeof document !== 'undefined'
? createPortal(
<div
className="ps-modal-overlay"
role="presentation"
>
<div
className="ps-modal-overlay"
role="presentation"
className="ps-modal"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="auto-layout-dialog-title"
>
<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-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" />
<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"
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
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"
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
)
</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>
<FieldSelect
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>
</FieldSelect>
<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>
)}
{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 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>
<FieldSelect
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>
</FieldSelect>
</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>
{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>
</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>
<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="100"
value={val}
onCommit={(v) => patchDraftPaper({ [key]: Number(v) || 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 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}
/>
<label className={labelClass}></label>
<FieldSelect
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>
</FieldSelect>
</div>
</div>
<div>
<label className={labelClass}></label>
<FieldSelect
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>
</FieldSelect>
</div>
</div>
</div>
</div>
</div>
{autoLayoutDialog}
{autoLayoutDialog}
</>
);
};
@@ -576,6 +576,8 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
const currentPaperW = paper.orientation === 'portrait' ? paper.width : paper.height;
const currentPaperH = paper.orientation === 'portrait' ? paper.height : paper.width;
const pageCardWidthPx = Math.ceil(currentPaperW * previewScale);
const previewPageGapPx = isMobileVariant ? 16 : 32;
const colGapPx = Math.max(0, paper.columnGap * previewScale);
const rowGapPx = Math.max(0, paper.rowGap * previewScale);
const labelWPx = template.width * previewScale;
@@ -665,13 +667,6 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
>
+
</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)}
@@ -684,7 +679,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
<button
type="button"
onClick={() => setShowAllPages((v) => !v)}
className="ps-btn text-[10px]"
className={`ps-preview-preset-btn`}
>
{showAllPages ? '收起预览' : `展开全部 ${totalPages}`}
</button>
@@ -693,9 +688,8 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
</div>
<div
className={`flex-1 overflow-auto ps-workspace-bg min-h-0 ps-preview-workspace ${
isMobileVariant ? 'p-3 mobile-preview-workspace' : 'p-6'
}`}
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">
@@ -708,28 +702,38 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
</div>
) : (
<div
className={`flex flex-col items-center ${
isMobileVariant ? 'space-y-4 pb-4' : 'space-y-8 pb-8'
}`}
className={
isMobileVariant
? 'flex flex-col items-center space-y-4 pb-4'
: 'grid pb-8 justify-items-center content-start w-full'
}
style={
isMobileVariant
? undefined
: {
gridTemplateColumns: `repeat(auto-fill, minmax(${pageCardWidthPx}px, 1fr))`,
gap: `${previewPageGapPx}px`,
}
}
>
{visiblePages.map((pageRows, pageIdx) => (
<div
key={pageIdx}
className="bg-white rounded shadow-2xl overflow-hidden relative border border-[#222] flex flex-col shrink-0"
className="bg-white rounded shadow-2xl overflow-hidden relative border border-[#222] flex flex-col shrink-0 max-w-full"
style={{
width: `${currentPaperW * previewScale}px`,
width: `${pageCardWidthPx}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>
{/* <span>
{currentPaperW} × {currentPaperH} mm · {paper.orientation === 'portrait' ? '纵向' : '横向'}
</span>
</span> */}
</div>
<div
className="bg-white flex items-start justify-start relative select-none overflow-hidden shrink-0"
style={{
width: `${currentPaperW * previewScale}px`,
width: `${pageCardWidthPx}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',

View File

@@ -0,0 +1,159 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Loader2, Printer, RefreshCw } from 'lucide-react';
import { FieldSelect } from './PsSelect';
import { CommitInput } from './CommitInput';
import {
addCustomPrinter,
DEFAULT_PRINTER_ID,
getAvailablePrinters,
loadSelectedPrinterId,
saveSelectedPrinterId,
supportsDirectPrinting,
type PrinterOption,
} from '../labelPrint';
interface PrintModuleProps {
disabled: boolean;
disabledReason?: string | null;
printing: boolean;
onPrint: (printerId: string) => void;
variant?: 'desktop' | 'mobile';
}
export const PrintModule: React.FC<PrintModuleProps> = ({
disabled,
disabledReason,
printing,
onPrint,
variant = 'desktop',
}) => {
const [printers, setPrinters] = useState<PrinterOption[]>([
{ id: DEFAULT_PRINTER_ID, name: '系统默认(打印对话框)' },
]);
const [selectedPrinterId, setSelectedPrinterId] = useState(loadSelectedPrinterId);
const [loadingPrinters, setLoadingPrinters] = useState(false);
const [customPrinterName, setCustomPrinterName] = useState('');
const refreshPrinters = useCallback(async () => {
setLoadingPrinters(true);
try {
const list = await getAvailablePrinters();
setPrinters(list);
const saved = loadSelectedPrinterId();
if (list.some((p) => p.id === saved)) {
setSelectedPrinterId(saved);
} else {
setSelectedPrinterId(DEFAULT_PRINTER_ID);
}
} finally {
setLoadingPrinters(false);
}
}, []);
useEffect(() => {
void refreshPrinters();
}, [refreshPrinters]);
const handlePrint = () => {
saveSelectedPrinterId(selectedPrinterId);
onPrint(selectedPrinterId);
};
const handleAddCustomPrinter = () => {
const trimmed = customPrinterName.trim();
if (!trimmed) return;
const added = addCustomPrinter(trimmed);
setCustomPrinterName('');
void refreshPrinters().then(() => {
setSelectedPrinterId(added.id);
saveSelectedPrinterId(added.id);
});
};
const directAvailable = supportsDirectPrinting(printers);
const selectedPrinter = printers.find((p) => p.id === selectedPrinterId);
const usesDirect = !!selectedPrinter?.direct;
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<label className="ps-field-label mb-0"></label>
<button
type="button"
onClick={() => void refreshPrinters()}
disabled={loadingPrinters}
className="inline-flex items-center gap-1 text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-50"
>
<RefreshCw className={`w-3 h-3 ${loadingPrinters ? 'animate-spin' : ''}`} />
</button>
</div>
<FieldSelect
value={selectedPrinterId}
onChange={(e) => setSelectedPrinterId(e.target.value)}
className="ps-field text-[11px]"
disabled={loadingPrinters || printing}
>
{printers.map((printer) => (
<option key={printer.id} value={printer.id}>
{printer.name}
{printer.direct ? ' · 直连' : ''}
</option>
))}
</FieldSelect>
{!directAvailable && (
<div className="space-y-1.5">
<label className="ps-field-label"></label>
<div className="flex gap-1.5">
<CommitInput
value={customPrinterName}
onCommit={setCustomPrinterName}
placeholder="与系统中打印机名称一致"
className="ps-field text-[11px] flex-1 min-w-0"
disabled={printing}
/>
<button
type="button"
onClick={handleAddCustomPrinter}
disabled={!customPrinterName.trim() || printing}
className="ps-btn text-[10px] shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
)}
<p className="text-[10px] text-[#666] leading-relaxed">
{usesDirect
? '将直接提交到所选打印机。'
: directAvailable
? '所选打印机将通过系统打印对话框确认后打印。'
: '当前浏览器不支持枚举打印机;打印时将打开系统对话框,请在对话框中选择对应打印机。已保存的名称便于下次快速识别。'}
</p>
{disabledReason && disabled && (
<p className="text-[10px] text-amber-500/90">{disabledReason}</p>
)}
<button
type="button"
onClick={handlePrint}
disabled={disabled || printing || loadingPrinters}
className={`w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold transition cursor-pointer ${
!disabled && !printing && !loadingPrinters
? 'bg-[#31a8ff] hover:bg-[#2890d8] text-white'
: 'bg-[#444] text-[#666] cursor-not-allowed'
}`}
>
{printing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin shrink-0" />
) : (
<Printer className="w-3.5 h-3.5 shrink-0" />
)}
{printing ? '准备打印…' : variant === 'mobile' ? '打印' : '开始打印'}
</button>
</div>
);
};

View File

@@ -16,6 +16,7 @@ import {
updateTableRowHeight,
getTableColWidths,
getTableRowHeights,
getCellRawContent,
type TableCellCoord,
} from '../tableUtils';
import { Table2, Merge, SplitSquareHorizontal } from 'lucide-react';

224
src/labelPrint.ts Normal file
View File

@@ -0,0 +1,224 @@
const SELECTED_PRINTER_KEY = 'label-designer.selected-printer-id';
const CUSTOM_PRINTERS_KEY = 'label-designer.custom-printers';
export const DEFAULT_PRINTER_ID = '__system_default__';
export interface PrinterOption {
id: string;
name: string;
/** 是否来自 Web Printing API可直接提交打印任务 */
direct?: boolean;
native?: WebPrintingNativePrinter;
}
interface WebPrintingNativePrinter {
printerId?: string;
printerName: string;
submitPrintJob(jobName: string, documentData: Blob, templateAttributes?: unknown): Promise<unknown>;
}
interface NavigatorWithPrinting extends Navigator {
printing?: {
getPrinters(): Promise<WebPrintingNativePrinter[]>;
};
}
function loadCustomPrinters(): PrinterOption[] {
try {
const raw = localStorage.getItem(CUSTOM_PRINTERS_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed
.filter((item): item is { id: string; name: string } => {
return (
!!item &&
typeof item === 'object' &&
typeof (item as { id?: string }).id === 'string' &&
typeof (item as { name?: string }).name === 'string'
);
})
.map((item) => ({ id: item.id, name: item.name }));
} catch {
return [];
}
}
export function saveCustomPrinters(printers: PrinterOption[]) {
const custom = printers.filter((p) => p.id !== DEFAULT_PRINTER_ID && !p.direct);
localStorage.setItem(CUSTOM_PRINTERS_KEY, JSON.stringify(custom));
}
export function loadSelectedPrinterId(): string {
return localStorage.getItem(SELECTED_PRINTER_KEY) || DEFAULT_PRINTER_ID;
}
export function saveSelectedPrinterId(printerId: string) {
localStorage.setItem(SELECTED_PRINTER_KEY, printerId);
}
export async function getAvailablePrinters(): Promise<PrinterOption[]> {
const options: PrinterOption[] = [
{ id: DEFAULT_PRINTER_ID, name: '系统默认(打印对话框)' },
];
const seen = new Set<string>([DEFAULT_PRINTER_ID]);
const printing = (navigator as NavigatorWithPrinting).printing;
if (printing?.getPrinters) {
try {
const nativePrinters = await printing.getPrinters();
for (const printer of nativePrinters) {
const id = printer.printerId || printer.printerName;
if (!id || seen.has(id)) continue;
seen.add(id);
options.push({
id,
name: printer.printerName,
direct: true,
native: printer,
});
}
} catch (err) {
console.warn('getPrinters failed', err);
}
}
for (const custom of loadCustomPrinters()) {
if (seen.has(custom.id)) continue;
seen.add(custom.id);
options.push(custom);
}
return options;
}
export function addCustomPrinter(name: string): PrinterOption {
const trimmed = name.trim();
const id = `custom:${trimmed}`;
const next: PrinterOption = { id, name: trimmed };
const existing = loadCustomPrinters().filter((p) => p.id !== id);
saveCustomPrinters([...existing, next]);
return next;
}
interface PrintSurface {
win: Window;
dispose: () => void;
}
function createPdfPrintSurface(url: string): PrintSurface | null {
const popup = window.open(url, '_blank');
if (popup) {
return {
win: popup,
dispose: () => {
try {
popup.close();
} catch {
/* ignore */
}
},
};
}
const iframe = document.createElement('iframe');
iframe.setAttribute('title', 'label-print');
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '100%',
height: '100%',
border: 'none',
opacity: '0',
pointerEvents: 'none',
zIndex: '2147483646',
});
iframe.src = url;
document.body.appendChild(iframe);
const iframeWin = iframe.contentWindow;
if (!iframeWin) {
iframe.remove();
return null;
}
return {
win: iframeWin,
dispose: () => iframe.remove(),
};
}
function schedulePdfPrint(surface: PrintSurface, onDone: () => void) {
let finished = false;
const finish = () => {
if (finished) return;
finished = true;
onDone();
};
const trigger = () => {
try {
surface.win.focus();
surface.win.print();
finish();
} catch (err) {
console.warn('print() failed', err);
}
};
surface.win.addEventListener('load', () => window.setTimeout(trigger, 800));
window.setTimeout(trigger, 1500);
window.setTimeout(trigger, 2800);
window.setTimeout(finish, 4000);
}
function printPdfBlob(blob: Blob): Promise<void> {
return new Promise((resolve, reject) => {
if (!blob || blob.size === 0) {
reject(new Error('Empty PDF'));
return;
}
const url = URL.createObjectURL(blob);
const surface = createPdfPrintSurface(url);
if (!surface) {
URL.revokeObjectURL(url);
reject(new Error('无法打开打印窗口,请允许弹出窗口后重试'));
return;
}
let settled = false;
const cleanup = () => {
window.setTimeout(() => {
surface.dispose();
URL.revokeObjectURL(url);
}, 60_000);
};
schedulePdfPrint(surface, () => {
if (settled) return;
settled = true;
cleanup();
resolve();
});
});
}
export async function submitLabelsPrint(
pdfBlob: Blob,
printerId: string,
printers: PrinterOption[]
): Promise<'direct' | 'dialog'> {
const printer = printers.find((p) => p.id === printerId);
if (printer?.direct && printer.native) {
await printer.native.submitPrintJob('labels', pdfBlob);
return 'direct';
}
await printPdfBlob(pdfBlob);
return 'dialog';
}
export function supportsDirectPrinting(printers: PrinterOption[]): boolean {
return printers.some((p) => p.direct);
}

View File

@@ -205,3 +205,104 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
await yieldToMain();
pdf.save(filename);
}
/** 与 exportLabelsPdfAsync 相同渲染流程,返回 PDF Blob用于浏览器打印 */
export async function exportLabelsPdfBlobAsync(
options: Omit<AsyncExportLabelsPdfOptions, 'filename'>
): Promise<Blob> {
const {
paper,
template,
printablePages,
gridCols,
cutLine,
imageFormat,
renderLabel,
onProgress,
} = options;
const paperW = paper.orientation === 'portrait' ? paper.width : paper.height;
const paperH = paper.orientation === 'portrait' ? paper.height : paper.width;
const orientation = paperW > paperH ? 'landscape' : 'portrait';
const labelW = template.width;
const labelH = template.height;
const totalPages = printablePages.length;
const totalLabels = printablePages.reduce(
(sum, page) => sum + page.filter((item) => item !== null).length,
0
);
const pdf = new jsPDF({
orientation,
unit: 'mm',
format: [paperW, paperH],
compress: true,
});
let done = 0;
let progressTick = 0;
const report = (page: number, phase: PdfExportProgress['phase']) => {
onProgress?.({ done, total: totalLabels, page, totalPages, phase });
};
for (let pIdx = 0; pIdx < printablePages.length; pIdx++) {
if (pIdx > 0) {
pdf.addPage([paperW, paperH], orientation);
}
const pageRows = printablePages[pIdx];
const gridRows = Math.ceil(pageRows.length / gridCols);
const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine);
report(pIdx + 1, 'render');
const pageJobs: { cellIdx: number; label: PrintableLabel }[] = [];
for (let cellIdx = 0; cellIdx < pageRows.length; cellIdx++) {
const item = pageRows[cellIdx];
if (!item) continue;
pageJobs.push({ cellIdx, label: item });
}
for (let i = 0; i < pageJobs.length; i += PAGE_RENDER_CONCURRENCY) {
const batch = pageJobs.slice(i, i + PAGE_RENDER_CONCURRENCY);
const rendered = await Promise.all(
batch.map(async (job) => ({
...job,
dataUrl: await renderLabel(job.label),
}))
);
for (const { cellIdx, dataUrl } of rendered) {
const col = cellIdx % gridCols;
const row = Math.floor(cellIdx / gridCols);
const x = paper.marginLeft + col * (labelW + paper.columnGap) + cutChannel.x;
const y = paper.marginTop + row * (labelH + paper.rowGap) + cutChannel.y;
const drawW = labelW - 2 * cutChannel.x;
const drawH = labelH - 2 * cutChannel.y;
pdf.addImage(dataUrl, imageFormat, x, y, drawW, drawH, undefined, 'FAST');
done++;
}
progressTick++;
if (progressTick >= 2) {
progressTick = 0;
report(pIdx + 1, 'render');
await yieldToMain();
}
}
if (cutLine.enabled && pageRows.length > 0) {
drawPageGridCutLines(pdf, paper, labelW, labelH, gridCols, gridRows, cutLine);
}
report(pIdx + 1, 'render');
await yieldToMain();
}
report(totalPages, 'save');
await yieldToMain();
return pdf.output('blob');
}