打印模块
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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
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