打印模块
This commit is contained in:
87
src/App.tsx
87
src/App.tsx
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,8 +688,7 @@ 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 ? (
|
||||
@@ -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',
|
||||
|
||||
159
src/components/PrintModule.tsx
Normal file
159
src/components/PrintModule.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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
224
src/labelPrint.ts
Normal 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);
|
||||
}
|
||||
101
src/pdfExport.ts
101
src/pdfExport.ts
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user