打印模块
This commit is contained in:
87
src/App.tsx
87
src/App.tsx
@@ -22,7 +22,12 @@ import type { TableCellCoord } from './tableUtils';
|
|||||||
import { LayoutPreview } from './components/PreviewGrid';
|
import { LayoutPreview } from './components/PreviewGrid';
|
||||||
import { CanvasLabelImage } from './components/CanvasLabelImage';
|
import { CanvasLabelImage } from './components/CanvasLabelImage';
|
||||||
import { renderLabelToDataUrl } from './labelRenderer';
|
import { renderLabelToDataUrl } from './labelRenderer';
|
||||||
import { exportLabelsPdfAsync, type PdfExportProgress } from './pdfExport';
|
import { exportLabelsPdfAsync, exportLabelsPdfBlobAsync, type PdfExportProgress } from './pdfExport';
|
||||||
|
import {
|
||||||
|
getAvailablePrinters,
|
||||||
|
saveSelectedPrinterId,
|
||||||
|
submitLabelsPrint,
|
||||||
|
} from './labelPrint';
|
||||||
import { useIsMobile } from './hooks/useIsMobile';
|
import { useIsMobile } from './hooks/useIsMobile';
|
||||||
import { useUndoRedo } from './hooks/useUndoRedo';
|
import { useUndoRedo } from './hooks/useUndoRedo';
|
||||||
import { useAppDialog } from './components/AppDialog';
|
import { useAppDialog } from './components/AppDialog';
|
||||||
@@ -136,6 +141,7 @@ export default function App() {
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const [pdfGenerating, setPdfGenerating] = useState(false);
|
const [pdfGenerating, setPdfGenerating] = useState(false);
|
||||||
|
const [printing, setPrinting] = useState(false);
|
||||||
const [pdfProgress, setPdfProgress] = useState<PdfExportProgress>({
|
const [pdfProgress, setPdfProgress] = useState<PdfExportProgress>({
|
||||||
done: 0,
|
done: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -778,6 +784,65 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const printLabels = async (printerId: string) => {
|
||||||
|
if (!activeTemplate) return;
|
||||||
|
if (!appliedExportData) {
|
||||||
|
await showAlert('请先应用数据后再打印。', { variant: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (appliedLayoutResult.selectedCount === 0) {
|
||||||
|
await showAlert('当前数据范围内没有可排版的标签,请调整范围设置。', { variant: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSelectedPrinterId(printerId);
|
||||||
|
setPrinting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const printers = await getAvailablePrinters();
|
||||||
|
const blob = await exportLabelsPdfBlobAsync({
|
||||||
|
paper,
|
||||||
|
template: activeTemplate,
|
||||||
|
printablePages: appliedLayoutResult.pages,
|
||||||
|
gridCols: appliedLayoutResult.gridCols,
|
||||||
|
cutLine,
|
||||||
|
imageFormat: pdfExportImage.jsPdfFormat,
|
||||||
|
renderLabel: (label) =>
|
||||||
|
renderLabelToDataUrl({
|
||||||
|
widthMm: activeTemplate.width,
|
||||||
|
heightMm: activeTemplate.height,
|
||||||
|
elements: activeTemplate.elements,
|
||||||
|
rowData: label.row,
|
||||||
|
rowIndex: label.sourceIndex,
|
||||||
|
showBorder: false,
|
||||||
|
mode: 'output',
|
||||||
|
scale: pdfExportImage.scale,
|
||||||
|
imageFormat: pdfExportImage.imageFormat,
|
||||||
|
jpegQuality: 0.9,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mode = await submitLabelsPrint(blob, printerId, printers);
|
||||||
|
if (mode === 'direct') {
|
||||||
|
await showAlert('已提交到所选打印机。', { variant: 'success' });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Print failed', e);
|
||||||
|
await showAlert('打印失败,请重试。', { variant: 'error' });
|
||||||
|
} finally {
|
||||||
|
setPrinting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const printBlockedReason =
|
||||||
|
!appliedExportData
|
||||||
|
? '请先应用数据'
|
||||||
|
: appliedLayoutResult.selectedCount === 0
|
||||||
|
? '当前数据范围内无标签'
|
||||||
|
: previewLayoutStale
|
||||||
|
? '排版已变更,请重新绘制预览'
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col font-sans h-dvh max-h-dvh overflow-hidden ${workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile)
|
className={`flex flex-col font-sans h-dvh max-h-dvh overflow-hidden ${workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile)
|
||||||
@@ -1270,8 +1335,18 @@ export default function App() {
|
|||||||
activeRowIndex={activeRowIndex}
|
activeRowIndex={activeRowIndex}
|
||||||
onSelectRowIndex={setActiveRowIndex}
|
onSelectRowIndex={setActiveRowIndex}
|
||||||
pdfGenerating={pdfGenerating}
|
pdfGenerating={pdfGenerating}
|
||||||
|
printing={printing}
|
||||||
onBack={() => setWorkflowStep('template-select')}
|
onBack={() => setWorkflowStep('template-select')}
|
||||||
onExportPdf={exportPdf}
|
onExportPdf={exportPdf}
|
||||||
|
onPrint={printLabels}
|
||||||
|
printDisabled={
|
||||||
|
!appliedExportData ||
|
||||||
|
appliedLayoutResult.selectedCount === 0 ||
|
||||||
|
previewLayoutStale ||
|
||||||
|
printing ||
|
||||||
|
pdfGenerating
|
||||||
|
}
|
||||||
|
printDisabledReason={printBlockedReason}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1365,6 +1440,16 @@ export default function App() {
|
|||||||
onApplyData={handleApplyData}
|
onApplyData={handleApplyData}
|
||||||
onRedrawPreview={handleRedrawPreview}
|
onRedrawPreview={handleRedrawPreview}
|
||||||
draftSelectedCount={selectedCount}
|
draftSelectedCount={selectedCount}
|
||||||
|
printing={printing}
|
||||||
|
onPrint={printLabels}
|
||||||
|
printDisabled={
|
||||||
|
!appliedExportData ||
|
||||||
|
appliedLayoutResult.selectedCount === 0 ||
|
||||||
|
previewLayoutStale ||
|
||||||
|
printing ||
|
||||||
|
pdfGenerating
|
||||||
|
}
|
||||||
|
printDisabledReason={printBlockedReason}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Database, Layout, Settings } from 'lucide-react';
|
import { Database, Layout, Printer, Settings } from 'lucide-react';
|
||||||
import { ImportData, PaperConfig, LabelTemplate, RecordRow, ExportRangeConfig, CutLineConfig } from '../types';
|
import { ImportData, PaperConfig, LabelTemplate, RecordRow, ExportRangeConfig, CutLineConfig } from '../types';
|
||||||
import { DataImporter } from './DataImporter';
|
import { DataImporter } from './DataImporter';
|
||||||
import { VariableMappingPanel } from './VariableMappingPanel';
|
import { VariableMappingPanel } from './VariableMappingPanel';
|
||||||
@@ -7,6 +7,7 @@ import { PaperSettingsPanel, PageInput } from './PreviewGrid';
|
|||||||
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
|
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
|
||||||
import { DataRangeFields } from './DataRangeFields';
|
import { DataRangeFields } from './DataRangeFields';
|
||||||
import { FieldSelect } from './PsSelect';
|
import { FieldSelect } from './PsSelect';
|
||||||
|
import { PrintModule } from './PrintModule';
|
||||||
|
|
||||||
interface ExportSidebarProps {
|
interface ExportSidebarProps {
|
||||||
importData: ImportData | null;
|
importData: ImportData | null;
|
||||||
@@ -32,6 +33,10 @@ interface ExportSidebarProps {
|
|||||||
onApplyData: () => void;
|
onApplyData: () => void;
|
||||||
onRedrawPreview: () => void;
|
onRedrawPreview: () => void;
|
||||||
draftSelectedCount: number;
|
draftSelectedCount: number;
|
||||||
|
printing: boolean;
|
||||||
|
onPrint: (printerId: string) => void;
|
||||||
|
printDisabled: boolean;
|
||||||
|
printDisabledReason?: string | null;
|
||||||
/** 移动端精简:仅数据、映射、数据范围 */
|
/** 移动端精简:仅数据、映射、数据范围 */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
@@ -60,12 +65,17 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
|||||||
onApplyData,
|
onApplyData,
|
||||||
onRedrawPreview,
|
onRedrawPreview,
|
||||||
draftSelectedCount,
|
draftSelectedCount,
|
||||||
|
printing,
|
||||||
|
onPrint,
|
||||||
|
printDisabled,
|
||||||
|
printDisabledReason = null,
|
||||||
compact = false,
|
compact = false,
|
||||||
}) => {
|
}) => {
|
||||||
type ExportPanelKey = 'data' | 'layout';
|
type ExportPanelKey = 'data' | 'layout' | 'print';
|
||||||
const [activePanel, setActivePanel] = useState<ExportPanelKey | null>('data');
|
const [activePanel, setActivePanel] = useState<ExportPanelKey | null>('data');
|
||||||
const dataCollapsed = activePanel !== 'data';
|
const dataCollapsed = activePanel !== 'data';
|
||||||
const layoutCollapsed = activePanel !== 'layout';
|
const layoutCollapsed = activePanel !== 'layout';
|
||||||
|
const printCollapsed = activePanel !== 'print';
|
||||||
const toggleExportPanel = (key: ExportPanelKey) => {
|
const toggleExportPanel = (key: ExportPanelKey) => {
|
||||||
setActivePanel((prev) => (prev === key ? null : key));
|
setActivePanel((prev) => (prev === key ? null : key));
|
||||||
};
|
};
|
||||||
@@ -283,6 +293,22 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</CollapsiblePanelSection>
|
</CollapsiblePanelSection>
|
||||||
|
|
||||||
|
<CollapsiblePanelSection
|
||||||
|
sectionClassName="export-print-panel"
|
||||||
|
collapsed={printCollapsed}
|
||||||
|
onToggle={() => toggleExportPanel('print')}
|
||||||
|
icon={<Printer className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />}
|
||||||
|
title="打印"
|
||||||
|
bodyClassName="ps-panel-body p-3 min-h-0 space-y-3"
|
||||||
|
>
|
||||||
|
<PrintModule
|
||||||
|
disabled={printDisabled}
|
||||||
|
disabledReason={printDisabledReason}
|
||||||
|
printing={printing}
|
||||||
|
onPrint={onPrint}
|
||||||
|
/>
|
||||||
|
</CollapsiblePanelSection>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
Layout,
|
Layout,
|
||||||
Download,
|
Download,
|
||||||
|
Printer,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
ImportData,
|
ImportData,
|
||||||
@@ -21,8 +22,9 @@ import { VariableMappingPanel } from './VariableMappingPanel';
|
|||||||
import { MobileExportLayoutPanel } from './MobileExportLayoutPanel';
|
import { MobileExportLayoutPanel } from './MobileExportLayoutPanel';
|
||||||
import { DataRangeFields } from './DataRangeFields';
|
import { DataRangeFields } from './DataRangeFields';
|
||||||
import { LayoutPreview } from './PreviewGrid';
|
import { LayoutPreview } from './PreviewGrid';
|
||||||
|
import { PrintModule } from './PrintModule';
|
||||||
|
|
||||||
type MobileExportModule = 'data' | 'layout';
|
type MobileExportModule = 'data' | 'layout' | 'print';
|
||||||
|
|
||||||
const MOBILE_EXPORT_MODULES: {
|
const MOBILE_EXPORT_MODULES: {
|
||||||
id: MobileExportModule;
|
id: MobileExportModule;
|
||||||
@@ -31,6 +33,7 @@ const MOBILE_EXPORT_MODULES: {
|
|||||||
}[] = [
|
}[] = [
|
||||||
{ id: 'data', label: '数据源', icon: <Database className="w-4 h-4" /> },
|
{ id: 'data', label: '数据源', icon: <Database className="w-4 h-4" /> },
|
||||||
{ id: 'layout', label: '排版', icon: <Layout className="w-4 h-4" /> },
|
{ id: 'layout', label: '排版', icon: <Layout className="w-4 h-4" /> },
|
||||||
|
{ id: 'print', label: '打印', icon: <Printer className="w-4 h-4" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface MobileExportViewProps {
|
interface MobileExportViewProps {
|
||||||
@@ -63,8 +66,12 @@ interface MobileExportViewProps {
|
|||||||
activeRowIndex: number;
|
activeRowIndex: number;
|
||||||
onSelectRowIndex: (idx: number) => void;
|
onSelectRowIndex: (idx: number) => void;
|
||||||
pdfGenerating: boolean;
|
pdfGenerating: boolean;
|
||||||
|
printing: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onExportPdf: () => void;
|
onExportPdf: () => void;
|
||||||
|
onPrint: (printerId: string) => void;
|
||||||
|
printDisabled: boolean;
|
||||||
|
printDisabledReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
||||||
@@ -97,8 +104,12 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
|||||||
activeRowIndex,
|
activeRowIndex,
|
||||||
onSelectRowIndex,
|
onSelectRowIndex,
|
||||||
pdfGenerating,
|
pdfGenerating,
|
||||||
|
printing,
|
||||||
onBack,
|
onBack,
|
||||||
onExportPdf,
|
onExportPdf,
|
||||||
|
onPrint,
|
||||||
|
printDisabled,
|
||||||
|
printDisabledReason = null,
|
||||||
}) => {
|
}) => {
|
||||||
const [mobileModule, setMobileModule] = useState<MobileExportModule>('data');
|
const [mobileModule, setMobileModule] = useState<MobileExportModule>('data');
|
||||||
|
|
||||||
@@ -262,6 +273,17 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{mobileModule === 'print' && (
|
||||||
|
<div className="ps-panel-body p-3 min-h-0">
|
||||||
|
<PrintModule
|
||||||
|
variant="mobile"
|
||||||
|
disabled={printDisabled}
|
||||||
|
disabledReason={printDisabledReason}
|
||||||
|
printing={printing}
|
||||||
|
onPrint={onPrint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -227,293 +227,293 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
|||||||
const autoLayoutDialog =
|
const autoLayoutDialog =
|
||||||
autoLayoutOpen && typeof document !== 'undefined'
|
autoLayoutOpen && typeof document !== 'undefined'
|
||||||
? createPortal(
|
? createPortal(
|
||||||
|
<div
|
||||||
|
className="ps-modal-overlay"
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="ps-modal-overlay"
|
className="ps-modal"
|
||||||
role="presentation"
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="auto-layout-dialog-title"
|
||||||
>
|
>
|
||||||
<div
|
<div className="ps-modal-header">
|
||||||
className="ps-modal"
|
<h3 id="auto-layout-dialog-title" className="ps-modal-title">
|
||||||
onClick={(e) => e.stopPropagation()}
|
自动排版
|
||||||
role="dialog"
|
</h3>
|
||||||
aria-modal="true"
|
<button
|
||||||
aria-labelledby="auto-layout-dialog-title"
|
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">
|
<div className="ps-modal-body space-y-3">
|
||||||
<h3 id="auto-layout-dialog-title" className="ps-modal-title">
|
<p className="text-[11px] text-[#888] leading-relaxed">
|
||||||
自动排版
|
将根据当前纸张与标签尺寸,在不低于最小页边距的前提下,自动计算左右、上下对称边距并居中排版。
|
||||||
</h3>
|
</p>
|
||||||
<button
|
<div>
|
||||||
type="button"
|
<label htmlFor="auto-layout-min-margin" className="ps-field-label">
|
||||||
onClick={closeAutoLayoutDialog}
|
最小页边距 (mm)
|
||||||
className="ps-modal-close"
|
</label>
|
||||||
aria-label="关闭"
|
<input
|
||||||
>
|
id="auto-layout-min-margin"
|
||||||
<X className="w-3.5 h-3.5" />
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form
|
</form>
|
||||||
onSubmit={(e) => {
|
</div>
|
||||||
e.preventDefault();
|
</div>,
|
||||||
void confirmAutoLayout();
|
document.body
|
||||||
}}
|
)
|
||||||
>
|
|
||||||
<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
|
|
||||||
)
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
onBlur={isMobile ? undefined : handlePanelBlur}
|
onBlur={isMobile ? undefined : handlePanelBlur}
|
||||||
className={`space-y-4 text-xs ${isPs || isMobile ? '' : 'bg-white border border-gray-200 rounded-2xl p-6 shadow-sm'
|
className={`space-y-4 text-xs ${isPs || isMobile ? '' : 'bg-white border border-gray-200 rounded-2xl p-6 shadow-sm'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{!isPs && !isMobile && (
|
{!isPs && !isMobile && (
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className="text-sm font-bold text-gray-900 leading-none">排版</h3>
|
<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>
|
</div>
|
||||||
|
)}
|
||||||
|
{isPs && (
|
||||||
|
<p className="text-[10px] text-[#666] leading-relaxed">
|
||||||
|
排版参数修改后将自动更新预览。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{draftPaper.type === 'custom' && (
|
<div className={isPs || isMobile ? 'space-y-4' : 'grid grid-cols-1 md:grid-cols-4 gap-6'}>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="space-y-2">
|
||||||
<div>
|
<h4 className={sectionTitleClass}>纸张</h4>
|
||||||
<label className={labelClass}>宽度 (mm)</label>
|
<div>
|
||||||
<PageInput
|
<label className={labelClass}>纸张类型</label>
|
||||||
type="number"
|
<FieldSelect
|
||||||
value={draftPaper.width}
|
value={draftPaper.type}
|
||||||
onCommit={(val) => patchDraftPaper({ width: Math.max(10, Number(val) || 10) })}
|
onChange={(e) => handlePaperTypeChange(e.target.value as PaperType)}
|
||||||
inputClassName={fieldClass}
|
className={selectClass}
|
||||||
/>
|
>
|
||||||
</div>
|
<option value="A4">A4 (210×297 mm)</option>
|
||||||
<div>
|
<option value="A5">A5 (148×210 mm)</option>
|
||||||
<label className={labelClass}>高度 (mm)</label>
|
<option value="A6">A6 (105×148 mm)</option>
|
||||||
<PageInput
|
<option value="custom">自定义尺寸</option>
|
||||||
type="number"
|
</FieldSelect>
|
||||||
value={draftPaper.height}
|
|
||||||
onCommit={(val) => patchDraftPaper({ height: Math.max(10, Number(val) || 10) })}
|
|
||||||
inputClassName={fieldClass}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
{draftPaper.type === 'custom' && (
|
||||||
<label className={labelClass}>进纸方向</label>
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{isPs ? (
|
<div>
|
||||||
<div className="ps-toggle-group">
|
<label className={labelClass}>宽度 (mm)</label>
|
||||||
<button
|
<PageInput
|
||||||
type="button"
|
type="number"
|
||||||
onClick={() => patchDraftPaper({ orientation: 'portrait' })}
|
value={draftPaper.width}
|
||||||
className={`ps-toggle-btn flex-1 text-[10px] ${draftPaper.orientation === 'portrait' ? 'active' : ''}`}
|
onCommit={(val) => patchDraftPaper({ width: Math.max(10, Number(val) || 10) })}
|
||||||
>
|
inputClassName={fieldClass}
|
||||||
纵向
|
/>
|
||||||
</button>
|
</div>
|
||||||
<button
|
<div>
|
||||||
type="button"
|
<label className={labelClass}>高度 (mm)</label>
|
||||||
onClick={() => patchDraftPaper({ orientation: 'landscape' })}
|
<PageInput
|
||||||
className={`ps-toggle-btn flex-1 text-[10px] ${draftPaper.orientation === 'landscape' ? 'active' : ''}`}
|
type="number"
|
||||||
>
|
value={draftPaper.height}
|
||||||
横向
|
onCommit={(val) => patchDraftPaper({ height: Math.max(10, Number(val) || 10) })}
|
||||||
</button>
|
inputClassName={fieldClass}
|
||||||
</div>
|
/>
|
||||||
) : isMobile ? (
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
<h4 className={sectionTitleClass}>页边距 (mm)</h4>
|
<label className={labelClass}>进纸方向</label>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
{isPs ? (
|
||||||
{(
|
<div className="ps-toggle-group">
|
||||||
[
|
<button
|
||||||
['上边距', 'marginTop', draftPaper.marginTop],
|
type="button"
|
||||||
['下边距', 'marginBottom', draftPaper.marginBottom],
|
onClick={() => patchDraftPaper({ orientation: 'portrait' })}
|
||||||
['左边距', 'marginLeft', draftPaper.marginLeft],
|
className={`ps-toggle-btn flex-1 text-[10px] ${draftPaper.orientation === 'portrait' ? 'active' : ''}`}
|
||||||
['右边距', 'marginRight', draftPaper.marginRight],
|
>
|
||||||
] as const
|
纵向
|
||||||
).map(([label, key, val]) => (
|
</button>
|
||||||
<div key={key}>
|
<button
|
||||||
<label className={labelClass}>{label}</label>
|
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
|
<PageInput
|
||||||
type="number"
|
type="number"
|
||||||
|
step="0.5"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="50"
|
||||||
value={val}
|
value={draftPaper.columnGap}
|
||||||
onCommit={(v) => patchDraftPaper({ [key]: Number(v) || 0 })}
|
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}
|
inputClassName={fieldClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>纵向</label>
|
<label className={labelClass}>填充顺序</label>
|
||||||
<PageInput
|
<FieldSelect
|
||||||
type="number"
|
value={draftPaper.flow}
|
||||||
step="0.5"
|
onChange={(e) => patchDraftPaper({ flow: e.target.value as PaperConfig['flow'] })}
|
||||||
min="0"
|
className={selectClass}
|
||||||
max="50"
|
>
|
||||||
value={draftPaper.rowGap}
|
<option value="left-right-top-bottom">左右 → 上下(行优先)</option>
|
||||||
onCommit={(val) => patchDraftPaper({ rowGap: Number(val) || 0 })}
|
<option value="top-bottom-left-right">上下 → 左右(列优先)</option>
|
||||||
inputClassName={fieldClass}
|
</FieldSelect>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
{autoLayoutDialog}
|
||||||
{autoLayoutDialog}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -576,6 +576,8 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
|
|
||||||
const currentPaperW = paper.orientation === 'portrait' ? paper.width : paper.height;
|
const currentPaperW = paper.orientation === 'portrait' ? paper.width : paper.height;
|
||||||
const currentPaperH = paper.orientation === 'portrait' ? paper.height : paper.width;
|
const currentPaperH = paper.orientation === 'portrait' ? paper.height : paper.width;
|
||||||
|
const pageCardWidthPx = Math.ceil(currentPaperW * previewScale);
|
||||||
|
const previewPageGapPx = isMobileVariant ? 16 : 32;
|
||||||
const colGapPx = Math.max(0, paper.columnGap * previewScale);
|
const colGapPx = Math.max(0, paper.columnGap * previewScale);
|
||||||
const rowGapPx = Math.max(0, paper.rowGap * previewScale);
|
const rowGapPx = Math.max(0, paper.rowGap * previewScale);
|
||||||
const labelWPx = template.width * previewScale;
|
const labelWPx = template.width * previewScale;
|
||||||
@@ -665,13 +667,6 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPreviewScale(2.0)}
|
|
||||||
className={`ps-preview-preset-btn ${previewScale === 2.0 ? 'active' : ''}`}
|
|
||||||
>
|
|
||||||
适中
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPreviewScale(3.3)}
|
onClick={() => setPreviewScale(3.3)}
|
||||||
@@ -684,7 +679,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowAllPages((v) => !v)}
|
onClick={() => setShowAllPages((v) => !v)}
|
||||||
className="ps-btn text-[10px]"
|
className={`ps-preview-preset-btn`}
|
||||||
>
|
>
|
||||||
{showAllPages ? '收起预览' : `展开全部 ${totalPages} 页`}
|
{showAllPages ? '收起预览' : `展开全部 ${totalPages} 页`}
|
||||||
</button>
|
</button>
|
||||||
@@ -693,9 +688,8 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex-1 overflow-auto ps-workspace-bg min-h-0 ps-preview-workspace ${
|
className={`flex-1 overflow-auto ps-workspace-bg min-h-0 ps-preview-workspace ${isMobileVariant ? 'p-3 mobile-preview-workspace' : 'p-6'
|
||||||
isMobileVariant ? 'p-3 mobile-preview-workspace' : 'p-6'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{!previewReady ? (
|
{!previewReady ? (
|
||||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 gap-2">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col items-center ${
|
className={
|
||||||
isMobileVariant ? 'space-y-4 pb-4' : 'space-y-8 pb-8'
|
isMobileVariant
|
||||||
}`}
|
? 'flex flex-col items-center space-y-4 pb-4'
|
||||||
|
: 'grid pb-8 justify-items-center content-start w-full'
|
||||||
|
}
|
||||||
|
style={
|
||||||
|
isMobileVariant
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
gridTemplateColumns: `repeat(auto-fill, minmax(${pageCardWidthPx}px, 1fr))`,
|
||||||
|
gap: `${previewPageGapPx}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{visiblePages.map((pageRows, pageIdx) => (
|
{visiblePages.map((pageRows, pageIdx) => (
|
||||||
<div
|
<div
|
||||||
key={pageIdx}
|
key={pageIdx}
|
||||||
className="bg-white rounded shadow-2xl overflow-hidden relative border border-[#222] flex flex-col shrink-0"
|
className="bg-white rounded shadow-2xl overflow-hidden relative border border-[#222] flex flex-col shrink-0 max-w-full"
|
||||||
style={{
|
style={{
|
||||||
width: `${currentPaperW * previewScale}px`,
|
width: `${pageCardWidthPx}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="bg-[#f0f0f0] border-b border-gray-200 py-1.5 px-3 flex justify-between items-center text-[10px] font-semibold text-gray-500 select-none shrink-0">
|
<div className="bg-[#f0f0f0] border-b border-gray-200 py-1.5 px-3 flex justify-between items-center text-[10px] font-semibold text-gray-500 select-none shrink-0">
|
||||||
<span>第 {pageIdx + 1} 页 / 共 {totalPages} 页</span>
|
<span>第 {pageIdx + 1} 页 / 共 {totalPages} 页</span>
|
||||||
<span>
|
{/* <span>
|
||||||
{currentPaperW} × {currentPaperH} mm · {paper.orientation === 'portrait' ? '纵向' : '横向'}
|
{currentPaperW} × {currentPaperH} mm · {paper.orientation === 'portrait' ? '纵向' : '横向'}
|
||||||
</span>
|
</span> */}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="bg-white flex items-start justify-start relative select-none overflow-hidden shrink-0"
|
className="bg-white flex items-start justify-start relative select-none overflow-hidden shrink-0"
|
||||||
style={{
|
style={{
|
||||||
width: `${currentPaperW * previewScale}px`,
|
width: `${pageCardWidthPx}px`,
|
||||||
height: `${currentPaperH * previewScale}px`,
|
height: `${currentPaperH * previewScale}px`,
|
||||||
padding: `${paper.marginTop * previewScale}px ${paper.marginRight * previewScale}px ${paper.marginBottom * previewScale}px ${paper.marginLeft * previewScale}px`,
|
padding: `${paper.marginTop * previewScale}px ${paper.marginRight * previewScale}px ${paper.marginBottom * previewScale}px ${paper.marginLeft * previewScale}px`,
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
|
|||||||
159
src/components/PrintModule.tsx
Normal file
159
src/components/PrintModule.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Loader2, Printer, RefreshCw } from 'lucide-react';
|
||||||
|
import { FieldSelect } from './PsSelect';
|
||||||
|
import { CommitInput } from './CommitInput';
|
||||||
|
import {
|
||||||
|
addCustomPrinter,
|
||||||
|
DEFAULT_PRINTER_ID,
|
||||||
|
getAvailablePrinters,
|
||||||
|
loadSelectedPrinterId,
|
||||||
|
saveSelectedPrinterId,
|
||||||
|
supportsDirectPrinting,
|
||||||
|
type PrinterOption,
|
||||||
|
} from '../labelPrint';
|
||||||
|
|
||||||
|
interface PrintModuleProps {
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason?: string | null;
|
||||||
|
printing: boolean;
|
||||||
|
onPrint: (printerId: string) => void;
|
||||||
|
variant?: 'desktop' | 'mobile';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrintModule: React.FC<PrintModuleProps> = ({
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
printing,
|
||||||
|
onPrint,
|
||||||
|
variant = 'desktop',
|
||||||
|
}) => {
|
||||||
|
const [printers, setPrinters] = useState<PrinterOption[]>([
|
||||||
|
{ id: DEFAULT_PRINTER_ID, name: '系统默认(打印对话框)' },
|
||||||
|
]);
|
||||||
|
const [selectedPrinterId, setSelectedPrinterId] = useState(loadSelectedPrinterId);
|
||||||
|
const [loadingPrinters, setLoadingPrinters] = useState(false);
|
||||||
|
const [customPrinterName, setCustomPrinterName] = useState('');
|
||||||
|
|
||||||
|
const refreshPrinters = useCallback(async () => {
|
||||||
|
setLoadingPrinters(true);
|
||||||
|
try {
|
||||||
|
const list = await getAvailablePrinters();
|
||||||
|
setPrinters(list);
|
||||||
|
const saved = loadSelectedPrinterId();
|
||||||
|
if (list.some((p) => p.id === saved)) {
|
||||||
|
setSelectedPrinterId(saved);
|
||||||
|
} else {
|
||||||
|
setSelectedPrinterId(DEFAULT_PRINTER_ID);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingPrinters(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshPrinters();
|
||||||
|
}, [refreshPrinters]);
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
saveSelectedPrinterId(selectedPrinterId);
|
||||||
|
onPrint(selectedPrinterId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCustomPrinter = () => {
|
||||||
|
const trimmed = customPrinterName.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const added = addCustomPrinter(trimmed);
|
||||||
|
setCustomPrinterName('');
|
||||||
|
void refreshPrinters().then(() => {
|
||||||
|
setSelectedPrinterId(added.id);
|
||||||
|
saveSelectedPrinterId(added.id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const directAvailable = supportsDirectPrinting(printers);
|
||||||
|
const selectedPrinter = printers.find((p) => p.id === selectedPrinterId);
|
||||||
|
const usesDirect = !!selectedPrinter?.direct;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<label className="ps-field-label mb-0">打印机</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refreshPrinters()}
|
||||||
|
disabled={loadingPrinters}
|
||||||
|
className="inline-flex items-center gap-1 text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3 h-3 ${loadingPrinters ? 'animate-spin' : ''}`} />
|
||||||
|
刷新列表
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<FieldSelect
|
||||||
|
value={selectedPrinterId}
|
||||||
|
onChange={(e) => setSelectedPrinterId(e.target.value)}
|
||||||
|
className="ps-field text-[11px]"
|
||||||
|
disabled={loadingPrinters || printing}
|
||||||
|
>
|
||||||
|
{printers.map((printer) => (
|
||||||
|
<option key={printer.id} value={printer.id}>
|
||||||
|
{printer.name}
|
||||||
|
{printer.direct ? ' · 直连' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</FieldSelect>
|
||||||
|
|
||||||
|
{!directAvailable && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="ps-field-label">添加常用打印机名称</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<CommitInput
|
||||||
|
value={customPrinterName}
|
||||||
|
onCommit={setCustomPrinterName}
|
||||||
|
placeholder="与系统中打印机名称一致"
|
||||||
|
className="ps-field text-[11px] flex-1 min-w-0"
|
||||||
|
disabled={printing}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddCustomPrinter}
|
||||||
|
disabled={!customPrinterName.trim() || printing}
|
||||||
|
className="ps-btn text-[10px] shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
添加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[10px] text-[#666] leading-relaxed">
|
||||||
|
{usesDirect
|
||||||
|
? '将直接提交到所选打印机。'
|
||||||
|
: directAvailable
|
||||||
|
? '所选打印机将通过系统打印对话框确认后打印。'
|
||||||
|
: '当前浏览器不支持枚举打印机;打印时将打开系统对话框,请在对话框中选择对应打印机。已保存的名称便于下次快速识别。'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{disabledReason && disabled && (
|
||||||
|
<p className="text-[10px] text-amber-500/90">{disabledReason}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePrint}
|
||||||
|
disabled={disabled || printing || loadingPrinters}
|
||||||
|
className={`w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold transition cursor-pointer ${
|
||||||
|
!disabled && !printing && !loadingPrinters
|
||||||
|
? 'bg-[#31a8ff] hover:bg-[#2890d8] text-white'
|
||||||
|
: 'bg-[#444] text-[#666] cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{printing ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Printer className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
{printing ? '准备打印…' : variant === 'mobile' ? '打印' : '开始打印'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
updateTableRowHeight,
|
updateTableRowHeight,
|
||||||
getTableColWidths,
|
getTableColWidths,
|
||||||
getTableRowHeights,
|
getTableRowHeights,
|
||||||
|
getCellRawContent,
|
||||||
type TableCellCoord,
|
type TableCellCoord,
|
||||||
} from '../tableUtils';
|
} from '../tableUtils';
|
||||||
import { Table2, Merge, SplitSquareHorizontal } from 'lucide-react';
|
import { Table2, Merge, SplitSquareHorizontal } from 'lucide-react';
|
||||||
|
|||||||
224
src/labelPrint.ts
Normal file
224
src/labelPrint.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
const SELECTED_PRINTER_KEY = 'label-designer.selected-printer-id';
|
||||||
|
const CUSTOM_PRINTERS_KEY = 'label-designer.custom-printers';
|
||||||
|
|
||||||
|
export const DEFAULT_PRINTER_ID = '__system_default__';
|
||||||
|
|
||||||
|
export interface PrinterOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** 是否来自 Web Printing API,可直接提交打印任务 */
|
||||||
|
direct?: boolean;
|
||||||
|
native?: WebPrintingNativePrinter;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebPrintingNativePrinter {
|
||||||
|
printerId?: string;
|
||||||
|
printerName: string;
|
||||||
|
submitPrintJob(jobName: string, documentData: Blob, templateAttributes?: unknown): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavigatorWithPrinting extends Navigator {
|
||||||
|
printing?: {
|
||||||
|
getPrinters(): Promise<WebPrintingNativePrinter[]>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCustomPrinters(): PrinterOption[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CUSTOM_PRINTERS_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed
|
||||||
|
.filter((item): item is { id: string; name: string } => {
|
||||||
|
return (
|
||||||
|
!!item &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
typeof (item as { id?: string }).id === 'string' &&
|
||||||
|
typeof (item as { name?: string }).name === 'string'
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((item) => ({ id: item.id, name: item.name }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCustomPrinters(printers: PrinterOption[]) {
|
||||||
|
const custom = printers.filter((p) => p.id !== DEFAULT_PRINTER_ID && !p.direct);
|
||||||
|
localStorage.setItem(CUSTOM_PRINTERS_KEY, JSON.stringify(custom));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSelectedPrinterId(): string {
|
||||||
|
return localStorage.getItem(SELECTED_PRINTER_KEY) || DEFAULT_PRINTER_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSelectedPrinterId(printerId: string) {
|
||||||
|
localStorage.setItem(SELECTED_PRINTER_KEY, printerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAvailablePrinters(): Promise<PrinterOption[]> {
|
||||||
|
const options: PrinterOption[] = [
|
||||||
|
{ id: DEFAULT_PRINTER_ID, name: '系统默认(打印对话框)' },
|
||||||
|
];
|
||||||
|
const seen = new Set<string>([DEFAULT_PRINTER_ID]);
|
||||||
|
|
||||||
|
const printing = (navigator as NavigatorWithPrinting).printing;
|
||||||
|
if (printing?.getPrinters) {
|
||||||
|
try {
|
||||||
|
const nativePrinters = await printing.getPrinters();
|
||||||
|
for (const printer of nativePrinters) {
|
||||||
|
const id = printer.printerId || printer.printerName;
|
||||||
|
if (!id || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
options.push({
|
||||||
|
id,
|
||||||
|
name: printer.printerName,
|
||||||
|
direct: true,
|
||||||
|
native: printer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('getPrinters failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const custom of loadCustomPrinters()) {
|
||||||
|
if (seen.has(custom.id)) continue;
|
||||||
|
seen.add(custom.id);
|
||||||
|
options.push(custom);
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addCustomPrinter(name: string): PrinterOption {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
const id = `custom:${trimmed}`;
|
||||||
|
const next: PrinterOption = { id, name: trimmed };
|
||||||
|
const existing = loadCustomPrinters().filter((p) => p.id !== id);
|
||||||
|
saveCustomPrinters([...existing, next]);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrintSurface {
|
||||||
|
win: Window;
|
||||||
|
dispose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPdfPrintSurface(url: string): PrintSurface | null {
|
||||||
|
const popup = window.open(url, '_blank');
|
||||||
|
if (popup) {
|
||||||
|
return {
|
||||||
|
win: popup,
|
||||||
|
dispose: () => {
|
||||||
|
try {
|
||||||
|
popup.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.setAttribute('title', 'label-print');
|
||||||
|
Object.assign(iframe.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
top: '0',
|
||||||
|
left: '0',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
border: 'none',
|
||||||
|
opacity: '0',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: '2147483646',
|
||||||
|
});
|
||||||
|
iframe.src = url;
|
||||||
|
document.body.appendChild(iframe);
|
||||||
|
|
||||||
|
const iframeWin = iframe.contentWindow;
|
||||||
|
if (!iframeWin) {
|
||||||
|
iframe.remove();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
win: iframeWin,
|
||||||
|
dispose: () => iframe.remove(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePdfPrint(surface: PrintSurface, onDone: () => void) {
|
||||||
|
let finished = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
onDone();
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = () => {
|
||||||
|
try {
|
||||||
|
surface.win.focus();
|
||||||
|
surface.win.print();
|
||||||
|
finish();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('print() failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
surface.win.addEventListener('load', () => window.setTimeout(trigger, 800));
|
||||||
|
window.setTimeout(trigger, 1500);
|
||||||
|
window.setTimeout(trigger, 2800);
|
||||||
|
window.setTimeout(finish, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printPdfBlob(blob: Blob): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!blob || blob.size === 0) {
|
||||||
|
reject(new Error('Empty PDF'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const surface = createPdfPrintSurface(url);
|
||||||
|
if (!surface) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error('无法打开打印窗口,请允许弹出窗口后重试'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
const cleanup = () => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
surface.dispose();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, 60_000);
|
||||||
|
};
|
||||||
|
|
||||||
|
schedulePdfPrint(surface, () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitLabelsPrint(
|
||||||
|
pdfBlob: Blob,
|
||||||
|
printerId: string,
|
||||||
|
printers: PrinterOption[]
|
||||||
|
): Promise<'direct' | 'dialog'> {
|
||||||
|
const printer = printers.find((p) => p.id === printerId);
|
||||||
|
if (printer?.direct && printer.native) {
|
||||||
|
await printer.native.submitPrintJob('labels', pdfBlob);
|
||||||
|
return 'direct';
|
||||||
|
}
|
||||||
|
await printPdfBlob(pdfBlob);
|
||||||
|
return 'dialog';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsDirectPrinting(printers: PrinterOption[]): boolean {
|
||||||
|
return printers.some((p) => p.direct);
|
||||||
|
}
|
||||||
101
src/pdfExport.ts
101
src/pdfExport.ts
@@ -205,3 +205,104 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
|
|||||||
await yieldToMain();
|
await yieldToMain();
|
||||||
pdf.save(filename);
|
pdf.save(filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 与 exportLabelsPdfAsync 相同渲染流程,返回 PDF Blob(用于浏览器打印) */
|
||||||
|
export async function exportLabelsPdfBlobAsync(
|
||||||
|
options: Omit<AsyncExportLabelsPdfOptions, 'filename'>
|
||||||
|
): Promise<Blob> {
|
||||||
|
const {
|
||||||
|
paper,
|
||||||
|
template,
|
||||||
|
printablePages,
|
||||||
|
gridCols,
|
||||||
|
cutLine,
|
||||||
|
imageFormat,
|
||||||
|
renderLabel,
|
||||||
|
onProgress,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const paperW = paper.orientation === 'portrait' ? paper.width : paper.height;
|
||||||
|
const paperH = paper.orientation === 'portrait' ? paper.height : paper.width;
|
||||||
|
const orientation = paperW > paperH ? 'landscape' : 'portrait';
|
||||||
|
const labelW = template.width;
|
||||||
|
const labelH = template.height;
|
||||||
|
const totalPages = printablePages.length;
|
||||||
|
|
||||||
|
const totalLabels = printablePages.reduce(
|
||||||
|
(sum, page) => sum + page.filter((item) => item !== null).length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const pdf = new jsPDF({
|
||||||
|
orientation,
|
||||||
|
unit: 'mm',
|
||||||
|
format: [paperW, paperH],
|
||||||
|
compress: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let done = 0;
|
||||||
|
let progressTick = 0;
|
||||||
|
|
||||||
|
const report = (page: number, phase: PdfExportProgress['phase']) => {
|
||||||
|
onProgress?.({ done, total: totalLabels, page, totalPages, phase });
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let pIdx = 0; pIdx < printablePages.length; pIdx++) {
|
||||||
|
if (pIdx > 0) {
|
||||||
|
pdf.addPage([paperW, paperH], orientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageRows = printablePages[pIdx];
|
||||||
|
const gridRows = Math.ceil(pageRows.length / gridCols);
|
||||||
|
const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine);
|
||||||
|
report(pIdx + 1, 'render');
|
||||||
|
|
||||||
|
const pageJobs: { cellIdx: number; label: PrintableLabel }[] = [];
|
||||||
|
for (let cellIdx = 0; cellIdx < pageRows.length; cellIdx++) {
|
||||||
|
const item = pageRows[cellIdx];
|
||||||
|
if (!item) continue;
|
||||||
|
pageJobs.push({ cellIdx, label: item });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < pageJobs.length; i += PAGE_RENDER_CONCURRENCY) {
|
||||||
|
const batch = pageJobs.slice(i, i + PAGE_RENDER_CONCURRENCY);
|
||||||
|
const rendered = await Promise.all(
|
||||||
|
batch.map(async (job) => ({
|
||||||
|
...job,
|
||||||
|
dataUrl: await renderLabel(job.label),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const { cellIdx, dataUrl } of rendered) {
|
||||||
|
const col = cellIdx % gridCols;
|
||||||
|
const row = Math.floor(cellIdx / gridCols);
|
||||||
|
const x = paper.marginLeft + col * (labelW + paper.columnGap) + cutChannel.x;
|
||||||
|
const y = paper.marginTop + row * (labelH + paper.rowGap) + cutChannel.y;
|
||||||
|
const drawW = labelW - 2 * cutChannel.x;
|
||||||
|
const drawH = labelH - 2 * cutChannel.y;
|
||||||
|
|
||||||
|
pdf.addImage(dataUrl, imageFormat, x, y, drawW, drawH, undefined, 'FAST');
|
||||||
|
|
||||||
|
done++;
|
||||||
|
}
|
||||||
|
|
||||||
|
progressTick++;
|
||||||
|
if (progressTick >= 2) {
|
||||||
|
progressTick = 0;
|
||||||
|
report(pIdx + 1, 'render');
|
||||||
|
await yieldToMain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cutLine.enabled && pageRows.length > 0) {
|
||||||
|
drawPageGridCutLines(pdf, paper, labelW, labelH, gridCols, gridRows, cutLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
report(pIdx + 1, 'render');
|
||||||
|
await yieldToMain();
|
||||||
|
}
|
||||||
|
|
||||||
|
report(totalPages, 'save');
|
||||||
|
await yieldToMain();
|
||||||
|
return pdf.output('blob');
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user