Files
klp-oa/klp-ui/src/views/wms/coil/panels/tool.vue
砂糖 1e0cb96650 fix(wms): 修复标签打印容器选择器和PDF导出空白问题
重构标签容器选择器从ID改为class选择器,解决打印功能失效问题
优化PDF导出逻辑,动态设置页面尺寸并裁剪空白区域,彻底解决多页导出时的空白页问题
统一重量和长度输入框为el-input-number组件,提升表单交互体验
2026-01-09 13:26:52 +08:00

633 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div style="display: flex; gap: 20px; padding: 20px; width: 100%; box-sizing: border-box;">
<!-- 左侧原有导出功能区域 -->
<div style="flex: 1; max-width: 700px;">
<!-- 导出配置区域 -->
<div class="config-container"
style="margin-bottom: 20px; padding: 16px; border: 1px solid #e6e6e6; border-radius: 8px;">
<div style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">导出配置</div>
<!-- 清晰度配置 -->
<div class="config-item" style="margin-bottom: 10px; display: flex; align-items: center;">
<label style="width: 120px; font-size: 14px; color: #333;">导出清晰度</label>
<input v-model.number="config.renderScale" type="number" min="1.0" max="4.0" step="0.1" style="width: 120px;"
placeholder="如1.5/2.0/3.0" @blur="validateRenderScale" />
<span style="margin-left: 8px; font-size: 12px; color: #666;">
范围1.0-4.0值越高越清晰导出越慢
</span>
</div>
<!-- 单次导出数量配置 -->
<div class="config-item" style="display: flex; align-items: center;">
<label style="width: 120px; font-size: 14px; color: #333;">单次导出数量</label>
<input v-model.number="config.pageSize" type="number" min="1" max="200" style="width: 120px;"
placeholder="如10/50/100" @blur="validatePageSize" @change="replanTask" />
<span style="margin-left: 8px; font-size: 12px; color: #666;">
范围1-200建议不超过100避免卡顿
</span>
</div>
</div>
<!-- 导出按钮批量导出所有未完成任务 -->
<button @click="handleExportAll" :disabled="exportLoading || taskPlan.totalTask === 0 || hasRunningTask"
style="padding: 8px 16px; cursor: pointer; margin-bottom: 20px;">
{{ exportLoading ? '导出中...' : '批量导出所有未完成任务' }}
</button>
<!-- 单批导出进度条 -->
<div v-if="showProgress" class="progress-container" style="margin-bottom: 20px; width: 100%; max-width: 600px;">
<div class="progress-text" style="margin-bottom: 8px; font-size: 14px; color: #666;">
当前批次进度{{ currentIndex }}/{{ totalCount }} ({{ progress }}%)
</div>
<div class="progress-bar-bg"
style="width: 100%; height: 8px; background: #f5f5f5; border-radius: 4px; overflow: hidden;">
<div class="progress-bar" style="height: 100%; background: #409eff; transition: width 0.3s ease;"
:style="{ width: `${progress}%` }"></div>
</div>
</div>
<!-- 预览组件 -->
<OuterTagPreview ref="outerTagPreview" :content="current" />
<!-- 临时渲染容器已修复解决留白核心样式 -->
<div ref="tempTagContainer"
style="position: fixed; top: -9999px; left: -9999px; z-index: -9999; width: 794px; padding: 10px; box-sizing: border-box; overflow: hidden !important;">
</div>
</div>
<!-- 右侧任务规划+进度展示区域 -->
<div style="flex: 0 0 400px; padding: 16px; border: 1px solid #e6e6e6; border-radius: 8px; height: fit-content;">
<!-- 任务概览 -->
<div style="font-size: 16px; font-weight: 600; margin-bottom: 16px;">
任务规划总数量{{ taskPlan.totalCount }}
</div>
<!-- 任务统计 -->
<div style="display: flex; gap: 16px; margin-bottom: 20px; font-size: 14px;">
<div>
<div style="color: #666; margin-bottom: 4px;">总任务数</div>
<div style="font-size: 20px; font-weight: 600; color: #333;">{{ taskPlan.totalTask }}</div>
</div>
<div>
<div style="color: #666; margin-bottom: 4px;">已完成</div>
<div style="font-size: 20px; font-weight: 600; color: #67c23a;">{{ taskPlan.completedTask }}</div>
</div>
<div>
<div style="color: #666; margin-bottom: 4px;">进行中</div>
<div style="font-size: 20px; font-weight: 600; color: #409eff;">{{ taskPlan.runningTask }}</div>
</div>
</div>
<!-- 总进度条 -->
<div style="margin-bottom: 20px;">
<div style="display: flex; justify-content: space-between; font-size: 12px; color: #666; margin-bottom: 8px;">
<span>整体导出进度</span>
<span>{{ taskPlan.totalProgress }}%</span>
</div>
<div style="width: 100%; height: 8px; background: #f5f5f5; border-radius: 4px; overflow: hidden;">
<div style="height: 100%; background: #67c23a; transition: width 0.3s ease;"
:style="{ width: `${taskPlan.totalProgress}%` }"></div>
</div>
</div>
<!-- 当前任务详情 -->
<div style="margin-bottom: 20px; padding: 12px; background: #f0f9ff; border-radius: 6px;">
<div style="font-size: 14px; font-weight: 600; color: #333; margin-bottom: 8px;">当前任务详情</div>
<div v-if="currentTask.idx > 0" style="font-size: 13px; line-height: 1.6;">
<div>任务序号{{ currentTask.idx }}/{{ taskPlan.totalTask }}</div>
<div>处理范围{{ currentTask.startNum }} - {{ currentTask.endNum }} </div>
<div>当前进度{{ currentTask.progress }}%</div>
<div>状态<span :style="{ color: currentTask.statusColor }">{{ currentTask.statusText }}</span></div>
</div>
<div v-else style="font-size: 13px; color: #666;">暂无进行中的任务</div>
</div>
<!-- 任务列表 -->
<div style="max-height: 400px; overflow-y: auto;">
<div v-for="(task, idx) in taskList" :key="idx"
style="padding: 10px; border: 1px solid #e6e6e6; border-radius: 6px; margin-bottom: 8px;" :style="{
borderColor: task.status === 'running' ? '#409eff' : task.status === 'completed' ? '#67c23a' : '#e6e6e6',
background: task.status === 'running' ? '#f0f9ff' : 'transparent'
}">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 14px; font-weight: 500;">任务 {{ idx + 1 }}</span>
<span :style="{ color: task.statusColor }">{{ task.statusText }}</span>
</div>
<div style="font-size: 12px; color: #666; margin-bottom: 6px;">
处理范围{{ task.startNum }} - {{ task.endNum }}
</div>
<div
style="width: 100%; height: 6px; background: #f5f5f5; border-radius: 3px; overflow: hidden; margin-bottom: 8px;">
<div style="height: 100%; transition: width 0.3s ease;" :style="{
width: `${task.progress}%`,
background: task.status === 'running' ? '#409eff' : task.status === 'completed' ? '#67c23a' : '#ccc'
}"></div>
</div>
<!-- 单个任务执行按钮 -->
<button @click="handleRunSingleTask(task.idx)"
:disabled="exportLoading || task.status === 'running' || task.status === 'completed'"
style="padding: 4px 8px; font-size: 12px; border: none; border-radius: 4px; cursor: pointer; background: #409eff; color: white;">
{{ task.status === 'failed' ? '重新执行' : '执行任务' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import OuterTagPreview from './LabelRender/OuterTagPreview.vue';
import { listMaterialCoil } from "@/api/wms/coil";
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
import { Message } from 'element-ui';
export default {
name: 'ToolPanel',
components: {
OuterTagPreview
},
data() {
return {
list: [], // 当前批次数据
current: {}, // 当前预览数据
exportLoading: false, // 全局导出加载状态
showProgress: false, // 单批进度条显示
progress: 0, // 单批进度百分比
currentIndex: 0, // 单批当前处理索引
totalCount: 0, // 当前批次总数
// 导出配置
config: {
renderScale: 1.2, // 清晰度
pageSize: 30 // 单次导出数量
},
// 任务规划数据
taskPlan: {
totalCount: 0, // 总数据量接口返回的res.total
totalTask: 0, // 总任务数Math.ceil(totalCount/pageSize)
completedTask: 0, // 已完成任务数
runningTask: 0, // 进行中任务数
totalProgress: 0 // 整体进度百分比
},
taskList: [], // 任务列表(每个任务的详情)
currentTask: { // 当前任务详情
idx: 0, // 任务序号
startNum: 0, // 起始条数
endNum: 0, // 结束条数
progress: 0, // 当前任务进度
status: 'pending',// 状态pending/running/completed/failed
statusText: '未开始',
statusColor: '#909399'
},
pdfDoc: null, // 全局PDF实例支持追加内容
}
},
computed: {
// 是否有任务正在执行
hasRunningTask() {
return this.taskPlan.runningTask > 0;
}
},
mounted() {
this.fetchTotalData(); // 先获取总数量,规划任务
},
methods: {
// ========== 配置校验 ==========
validateRenderScale() {
let scale = this.config.renderScale;
if (!scale || isNaN(scale)) {
this.config.renderScale = 1.5;
Message.warning('清晰度请输入1.0-4.0之间的数字已重置为默认值1.5');
return;
}
if (scale < 1.0) this.config.renderScale = 1.0;
else if (scale > 4.0) this.config.renderScale = 4.0;
},
validatePageSize() {
let size = this.config.pageSize;
if (!size || isNaN(size) || size < 1) {
this.config.pageSize = 10;
Message.warning('每页数量请输入1-200之间的正整数已重置为默认值10');
return;
}
if (size > 200) this.config.pageSize = 200;
},
// ========== 任务规划核心逻辑 ==========
async fetchTotalData() {
try {
const res = await listMaterialCoil({
status: 0,
dataType: 1,
materialType: '成品',
itemType: 'product',
selectType: 'raw_material',
pageSize: 1, // 仅获取总数量pageSize设为1
pageNum: 1
});
// 初始化任务规划
this.taskPlan.totalCount = res.total || 0;
this.taskPlan.totalTask = Math.ceil(this.taskPlan.totalCount / this.config.pageSize);
this.initTaskList(); // 初始化任务列表
} catch (err) {
console.error('获取总数据量失败:', err);
Message.error('获取数据总量失败,请刷新重试');
}
},
initTaskList() {
this.taskList = [];
for (let i = 0; i < this.taskPlan.totalTask; i++) {
const startNum = i * this.config.pageSize + 1;
const endNum = Math.min((i + 1) * this.config.pageSize, this.taskPlan.totalCount);
this.taskList.push({
idx: i + 1, // 任务序号
startNum, // 起始条数
endNum, // 结束条数
progress: 0, // 任务进度
status: 'pending', // 状态pending/running/completed/failed
statusText: '未开始',
statusColor: '#909399'
});
}
this.taskPlan.completedTask = 0;
this.taskPlan.runningTask = 0;
this.taskPlan.totalProgress = 0;
this.currentTask = { idx: 0, startNum: 0, endNum: 0, progress: 0, status: 'pending', statusText: '未开始', statusColor: '#909399' };
this.pdfDoc = null;
},
replanTask() {
this.validatePageSize();
this.taskPlan.totalTask = Math.ceil(this.taskPlan.totalCount / this.config.pageSize);
this.initTaskList();
Message.success(`已重新规划任务!单次导出${this.config.pageSize}条,总计${this.taskPlan.totalTask}个任务`);
},
updateTaskStatus(taskIdx, status, progress = 0) {
const task = this.taskList[taskIdx - 1];
if (!task) return;
task.status = status;
task.progress = progress;
switch (status) {
case 'running':
task.statusText = '进行中';
task.statusColor = '#409eff';
this.currentTask = { ...task };
break;
case 'completed':
task.statusText = '已完成';
task.statusColor = '#67c23a';
break;
case 'failed':
task.statusText = '失败';
task.statusColor = '#f56c6c';
break;
default:
task.statusText = '未开始';
task.statusColor = '#909399';
}
this.taskPlan.completedTask = this.taskList.filter(t => t.status === 'completed').length;
this.taskPlan.runningTask = this.taskList.filter(t => t.status === 'running').length;
const totalCompleted = this.taskList.reduce((sum, t) => sum + (t.progress / 100) * (t.endNum - t.startNum + 1), 0);
this.taskPlan.totalProgress = Math.round((totalCompleted / this.taskPlan.totalCount) * 100);
},
// ========== 核心导出逻辑 ==========
async handleExportAll() {
if (this.exportLoading || this.hasRunningTask) return;
if (this.taskPlan.totalCount === 0) {
Message.warning('暂无外标签数据可导出');
return;
}
try {
this.exportLoading = true;
// 初始化PDF 动态尺寸【核心修改】
this.pdfDoc = new jsPDF({
unit: 'mm',
orientation: 'portrait',
format: [0, 0]
});
const uncompletedTasks = this.taskList.filter(t => t.status !== 'completed');
if (uncompletedTasks.length === 0) {
Message.info('所有任务均已完成,无需导出');
this.exportLoading = false;
return;
}
for (const task of uncompletedTasks) {
await this.runSingleTaskCore(task.idx);
}
const fileName = `外标签批量导出_${new Date().toLocaleString().replace(/[/: ]/g, '-')}.pdf`;
this.pdfDoc.save(fileName);
Message.success(`PDF批量导出成功共完成${uncompletedTasks.length}个任务,总计${this.taskPlan.totalCount}条数据`);
} catch (err) {
console.error('批量导出失败:', err);
Message.error('批量导出失败,请查看控制台');
} finally {
this.exportLoading = false;
this.showProgress = false;
this.progress = 0;
this.currentIndex = 0;
}
},
async handleRunSingleTask(taskIdx) {
if (this.exportLoading || this.hasRunningTask) {
Message.warning('有任务正在执行中,请等待完成后再操作');
return;
}
try {
this.exportLoading = true;
// 初始化PDF 动态尺寸【核心修改】
if (!this.pdfDoc) {
this.pdfDoc = new jsPDF({
unit: 'mm',
orientation: 'portrait',
format: [0, 0]
});
}
await this.runSingleTaskCore(taskIdx);
const fileName = `外标签任务${taskIdx}_导出_${new Date().toLocaleString().replace(/[/: ]/g, '-')}.pdf`;
this.pdfDoc.save(fileName);
Message.success(`任务${taskIdx}导出成功!处理范围:${this.taskList[taskIdx - 1].startNum}-${this.taskList[taskIdx - 1].endNum}`);
} catch (err) {
console.error(`任务${taskIdx}执行失败:`, err);
Message.error(`任务${taskIdx}执行失败,请查看控制台`);
this.updateTaskStatus(taskIdx, 'failed');
} finally {
this.exportLoading = false;
this.showProgress = false;
this.progress = 0;
this.currentIndex = 0;
}
},
// 单个任务执行核心逻辑【修复后:彻底解决多页尺寸异常】
async runSingleTaskCore(taskIdx) {
this.updateTaskStatus(taskIdx, 'running', 0);
const pageNum = taskIdx;
const res = await listMaterialCoil({
status: 0,
dataType: 1,
materialType: '成品',
itemType: 'product',
selectType: 'raw_material',
pageSize: this.config.pageSize,
pageNum
});
this.list = res.rows || [];
this.totalCount = this.list.length;
if (this.totalCount === 0) {
this.updateTaskStatus(taskIdx, 'completed', 100);
return;
}
this.showProgress = true;
this.currentIndex = 0;
this.progress = 0;
for (let index = 0; index < this.list.length; index++) {
this.current = this.list[index];
await this.waitForDOMRender();
const previewComponent = this.$refs.outerTagPreview;
await this.waitForAllResources(previewComponent.$el);
// 克隆DOM到临时容器
const tempContainer = this.$refs.tempTagContainer;
const renderedDOM = previewComponent.$el.cloneNode(true);
tempContainer.innerHTML = '';
tempContainer.appendChild(renderedDOM);
// 修复克隆DOM丢失样式问题
const labelDom = tempContainer.querySelector('.label-container');
if (labelDom) {
labelDom.style.width = '100%';
labelDom.style.maxWidth = '100%';
labelDom.style.boxSizing = 'border-box';
labelDom.style.overflow = 'hidden';
}
await this.waitForDOMRender();
this.copyCanvasContent(previewComponent.$el, tempContainer);
if (!tempContainer.firstChild) {
console.warn(`任务${taskIdx}${index + 1}个标签DOM为空`, this.list[index]);
this.updateProgress(index + 1);
continue;
}
// 生成高清Canvas
const canvas = await html2canvas(tempContainer, {
scale: this.config.renderScale,
backgroundColor: '#ffffff',
useCORS: true,
width: tempContainer.offsetWidth,
height: tempContainer.offsetHeight,
windowWidth: tempContainer.offsetWidth * this.config.renderScale,
windowHeight: tempContainer.offsetHeight * this.config.renderScale,
logging: false,
allowTaint: true,
taintTest: false,
scrollX: 0,
scrollY: 0,
letterRendering: true,
imageTimeout: 10000
});
// 裁剪画布极细空白
const cropCanvas = this.cropCanvasWhiteSpace(canvas);
// 计算当前标签的实际尺寸(每次都重新计算,避免缓存错误)
const canvasWidth = cropCanvas.width / this.config.renderScale;
const canvasHeight = cropCanvas.height / this.config.renderScale;
const imgWidthMM = canvasWidth * (25.4 / 96);
const imgHeightMM = canvasHeight * (25.4 / 96);
const currentPageFormat = [imgWidthMM, imgHeightMM];
// ========== 修复核心正确设置PDF页面尺寸 ==========
const totalPdfPages = this.pdfDoc.internal.getNumberOfPages();
// 情况1PDF是“初始空白页”仅1页且尺寸为0→ 修改初始页尺寸
if (totalPdfPages === 1 && this.pdfDoc.internal.pageSize.getWidth() === 0) {
this.pdfDoc.setPage(1);
this.pdfDoc.internal.pageSize.setWidth(imgWidthMM);
this.pdfDoc.internal.pageSize.setHeight(imgHeightMM);
}
// 情况2其他所有情况→ 新增页面并使用当前标签的尺寸
else {
this.pdfDoc.addPage(currentPageFormat, 'portrait');
}
// 图片0边距填充当前页面
const currentPageNum = this.pdfDoc.internal.getNumberOfPages();
this.pdfDoc.setPage(currentPageNum); // 切换到当前页面
this.pdfDoc.internal.pageSize.setWidth(imgWidthMM);
this.pdfDoc.internal.pageSize.setHeight(imgHeightMM);
this.pdfDoc.addImage(cropCanvas.toDataURL('image/png', 1.0), 'PNG', 0, 0, imgWidthMM, imgHeightMM);
// 更新进度
this.updateProgress(index + 1);
this.updateTaskStatus(taskIdx, 'running', this.progress);
}
this.updateTaskStatus(taskIdx, 'completed', 100);
},
// ========== 新增+保留 所有辅助方法 ==========
cropCanvasWhiteSpace(canvas) {
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
let left = width, top = height, right = 0, bottom = 0;
let pixelIndex, alpha;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
pixelIndex = (y * width + x) * 4;
alpha = data[pixelIndex + 3];
if (alpha > 0) {
left = Math.min(left, x);
top = Math.min(top, y);
right = Math.max(right, x);
bottom = Math.max(bottom, y);
}
}
}
const cropWidth = right - left + 1;
const cropHeight = bottom - top + 1;
if (cropWidth <= 0 || cropHeight <= 0) return canvas;
const newCanvas = document.createElement('canvas');
newCanvas.width = cropWidth;
newCanvas.height = cropHeight;
const newCtx = newCanvas.getContext('2d');
newCtx.drawImage(canvas, left, top, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight);
return newCanvas;
},
async waitForAllResources(element) {
const images = element.querySelectorAll('img');
const imgPromises = Array.from(images).map(img =>
img.complete ? Promise.resolve() : new Promise(resolve => {
img.onload = resolve;
img.onerror = resolve;
})
);
await Promise.all(imgPromises);
await document.fonts.ready;
await this.waitForQRCodeRender(element);
await new Promise(resolve => setTimeout(resolve, 300));
},
async waitForQRCodeRender(element) {
const qrCanvasList = element.querySelectorAll('canvas');
if (qrCanvasList.length === 0) return;
const qrLoadPromises = Array.from(qrCanvasList).map(canvas => {
return new Promise(resolve => {
const checkInterval = setInterval(() => {
const ctx = canvas.getContext('2d');
if (!ctx) {
clearInterval(checkInterval);
resolve();
return;
}
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const hasContent = imageData.data.some(value => value !== 0);
if (hasContent || Date.now() - (ctx.startTime || 0) > 2000) {
clearInterval(checkInterval);
resolve();
}
}, 100);
});
});
await Promise.all(qrLoadPromises);
},
copyCanvasContent(sourceEl, targetEl) {
const sourceCanvases = sourceEl.querySelectorAll('canvas');
const targetCanvases = targetEl.querySelectorAll('canvas');
sourceCanvases.forEach((canvas, index) => {
const targetCanvas = targetCanvases[index];
if (!targetCanvas) return;
targetCanvas.width = canvas.width;
targetCanvas.height = canvas.height;
const targetCtx = targetCanvas.getContext('2d');
targetCtx.drawImage(canvas, 0, 0);
});
},
updateProgress(current) {
this.currentIndex = current;
this.progress = Math.round((current / this.totalCount) * 100);
},
waitForDOMRender(time = 300) {
return new Promise(resolve => setTimeout(resolve, time));
}
}
}
</script>
<style scoped>
:deep(.label-container) {
width: 100%;
height: auto;
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-smooth: always;
}
:deep(canvas) {
display: block;
visibility: visible;
opacity: 1;
}
.config-container {
input {
padding: 6px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
outline: none;
}
input:focus {
border-color: #409eff;
}
}
button {
border: 1px solid #409eff;
background: #409eff;
color: white;
border-radius: 4px;
transition: all 0.3s;
}
button:disabled {
background: #a0cfff;
cursor: not-allowed;
}
</style>