ERPTurbo_Admin/packages/app-operation/src/components/Dealer/Module/PreviewCanvas.tsx
shenyifei 45dc1de523 feat(dealer): 优化发货单模板模块样式与打印功能
- 引入 classNames 库统一管理组件样式类名
- 调整模块选中状态与预览模式下的边框样式
- 优化 PackingSpecModule 模块的数据显示逻辑
- 增加 formatCurrency 工具函数格式化金额显示
- 重构 PreviewCanvas 打印预览页面样式与结构
- 移除无效的模块配置项 showBoxType
- 修复全屏事件监听器在不同浏览器的兼容性问题
- 优化打印样式,增加页面尺寸与边距控制
- 调整模块渲染逻辑,提升预览模式下的显示效果
2025-11-08 13:29:52 +08:00

735 lines
18 KiB
TypeScript

import {
CompressOutlined,
DeleteOutlined,
ExpandOutlined,
EyeOutlined,
PrinterOutlined,
ZoomInOutlined,
ZoomOutOutlined,
} from '@ant-design/icons';
import { Button, Card, Empty, message, Popconfirm, Slider } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import {
DragDropContext,
Draggable,
Droppable,
DropResult,
} from 'react-beautiful-dnd';
// 直接导入组件而不是使用 React.lazy
import DealerInfoModule from './DealerInfoModule';
import OtherFeesModule from './OtherFeesModule';
import OtherInfoModule from './OtherInfoModule';
import PackingSpecModule from './PackingSpecModule';
import ShippingInfoModule from './ShippingInfoModule';
import TitleModule from './TitleModule';
import TotalAmountModule from './TotalAmountModule';
import VehicleInfoModule from './VehicleInfoModule';
import WeightInfoModule from './WeightInfoModule';
interface PreviewCanvasProps {
modules: any[];
selectedModule: any;
onModuleSelect: (module: any) => void;
onModuleDelete: (moduleId: string) => void;
onModuleReorder: (newModules: any[]) => void;
onClearAll?: () => void; // 添加清空所有模块的回调函数
}
const PreviewCanvas: React.FC<PreviewCanvasProps> = ({
modules,
selectedModule,
onModuleSelect,
onModuleDelete,
onModuleReorder,
onClearAll, // 接收清空所有模块的回调函数
}) => {
const [isPreviewMode, setIsPreviewMode] = useState(false);
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [isFullscreen, setIsFullscreen] = useState(false);
const canvasRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const fullscreenElementRef = useRef<HTMLDivElement>(null);
const handleDragEnd = (result: DropResult) => {
// 如果没有拖拽到有效位置,则直接返回
if (!result.destination) {
return;
}
const items = Array.from(modules);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
onModuleReorder(items);
};
// 全屏功能
const toggleFullscreen = () => {
if (!fullscreenElementRef.current) return;
if (!isFullscreen) {
// 进入全屏
const element = fullscreenElementRef.current;
if (element.requestFullscreen) {
element.requestFullscreen();
} else if ((element as any).mozRequestFullScreen) {
// Firefox
(element as any).mozRequestFullScreen();
} else if ((element as any).webkitRequestFullscreen) {
// Chrome, Safari and Opera
(element as any).webkitRequestFullscreen();
} else if ((element as any).msRequestFullscreen) {
// IE/Edge
(element as any).msRequestFullscreen();
}
setIsFullscreen(true);
} else {
// 退出全屏
if (document.exitFullscreen) {
document.exitFullscreen();
} else if ((document as any).mozCancelFullScreen) {
// Firefox
(document as any).mozCancelFullScreen();
} else if ((document as any).webkitExitFullscreen) {
// Chrome, Safari and Opera
(document as any).webkitExitFullscreen();
} else if ((document as any).msExitFullscreen) {
// IE/Edge
(document as any).msExitFullscreen();
}
setIsFullscreen(false);
}
};
// 监听全屏状态变化
useEffect(() => {
const handleFullscreenChange = () => {
const isCurrentlyFullscreen =
document.fullscreenElement ||
(document as any).webkitFullscreenElement ||
(document as any).mozFullScreenElement ||
(document as any).msFullscreenElement;
setIsFullscreen(!!isCurrentlyFullscreen);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener(
'webkitfullscreenchange',
handleFullscreenChange,
);
document.removeEventListener(
'mozfullscreenchange',
handleFullscreenChange,
);
document.removeEventListener(
'MSFullscreenChange',
handleFullscreenChange,
);
};
}, []);
// 打印预览功能
const handlePrint = () => {
// 获取要打印的内容
const printContentElement = document.getElementById('preview-canvas');
if (!printContentElement) {
message.error('未能找到打印内容');
return;
}
// 创建一个用于打印的隐藏iframe
const printWindow = window.open('', '_blank');
if (printWindow) {
// 获取预览区域的HTML内容
const contentHTML = printContentElement.innerHTML;
// 构建打印内容
let printContent = `
<html>
<head>
<title>发货单打印预览</title>
<style>
@page {
size: 210mm 297mm;
margin: 0;
padding: 0;
}
* {
outline: none;
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
body {
background-color: #fff;
color: #4d4d4d;
font-size: 14px;
font-style: normal;
box-sizing: border-box;
}
.page-wrap {
width: 210mm;
min-height: 297mm;
margin: 0 auto;
}
.page-content {
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 20mm 10mm 0;
display: flex;
flex-direction: column;
gap: 2mm;
}
@media print {
.print-controls {
display: none !important;
}
body {
padding: 0;
margin: 0;
}
}
.print-module {
margin-bottom: 15px;
text-align: center;
}
.print-controls {
position: fixed;
top: 10px;
right: 10px;
z-index: 9999;
}
.print-button, .close-button {
padding: 8px 16px;
margin-left: 10px;
cursor: pointer;
border: 1px solid #d9d9d9;
border-radius: 4px;
}
.print-button {
background-color: #1890ff;
color: white;
}
.close-button {
background-color: #fff;
color: #000;
}
.preview {
width: 19cm;
div {
height: 0.69cm;
}
}
.table-border {
border: 2px solid #000;
}
.table-border > div {
border-bottom: 1px solid #000;
}
.table-border > div > div {
border-right: 1px solid #000;
}
.table-border > div > div:last-child {
border-right: none;
}
.table-border > div:last-child {
border-bottom: none;
}
.col-span-1 {
grid-column: span 1 / span 1;
}
.col-span-2 {
grid-column: span 2 / span 2;
}
.col-span-3 {
grid-column: span 3 / span 3;
}
.col-span-6 {
grid-column: span 6 / span 6;
}
.col-span-8 {
grid-column: span 8 / span 8;
}
.flex {
display: flex;
}
.grid {
display: grid;
}
.w-full {
width: 100%;
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.grid-cols-7 {
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.grid-cols-8 {
grid-template-columns: repeat(8, minmax(0, 1fr));
}
.items-end {
align-items: flex-end;
}
.justify-center {
justify-content: center;
}
.border-t-0 {
border-top-width: 0px;
}
.border-b {
border-bottom-width: 1px;
}
.border-black {
border-color: #000000;
}
.bg-white {
background-color: #ffffff;
}
.text-2xl {
font-size: 24px;
line-height: 1;
}
.text-base {
font-size: 16px;
line-height: 1;
}
.text-lg {
font-size: 18px;
line-height: 1;
}
.font-bold {
font-weight: bold;
}
</style>
</head>
<body>
<div class="print-controls">
<button class="print-button" onclick="window.print()">打印</button>
<button class="close-button" onclick="window.close()">关闭</button>
</div>
<div class="page-wrap">
<div class="page-content">
${contentHTML}
</div>
</div>
<script>
// 打印前隐藏按钮
window.onbeforeprint = function() {
var controls = document.querySelector('.print-controls');
if (controls) {
controls.style.visibility = 'hidden';
}
};
// 打印后显示按钮
window.onafterprint = function() {
var controls = document.querySelector('.print-controls');
if (controls) {
controls.style.visibility = 'visible';
}
};
// 页面加载完成后自动打印
window.onload = function() {
window.print();
};
</script>
</body>
</html>
`;
// 写入内容到打印窗口
printWindow.document.write(printContent);
printWindow.document.close();
} else {
message.error('无法打开打印预览窗口,请检查浏览器设置');
}
};
// 缩放功能
const handleZoomIn = () => {
setScale((prev) => Math.min(prev + 0.1, 3));
};
const handleZoomOut = () => {
setScale((prev) => Math.max(prev - 0.1, 0.5));
};
const handleResetZoom = () => {
setScale(1);
setPosition({ x: 0, y: 0 });
};
const handleSliderChange = (value: number) => {
setScale(value / 100);
};
// 鼠标滚轮缩放
const handleWheel = (e: React.WheelEvent) => {
if (!isPreviewMode) return;
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setScale((prev) => Math.min(Math.max(prev + delta, 0.5), 3));
};
// 拖动功能
const handleMouseDown = (e: React.MouseEvent) => {
if (!isPreviewMode) return;
setIsDragging(true);
setDragStart({
x: e.clientX - position.x,
y: e.clientY - position.y,
});
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isPreviewMode || !isDragging) return;
setPosition({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
});
};
const handleMouseUp = () => {
setIsDragging(false);
};
// 点击预览区域外取消选中
const handleContainerClick = (e: React.MouseEvent) => {
if (e.target === containerRef.current) {
onModuleSelect(null);
}
};
// 清理拖动状态
useEffect(() => {
if (!isPreviewMode) {
setIsDragging(false);
}
}, [isPreviewMode]);
const renderModule = (module: any, index: number) => {
const props = {
config: module.config,
isSelected: selectedModule?.id === module.id,
onSelect: () => onModuleSelect(module),
onDelete: () => onModuleDelete(module.id),
onMoveUp: () => {
if (index > 0) {
const newModules = [...modules];
const temp = newModules[index];
newModules[index] = newModules[index - 1];
newModules[index - 1] = temp;
onModuleReorder(newModules);
}
},
onMoveDown: () => {
if (index < modules.length - 1) {
const newModules = [...modules];
const temp = newModules[index];
newModules[index] = newModules[index + 1];
newModules[index + 1] = temp;
onModuleReorder(newModules);
}
},
canMoveUp: index > 0,
canMoveDown: index < modules.length - 1,
};
// 创建组件映射对象
const componentMap = {
title: TitleModule,
dealerInfo: DealerInfoModule,
shippingInfo: ShippingInfoModule,
weightInfo: WeightInfoModule,
packingSpec: PackingSpecModule,
vehicleInfo: VehicleInfoModule,
otherFees: OtherFeesModule,
totalAmount: TotalAmountModule,
otherInfo: OtherInfoModule,
};
const Component = componentMap[module.type as keyof typeof componentMap];
if (isPreviewMode) {
return Component ? (
<Component {...props} previewMode={isPreviewMode} />
) : null;
}
return (
<Draggable
key={module.id}
draggableId={module.id}
index={index}
isDragDisabled={isPreviewMode}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{
...provided.draggableProps.style,
marginBottom: '8px',
borderRadius: '4px',
border: snapshot.isDragging ? '1px dashed #1890ff' : 'none',
backgroundColor: snapshot.isDragging ? '#e6f7ff' : 'transparent',
userSelect: 'none', // 防止拖拽时选中文本
}}
>
{Component ? (
<Component {...props} previewMode={isPreviewMode} />
) : null}
</div>
)}
</Draggable>
);
};
// 为 Droppable 组件创建一个不使用 memo 的包装组件
const DroppableWrapper = ({ children }: { children: React.ReactNode }) => (
<Droppable droppableId="modules" isDropDisabled={isPreviewMode}>
{(provided, snapshot) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
style={{
minHeight: '100px',
padding: '8px',
backgroundColor: snapshot.isDraggingOver
? '#f0f8ff'
: 'transparent',
borderRadius: '4px',
transition: 'background-color 0.2s ease',
userSelect: 'none', // 防止拖拽时选中文本
}}
>
{children}
{provided.placeholder}
</div>
)}
</Droppable>
);
return (
<Card
title="模板预览"
size="small"
className="bg-white rounded-lg border border-gray-300"
extra={
modules.length > 0 ? (
<div className="flex gap-2">
{onClearAll && (
<Popconfirm
title="确定要清空所有模块吗?"
onConfirm={onClearAll}
okText="确定"
cancelText="取消"
>
<Button
type="text"
icon={<DeleteOutlined />}
danger
size="small"
>
</Button>
</Popconfirm>
)}
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => setIsPreviewMode(!isPreviewMode)}
size="small"
>
{isPreviewMode ? '退出预览' : '预览'}
</Button>
{isPreviewMode && (
<>
<Button
type="text"
icon={
isFullscreen ? <CompressOutlined /> : <ExpandOutlined />
}
onClick={toggleFullscreen}
size="small"
/>
<Button
type="text"
icon={<ZoomOutOutlined />}
onClick={handleZoomOut}
size="small"
/>
<span style={{ fontSize: '12px', alignSelf: 'center' }}>
{Math.round(scale * 100)}%
</span>
<Button
type="text"
icon={<ZoomInOutlined />}
onClick={handleZoomIn}
size="small"
/>
<Button type="text" onClick={handleResetZoom} size="small">
</Button>
</>
)}
{isPreviewMode && (
<Button
type="text"
icon={<PrinterOutlined />}
onClick={handlePrint}
size="small"
>
</Button>
)}
</div>
) : null
}
bodyStyle={{
maxHeight: 'calc(100vh - 200px)',
overflowY: 'auto',
margin: '0 auto',
position: 'relative',
cursor: isPreviewMode ? (isDragging ? 'grabbing' : 'grab') : 'default',
}}
>
<div
ref={fullscreenElementRef}
className={isFullscreen ? 'fixed inset-0 z-50 bg-white' : ''}
>
{isPreviewMode ? (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '8px',
backgroundColor: '#f5f5f5',
borderRadius: '4px',
marginBottom: '8px',
}}
>
<span style={{ marginRight: '8px', fontSize: '12px' }}>
:
</span>
<Slider
min={50}
max={300}
value={Math.round(scale * 100)}
onChange={handleSliderChange}
style={{ width: '120px', marginRight: '8px' }}
disabled={!isPreviewMode}
/>
<span style={{ fontSize: '12px' }}>
{Math.round(scale * 100)}%
</span>
</div>
<div
ref={containerRef}
style={{
width: '100%',
height: isFullscreen
? 'calc(100vh - 0px)'
: 'calc(100vh - 280px)',
overflow: 'hidden',
position: 'relative',
backgroundColor: isPreviewMode ? '#f0f0f0' : 'transparent',
}}
onClick={handleContainerClick}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
ref={canvasRef}
id={'preview-canvas'}
style={{
transform: `scale(${scale}) translate(${position.x}px, ${position.y}px)`,
transformOrigin: 'center top',
transition: isDragging ? 'none' : 'transform 0.1s ease',
width: '21cm',
padding: '2cm 1cm 0 1cm',
margin: '0 auto',
minHeight: '29.7cm',
backgroundColor: 'white',
boxShadow: isPreviewMode
? '0 0 10px rgba(0,0,0,0.1)'
: 'none',
position: 'relative',
userSelect: 'none', // 防止拖拽时选中文本
}}
>
{modules.map((module, index) => renderModule(module, index))}
</div>
</div>
</>
) : (
<div
ref={containerRef}
style={{
width: '21cm',
margin: '0 auto',
}}
>
<div ref={canvasRef} id={'preview-canvas'}>
{modules.length === 0 ? (
<div className="empty-preview">
<Empty description="从左侧模块库点击添加模块" />
</div>
) : (
<DragDropContext onDragEnd={handleDragEnd}>
<DroppableWrapper>
{modules.map((module, index) => renderModule(module, index))}
</DroppableWrapper>
</DragDropContext>
)}
</div>
</div>
)}
</div>
</Card>
);
};
export default PreviewCanvas;