打印模块

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

View File

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

View File

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

View File

@@ -227,293 +227,293 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
const autoLayoutDialog =
autoLayoutOpen && typeof document !== 'undefined'
? createPortal(
<div
className="ps-modal-overlay"
role="presentation"
>
<div
className="ps-modal-overlay"
role="presentation"
className="ps-modal"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="auto-layout-dialog-title"
>
<div
className="ps-modal"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="auto-layout-dialog-title"
<div className="ps-modal-header">
<h3 id="auto-layout-dialog-title" className="ps-modal-title">
</h3>
<button
type="button"
onClick={closeAutoLayoutDialog}
className="ps-modal-close"
aria-label="关闭"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
void confirmAutoLayout();
}}
>
<div className="ps-modal-header">
<h3 id="auto-layout-dialog-title" className="ps-modal-title">
</h3>
<button
type="button"
onClick={closeAutoLayoutDialog}
className="ps-modal-close"
aria-label="关闭"
>
<X className="w-3.5 h-3.5" />
<div className="ps-modal-body space-y-3">
<p className="text-[11px] text-[#888] leading-relaxed">
</p>
<div>
<label htmlFor="auto-layout-min-margin" className="ps-field-label">
(mm)
</label>
<input
id="auto-layout-min-margin"
ref={autoLayoutInputRef}
type="number"
min="0"
max="100"
step="0.1"
value={autoLayoutMinMargin}
onChange={(e) => setAutoLayoutMinMargin(e.target.value)}
className="ps-field font-mono"
/>
</div>
</div>
<div className="ps-modal-footer">
<button type="button" onClick={closeAutoLayoutDialog} className="ps-btn text-[11px]">
</button>
<button type="submit" className="ps-btn ps-btn-primary text-[11px]">
</button>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
void confirmAutoLayout();
}}
>
<div className="ps-modal-body space-y-3">
<p className="text-[11px] text-[#888] leading-relaxed">
</p>
<div>
<label htmlFor="auto-layout-min-margin" className="ps-field-label">
(mm)
</label>
<input
id="auto-layout-min-margin"
ref={autoLayoutInputRef}
type="number"
min="0"
max="100"
step="0.1"
value={autoLayoutMinMargin}
onChange={(e) => setAutoLayoutMinMargin(e.target.value)}
className="ps-field font-mono"
/>
</div>
</div>
<div className="ps-modal-footer">
<button type="button" onClick={closeAutoLayoutDialog} className="ps-btn text-[11px]">
</button>
<button type="submit" className="ps-btn ps-btn-primary text-[11px]">
</button>
</div>
</form>
</div>
</div>,
document.body
)
</form>
</div>
</div>,
document.body
)
: null;
return (
<>
<div
ref={panelRef}
onBlur={isMobile ? undefined : handlePanelBlur}
className={`space-y-4 text-xs ${isPs || isMobile ? '' : 'bg-white border border-gray-200 rounded-2xl p-6 shadow-sm'
}`}
>
{!isPs && !isMobile && (
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-bold text-gray-900 leading-none"></h3>
</div>
)}
{isPs && (
<p className="text-[10px] text-[#666] leading-relaxed">
</p>
)}
<div className={isPs || isMobile ? 'space-y-4' : 'grid grid-cols-1 md:grid-cols-4 gap-6'}>
<div className="space-y-2">
<h4 className={sectionTitleClass}></h4>
<div>
<label className={labelClass}></label>
<FieldSelect
value={draftPaper.type}
onChange={(e) => handlePaperTypeChange(e.target.value as PaperType)}
className={selectClass}
>
<option value="A4">A4 (210×297 mm)</option>
<option value="A5">A5 (148×210 mm)</option>
<option value="A6">A6 (105×148 mm)</option>
<option value="custom"></option>
</FieldSelect>
<div
ref={panelRef}
onBlur={isMobile ? undefined : handlePanelBlur}
className={`space-y-4 text-xs ${isPs || isMobile ? '' : 'bg-white border border-gray-200 rounded-2xl p-6 shadow-sm'
}`}
>
{!isPs && !isMobile && (
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-bold text-gray-900 leading-none"></h3>
</div>
)}
{isPs && (
<p className="text-[10px] text-[#666] leading-relaxed">
</p>
)}
{draftPaper.type === 'custom' && (
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}> (mm)</label>
<PageInput
type="number"
value={draftPaper.width}
onCommit={(val) => patchDraftPaper({ width: Math.max(10, Number(val) || 10) })}
inputClassName={fieldClass}
/>
</div>
<div>
<label className={labelClass}> (mm)</label>
<PageInput
type="number"
value={draftPaper.height}
onCommit={(val) => patchDraftPaper({ height: Math.max(10, Number(val) || 10) })}
inputClassName={fieldClass}
/>
</div>
<div className={isPs || isMobile ? 'space-y-4' : 'grid grid-cols-1 md:grid-cols-4 gap-6'}>
<div className="space-y-2">
<h4 className={sectionTitleClass}></h4>
<div>
<label className={labelClass}></label>
<FieldSelect
value={draftPaper.type}
onChange={(e) => handlePaperTypeChange(e.target.value as PaperType)}
className={selectClass}
>
<option value="A4">A4 (210×297 mm)</option>
<option value="A5">A5 (148×210 mm)</option>
<option value="A6">A6 (105×148 mm)</option>
<option value="custom"></option>
</FieldSelect>
</div>
)}
<div>
<label className={labelClass}></label>
{isPs ? (
<div className="ps-toggle-group">
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'portrait' })}
className={`ps-toggle-btn flex-1 text-[10px] ${draftPaper.orientation === 'portrait' ? 'active' : ''}`}
>
</button>
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'landscape' })}
className={`ps-toggle-btn flex-1 text-[10px] ${draftPaper.orientation === 'landscape' ? 'active' : ''}`}
>
</button>
</div>
) : isMobile ? (
<div className="flex bg-slate-100 p-1 rounded-xl">
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'portrait' })}
className={`flex-1 py-2.5 text-sm font-semibold rounded-lg transition ${draftPaper.orientation === 'portrait'
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500'
} cursor-pointer`}
>
</button>
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'landscape' })}
className={`flex-1 py-2.5 text-sm font-semibold rounded-lg transition ${draftPaper.orientation === 'landscape'
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500'
} cursor-pointer`}
>
</button>
</div>
) : (
<div className="flex bg-gray-100 p-0.5 rounded-lg">
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'portrait' })}
className={`flex-1 py-1.5 text-[11px] font-semibold rounded ${draftPaper.orientation === 'portrait' ? 'bg-white text-gray-800 shadow-xs' : 'text-gray-500'
} cursor-pointer`}
>
</button>
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'landscape' })}
className={`flex-1 py-1.5 text-[11px] font-semibold rounded ${draftPaper.orientation === 'landscape' ? 'bg-white text-gray-800 shadow-xs' : 'text-gray-500'
} cursor-pointer`}
>
</button>
{draftPaper.type === 'custom' && (
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}> (mm)</label>
<PageInput
type="number"
value={draftPaper.width}
onCommit={(val) => patchDraftPaper({ width: Math.max(10, Number(val) || 10) })}
inputClassName={fieldClass}
/>
</div>
<div>
<label className={labelClass}> (mm)</label>
<PageInput
type="number"
value={draftPaper.height}
onCommit={(val) => patchDraftPaper({ height: Math.max(10, Number(val) || 10) })}
inputClassName={fieldClass}
/>
</div>
</div>
)}
</div>
</div>
<div className="space-y-2">
<h4 className={sectionTitleClass}> (mm)</h4>
<div className="grid grid-cols-2 gap-2">
{(
[
['上边距', 'marginTop', draftPaper.marginTop],
['下边距', 'marginBottom', draftPaper.marginBottom],
['左边距', 'marginLeft', draftPaper.marginLeft],
['右边距', 'marginRight', draftPaper.marginRight],
] as const
).map(([label, key, val]) => (
<div key={key}>
<label className={labelClass}>{label}</label>
<div>
<label className={labelClass}></label>
{isPs ? (
<div className="ps-toggle-group">
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'portrait' })}
className={`ps-toggle-btn flex-1 text-[10px] ${draftPaper.orientation === 'portrait' ? 'active' : ''}`}
>
</button>
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'landscape' })}
className={`ps-toggle-btn flex-1 text-[10px] ${draftPaper.orientation === 'landscape' ? 'active' : ''}`}
>
</button>
</div>
) : isMobile ? (
<div className="flex bg-slate-100 p-1 rounded-xl">
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'portrait' })}
className={`flex-1 py-2.5 text-sm font-semibold rounded-lg transition ${draftPaper.orientation === 'portrait'
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500'
} cursor-pointer`}
>
</button>
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'landscape' })}
className={`flex-1 py-2.5 text-sm font-semibold rounded-lg transition ${draftPaper.orientation === 'landscape'
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500'
} cursor-pointer`}
>
</button>
</div>
) : (
<div className="flex bg-gray-100 p-0.5 rounded-lg">
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'portrait' })}
className={`flex-1 py-1.5 text-[11px] font-semibold rounded ${draftPaper.orientation === 'portrait' ? 'bg-white text-gray-800 shadow-xs' : 'text-gray-500'
} cursor-pointer`}
>
</button>
<button
type="button"
onClick={() => patchDraftPaper({ orientation: 'landscape' })}
className={`flex-1 py-1.5 text-[11px] font-semibold rounded ${draftPaper.orientation === 'landscape' ? 'bg-white text-gray-800 shadow-xs' : 'text-gray-500'
} cursor-pointer`}
>
</button>
</div>
)}
</div>
</div>
<div className="space-y-2">
<h4 className={sectionTitleClass}> (mm)</h4>
<div className="grid grid-cols-2 gap-2">
{(
[
['上边距', 'marginTop', draftPaper.marginTop],
['下边距', 'marginBottom', draftPaper.marginBottom],
['左边距', 'marginLeft', draftPaper.marginLeft],
['右边距', 'marginRight', draftPaper.marginRight],
] as const
).map(([label, key, val]) => (
<div key={key}>
<label className={labelClass}>{label}</label>
<PageInput
type="number"
min="0"
max="100"
value={val}
onCommit={(v) => patchDraftPaper({ [key]: Number(v) || 0 })}
inputClassName={fieldClass}
/>
</div>
))}
<div className="col-span-2 flex flex-col items-end gap-0.5 -mt-0.5">
<button
type="button"
onClick={openAutoLayoutDialog}
title="设置最小页边距并自动居中排版"
className={
isPs
? 'text-[10px] text-[#777] hover:text-[#31a8ff] transition cursor-pointer'
: isMobile
? 'text-xs text-slate-500 hover:text-indigo-600 font-normal py-1 cursor-pointer'
: 'text-[10px] text-gray-500 hover:text-indigo-600 font-normal cursor-pointer'
}
>
</button>
<span
className={
isPs
? 'text-[9px] text-[#666] leading-tight'
: isMobile
? 'text-[10px] text-slate-400 leading-tight'
: 'text-[9px] text-gray-400 leading-tight'
}
>
{lastAutoLayoutMinMargin} mm
</span>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className={sectionTitleClass}> (mm)</h4>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}></label>
<PageInput
type="number"
step="0.5"
min="0"
max="100"
value={val}
onCommit={(v) => patchDraftPaper({ [key]: Number(v) || 0 })}
max="50"
value={draftPaper.columnGap}
onCommit={(val) => patchDraftPaper({ columnGap: Number(val) || 0 })}
inputClassName={fieldClass}
/>
</div>
<div>
<label className={labelClass}></label>
<PageInput
type="number"
step="0.5"
min="0"
max="50"
value={draftPaper.rowGap}
onCommit={(val) => patchDraftPaper({ rowGap: Number(val) || 0 })}
inputClassName={fieldClass}
/>
</div>
))}
<div className="col-span-2 flex flex-col items-end gap-0.5 -mt-0.5">
<button
type="button"
onClick={openAutoLayoutDialog}
title="设置最小页边距并自动居中排版"
className={
isPs
? 'text-[10px] text-[#777] hover:text-[#31a8ff] transition cursor-pointer'
: isMobile
? 'text-xs text-slate-500 hover:text-indigo-600 font-normal py-1 cursor-pointer'
: 'text-[10px] text-gray-500 hover:text-indigo-600 font-normal cursor-pointer'
}
>
</button>
<span
className={
isPs
? 'text-[9px] text-[#666] leading-tight'
: isMobile
? 'text-[10px] text-slate-400 leading-tight'
: 'text-[9px] text-gray-400 leading-tight'
}
>
{lastAutoLayoutMinMargin} mm
</span>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className={sectionTitleClass}> (mm)</h4>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}></label>
<PageInput
type="number"
step="0.5"
min="0"
max="50"
value={draftPaper.columnGap}
onCommit={(val) => patchDraftPaper({ columnGap: Number(val) || 0 })}
inputClassName={fieldClass}
/>
</div>
<div>
<label className={labelClass}></label>
<PageInput
type="number"
step="0.5"
min="0"
max="50"
value={draftPaper.rowGap}
onCommit={(val) => patchDraftPaper({ rowGap: Number(val) || 0 })}
inputClassName={fieldClass}
/>
<label className={labelClass}></label>
<FieldSelect
value={draftPaper.flow}
onChange={(e) => patchDraftPaper({ flow: e.target.value as PaperConfig['flow'] })}
className={selectClass}
>
<option value="left-right-top-bottom"> </option>
<option value="top-bottom-left-right"> </option>
</FieldSelect>
</div>
</div>
<div>
<label className={labelClass}></label>
<FieldSelect
value={draftPaper.flow}
onChange={(e) => patchDraftPaper({ flow: e.target.value as PaperConfig['flow'] })}
className={selectClass}
>
<option value="left-right-top-bottom"> </option>
<option value="top-bottom-left-right"> </option>
</FieldSelect>
</div>
</div>
</div>
</div>
</div>
{autoLayoutDialog}
{autoLayoutDialog}
</>
);
};
@@ -576,6 +576,8 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
const currentPaperW = paper.orientation === 'portrait' ? paper.width : paper.height;
const currentPaperH = paper.orientation === 'portrait' ? paper.height : paper.width;
const pageCardWidthPx = Math.ceil(currentPaperW * previewScale);
const previewPageGapPx = isMobileVariant ? 16 : 32;
const colGapPx = Math.max(0, paper.columnGap * previewScale);
const rowGapPx = Math.max(0, paper.rowGap * previewScale);
const labelWPx = template.width * previewScale;
@@ -665,13 +667,6 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
>
+
</button>
<button
type="button"
onClick={() => setPreviewScale(2.0)}
className={`ps-preview-preset-btn ${previewScale === 2.0 ? 'active' : ''}`}
>
</button>
<button
type="button"
onClick={() => setPreviewScale(3.3)}
@@ -684,7 +679,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
<button
type="button"
onClick={() => setShowAllPages((v) => !v)}
className="ps-btn text-[10px]"
className={`ps-preview-preset-btn`}
>
{showAllPages ? '收起预览' : `展开全部 ${totalPages}`}
</button>
@@ -693,9 +688,8 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
</div>
<div
className={`flex-1 overflow-auto ps-workspace-bg min-h-0 ps-preview-workspace ${
isMobileVariant ? 'p-3 mobile-preview-workspace' : 'p-6'
}`}
className={`flex-1 overflow-auto ps-workspace-bg min-h-0 ps-preview-workspace ${isMobileVariant ? 'p-3 mobile-preview-workspace' : 'p-6'
}`}
>
{!previewReady ? (
<div className="h-full flex flex-col items-center justify-center text-center px-6 gap-2">
@@ -708,28 +702,38 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
</div>
) : (
<div
className={`flex flex-col items-center ${
isMobileVariant ? 'space-y-4 pb-4' : 'space-y-8 pb-8'
}`}
className={
isMobileVariant
? 'flex flex-col items-center space-y-4 pb-4'
: 'grid pb-8 justify-items-center content-start w-full'
}
style={
isMobileVariant
? undefined
: {
gridTemplateColumns: `repeat(auto-fill, minmax(${pageCardWidthPx}px, 1fr))`,
gap: `${previewPageGapPx}px`,
}
}
>
{visiblePages.map((pageRows, pageIdx) => (
<div
key={pageIdx}
className="bg-white rounded shadow-2xl overflow-hidden relative border border-[#222] flex flex-col shrink-0"
className="bg-white rounded shadow-2xl overflow-hidden relative border border-[#222] flex flex-col shrink-0 max-w-full"
style={{
width: `${currentPaperW * previewScale}px`,
width: `${pageCardWidthPx}px`,
}}
>
<div className="bg-[#f0f0f0] border-b border-gray-200 py-1.5 px-3 flex justify-between items-center text-[10px] font-semibold text-gray-500 select-none shrink-0">
<span> {pageIdx + 1} / {totalPages} </span>
<span>
{/* <span>
{currentPaperW} × {currentPaperH} mm · {paper.orientation === 'portrait' ? '纵向' : '横向'}
</span>
</span> */}
</div>
<div
className="bg-white flex items-start justify-start relative select-none overflow-hidden shrink-0"
style={{
width: `${currentPaperW * previewScale}px`,
width: `${pageCardWidthPx}px`,
height: `${currentPaperH * previewScale}px`,
padding: `${paper.marginTop * previewScale}px ${paper.marginRight * previewScale}px ${paper.marginBottom * previewScale}px ${paper.marginLeft * previewScale}px`,
boxSizing: 'border-box',

View File

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

View File

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