打印模块
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user