打印模块
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 { LayoutPreview } from './components/PreviewGrid';
|
||||||
import { CanvasLabelImage } from './components/CanvasLabelImage';
|
import { CanvasLabelImage } from './components/CanvasLabelImage';
|
||||||
import { renderLabelToDataUrl } from './labelRenderer';
|
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 { useIsMobile } from './hooks/useIsMobile';
|
||||||
import { useUndoRedo } from './hooks/useUndoRedo';
|
import { useUndoRedo } from './hooks/useUndoRedo';
|
||||||
import { useAppDialog } from './components/AppDialog';
|
import { useAppDialog } from './components/AppDialog';
|
||||||
@@ -136,6 +141,7 @@ export default function App() {
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const [pdfGenerating, setPdfGenerating] = useState(false);
|
const [pdfGenerating, setPdfGenerating] = useState(false);
|
||||||
|
const [printing, setPrinting] = useState(false);
|
||||||
const [pdfProgress, setPdfProgress] = useState<PdfExportProgress>({
|
const [pdfProgress, setPdfProgress] = useState<PdfExportProgress>({
|
||||||
done: 0,
|
done: 0,
|
||||||
total: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col font-sans h-dvh max-h-dvh overflow-hidden ${workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile)
|
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}
|
activeRowIndex={activeRowIndex}
|
||||||
onSelectRowIndex={setActiveRowIndex}
|
onSelectRowIndex={setActiveRowIndex}
|
||||||
pdfGenerating={pdfGenerating}
|
pdfGenerating={pdfGenerating}
|
||||||
|
printing={printing}
|
||||||
onBack={() => setWorkflowStep('template-select')}
|
onBack={() => setWorkflowStep('template-select')}
|
||||||
onExportPdf={exportPdf}
|
onExportPdf={exportPdf}
|
||||||
|
onPrint={printLabels}
|
||||||
|
printDisabled={
|
||||||
|
!appliedExportData ||
|
||||||
|
appliedLayoutResult.selectedCount === 0 ||
|
||||||
|
previewLayoutStale ||
|
||||||
|
printing ||
|
||||||
|
pdfGenerating
|
||||||
|
}
|
||||||
|
printDisabledReason={printBlockedReason}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1365,6 +1440,16 @@ export default function App() {
|
|||||||
onApplyData={handleApplyData}
|
onApplyData={handleApplyData}
|
||||||
onRedrawPreview={handleRedrawPreview}
|
onRedrawPreview={handleRedrawPreview}
|
||||||
draftSelectedCount={selectedCount}
|
draftSelectedCount={selectedCount}
|
||||||
|
printing={printing}
|
||||||
|
onPrint={printLabels}
|
||||||
|
printDisabled={
|
||||||
|
!appliedExportData ||
|
||||||
|
appliedLayoutResult.selectedCount === 0 ||
|
||||||
|
previewLayoutStale ||
|
||||||
|
printing ||
|
||||||
|
pdfGenerating
|
||||||
|
}
|
||||||
|
printDisabledReason={printBlockedReason}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
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 { ImportData, PaperConfig, LabelTemplate, RecordRow, ExportRangeConfig, CutLineConfig } from '../types';
|
||||||
import { DataImporter } from './DataImporter';
|
import { DataImporter } from './DataImporter';
|
||||||
import { VariableMappingPanel } from './VariableMappingPanel';
|
import { VariableMappingPanel } from './VariableMappingPanel';
|
||||||
@@ -7,6 +7,7 @@ import { PaperSettingsPanel, PageInput } from './PreviewGrid';
|
|||||||
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
|
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
|
||||||
import { DataRangeFields } from './DataRangeFields';
|
import { DataRangeFields } from './DataRangeFields';
|
||||||
import { FieldSelect } from './PsSelect';
|
import { FieldSelect } from './PsSelect';
|
||||||
|
import { PrintModule } from './PrintModule';
|
||||||
|
|
||||||
interface ExportSidebarProps {
|
interface ExportSidebarProps {
|
||||||
importData: ImportData | null;
|
importData: ImportData | null;
|
||||||
@@ -32,6 +33,10 @@ interface ExportSidebarProps {
|
|||||||
onApplyData: () => void;
|
onApplyData: () => void;
|
||||||
onRedrawPreview: () => void;
|
onRedrawPreview: () => void;
|
||||||
draftSelectedCount: number;
|
draftSelectedCount: number;
|
||||||
|
printing: boolean;
|
||||||
|
onPrint: (printerId: string) => void;
|
||||||
|
printDisabled: boolean;
|
||||||
|
printDisabledReason?: string | null;
|
||||||
/** 移动端精简:仅数据、映射、数据范围 */
|
/** 移动端精简:仅数据、映射、数据范围 */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
@@ -60,12 +65,17 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
|||||||
onApplyData,
|
onApplyData,
|
||||||
onRedrawPreview,
|
onRedrawPreview,
|
||||||
draftSelectedCount,
|
draftSelectedCount,
|
||||||
|
printing,
|
||||||
|
onPrint,
|
||||||
|
printDisabled,
|
||||||
|
printDisabledReason = null,
|
||||||
compact = false,
|
compact = false,
|
||||||
}) => {
|
}) => {
|
||||||
type ExportPanelKey = 'data' | 'layout';
|
type ExportPanelKey = 'data' | 'layout' | 'print';
|
||||||
const [activePanel, setActivePanel] = useState<ExportPanelKey | null>('data');
|
const [activePanel, setActivePanel] = useState<ExportPanelKey | null>('data');
|
||||||
const dataCollapsed = activePanel !== 'data';
|
const dataCollapsed = activePanel !== 'data';
|
||||||
const layoutCollapsed = activePanel !== 'layout';
|
const layoutCollapsed = activePanel !== 'layout';
|
||||||
|
const printCollapsed = activePanel !== 'print';
|
||||||
const toggleExportPanel = (key: ExportPanelKey) => {
|
const toggleExportPanel = (key: ExportPanelKey) => {
|
||||||
setActivePanel((prev) => (prev === key ? null : key));
|
setActivePanel((prev) => (prev === key ? null : key));
|
||||||
};
|
};
|
||||||
@@ -283,6 +293,22 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</CollapsiblePanelSection>
|
</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>
|
</aside>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
Layout,
|
Layout,
|
||||||
Download,
|
Download,
|
||||||
|
Printer,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
ImportData,
|
ImportData,
|
||||||
@@ -21,8 +22,9 @@ import { VariableMappingPanel } from './VariableMappingPanel';
|
|||||||
import { MobileExportLayoutPanel } from './MobileExportLayoutPanel';
|
import { MobileExportLayoutPanel } from './MobileExportLayoutPanel';
|
||||||
import { DataRangeFields } from './DataRangeFields';
|
import { DataRangeFields } from './DataRangeFields';
|
||||||
import { LayoutPreview } from './PreviewGrid';
|
import { LayoutPreview } from './PreviewGrid';
|
||||||
|
import { PrintModule } from './PrintModule';
|
||||||
|
|
||||||
type MobileExportModule = 'data' | 'layout';
|
type MobileExportModule = 'data' | 'layout' | 'print';
|
||||||
|
|
||||||
const MOBILE_EXPORT_MODULES: {
|
const MOBILE_EXPORT_MODULES: {
|
||||||
id: MobileExportModule;
|
id: MobileExportModule;
|
||||||
@@ -31,6 +33,7 @@ const MOBILE_EXPORT_MODULES: {
|
|||||||
}[] = [
|
}[] = [
|
||||||
{ id: 'data', label: '数据源', icon: <Database className="w-4 h-4" /> },
|
{ id: 'data', label: '数据源', icon: <Database className="w-4 h-4" /> },
|
||||||
{ id: 'layout', label: '排版', icon: <Layout 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 {
|
interface MobileExportViewProps {
|
||||||
@@ -63,8 +66,12 @@ interface MobileExportViewProps {
|
|||||||
activeRowIndex: number;
|
activeRowIndex: number;
|
||||||
onSelectRowIndex: (idx: number) => void;
|
onSelectRowIndex: (idx: number) => void;
|
||||||
pdfGenerating: boolean;
|
pdfGenerating: boolean;
|
||||||
|
printing: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onExportPdf: () => void;
|
onExportPdf: () => void;
|
||||||
|
onPrint: (printerId: string) => void;
|
||||||
|
printDisabled: boolean;
|
||||||
|
printDisabledReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
||||||
@@ -97,8 +104,12 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
|||||||
activeRowIndex,
|
activeRowIndex,
|
||||||
onSelectRowIndex,
|
onSelectRowIndex,
|
||||||
pdfGenerating,
|
pdfGenerating,
|
||||||
|
printing,
|
||||||
onBack,
|
onBack,
|
||||||
onExportPdf,
|
onExportPdf,
|
||||||
|
onPrint,
|
||||||
|
printDisabled,
|
||||||
|
printDisabledReason = null,
|
||||||
}) => {
|
}) => {
|
||||||
const [mobileModule, setMobileModule] = useState<MobileExportModule>('data');
|
const [mobileModule, setMobileModule] = useState<MobileExportModule>('data');
|
||||||
|
|
||||||
@@ -262,6 +273,17 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -576,6 +576,8 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
|
|
||||||
const currentPaperW = paper.orientation === 'portrait' ? paper.width : paper.height;
|
const currentPaperW = paper.orientation === 'portrait' ? paper.width : paper.height;
|
||||||
const currentPaperH = paper.orientation === 'portrait' ? paper.height : paper.width;
|
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 colGapPx = Math.max(0, paper.columnGap * previewScale);
|
||||||
const rowGapPx = Math.max(0, paper.rowGap * previewScale);
|
const rowGapPx = Math.max(0, paper.rowGap * previewScale);
|
||||||
const labelWPx = template.width * previewScale;
|
const labelWPx = template.width * previewScale;
|
||||||
@@ -665,13 +667,6 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPreviewScale(2.0)}
|
|
||||||
className={`ps-preview-preset-btn ${previewScale === 2.0 ? 'active' : ''}`}
|
|
||||||
>
|
|
||||||
适中
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPreviewScale(3.3)}
|
onClick={() => setPreviewScale(3.3)}
|
||||||
@@ -684,7 +679,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowAllPages((v) => !v)}
|
onClick={() => setShowAllPages((v) => !v)}
|
||||||
className="ps-btn text-[10px]"
|
className={`ps-preview-preset-btn`}
|
||||||
>
|
>
|
||||||
{showAllPages ? '收起预览' : `展开全部 ${totalPages} 页`}
|
{showAllPages ? '收起预览' : `展开全部 ${totalPages} 页`}
|
||||||
</button>
|
</button>
|
||||||
@@ -693,8 +688,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex-1 overflow-auto ps-workspace-bg min-h-0 ps-preview-workspace ${
|
className={`flex-1 overflow-auto ps-workspace-bg min-h-0 ps-preview-workspace ${isMobileVariant ? 'p-3 mobile-preview-workspace' : 'p-6'
|
||||||
isMobileVariant ? 'p-3 mobile-preview-workspace' : 'p-6'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{!previewReady ? (
|
{!previewReady ? (
|
||||||
@@ -708,28 +702,38 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col items-center ${
|
className={
|
||||||
isMobileVariant ? 'space-y-4 pb-4' : 'space-y-8 pb-8'
|
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) => (
|
{visiblePages.map((pageRows, pageIdx) => (
|
||||||
<div
|
<div
|
||||||
key={pageIdx}
|
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={{
|
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">
|
<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>第 {pageIdx + 1} 页 / 共 {totalPages} 页</span>
|
||||||
<span>
|
{/* <span>
|
||||||
{currentPaperW} × {currentPaperH} mm · {paper.orientation === 'portrait' ? '纵向' : '横向'}
|
{currentPaperW} × {currentPaperH} mm · {paper.orientation === 'portrait' ? '纵向' : '横向'}
|
||||||
</span>
|
</span> */}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="bg-white flex items-start justify-start relative select-none overflow-hidden shrink-0"
|
className="bg-white flex items-start justify-start relative select-none overflow-hidden shrink-0"
|
||||||
style={{
|
style={{
|
||||||
width: `${currentPaperW * previewScale}px`,
|
width: `${pageCardWidthPx}px`,
|
||||||
height: `${currentPaperH * previewScale}px`,
|
height: `${currentPaperH * previewScale}px`,
|
||||||
padding: `${paper.marginTop * previewScale}px ${paper.marginRight * previewScale}px ${paper.marginBottom * previewScale}px ${paper.marginLeft * previewScale}px`,
|
padding: `${paper.marginTop * previewScale}px ${paper.marginRight * previewScale}px ${paper.marginBottom * previewScale}px ${paper.marginLeft * previewScale}px`,
|
||||||
boxSizing: 'border-box',
|
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,
|
updateTableRowHeight,
|
||||||
getTableColWidths,
|
getTableColWidths,
|
||||||
getTableRowHeights,
|
getTableRowHeights,
|
||||||
|
getCellRawContent,
|
||||||
type TableCellCoord,
|
type TableCellCoord,
|
||||||
} from '../tableUtils';
|
} from '../tableUtils';
|
||||||
import { Table2, Merge, SplitSquareHorizontal } from 'lucide-react';
|
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();
|
await yieldToMain();
|
||||||
pdf.save(filename);
|
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