Files
KCGL/inventory-web/src/views/outbound/Selection.vue

1018 lines
37 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 class="app-container">
<el-card shadow="always" class="no-print-content">
<template #header>
<div class="card-header">
<div class="header-left">
<span class="title">出库拣货车</span>
<span class="subtitle">(请添加需要出库的物品)</span>
</div>
<div>
<!-- 批量模式 -->
<template v-if="isBulkMode">
<el-button @click="cancelBulkMode">
取消
</el-button>
<el-button type="danger" :disabled="selectedRows.length === 0" @click="batchRemove">
移除选中 ({{ selectedRows.length }})
</el-button>
</template>
<!-- 普通模式 -->
<template v-else>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="warning" plain :disabled="selectedItems.length === 0" @click="isBulkMode = true">
批量操作
</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="danger" :disabled="selectedItems.length === 0" @click="clearAll">
清空列表
</el-button>
<el-divider direction="vertical" />
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" :icon="Plus" @click="openManualSelect">
手动添加库存
</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="warning" :icon="List" @click="openBomSelect">
BOM 套餐添加
</el-button>
</template>
<el-divider direction="vertical" />
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="success" :icon="Printer" :disabled="selectedItems.length === 0" @click="handlePreview">
生成预览 & 打印
</el-button>
</div>
</div>
</template>
<el-alert
v-if="selectedItems.length === 0"
title="清单为空,请点击右上角按钮添加物品"
type="info"
center
show-icon
style="margin-bottom: 20px"
:closable="false"
/>
<el-table
ref="tableRef"
v-else
:data="sortedSelectedItems"
border
style="width: 100%"
row-key="uniqueKey"
@selection-change="handleSelectionChange"
@row-click="handleBulkRowClick"
:row-class-name="getRowClassName"
>
<el-table-column v-if="isBulkMode" type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="50" align="center" />
<el-table-column label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getTypeTag(row.type)">{{ row.typeLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="standard" label="规格型号" min-width="150" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="150" show-overflow-tooltip>
<template #default="{ row }">
<span style="color: #409EFF; font-weight: bold;">{{ row.warehouse_location || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="available_quantity" label="当前库存" width="120" align="right">
<template #default="{ row }">
<span style="color: green; font-weight: bold;">{{ row.available_quantity }}</span>
</template>
</el-table-column>
<el-table-column label="本次出库数" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-input-number
v-model="row.export_quantity"
:min="1"
:max="row.available_quantity"
size="small"
style="width: 100%"
controls-position="right"
:disabled="!userStore.hasPermission('outbound_selection:operation')"
@change="(val) => handleMainQuantityChange(val, row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ $index }">
<el-button v-if="!isBulkMode && userStore.hasPermission('outbound_selection:operation')" type="danger" link @click="removeRow($index)">移除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="selectedItems.length > 0" style="margin-top: 15px; text-align: right; color: #606266; font-size: 14px;">
<span style="color: red; font-weight: bold;">{{ selectedItems.length }}</span> 种物品
合计出库 <span style="color: red; font-weight: bold;">{{ totalExportCount }}</span>
</div>
</el-card>
<el-dialog v-model="manualDialogVisible" title="选择库存物品" width="85%" top="5vh" destroy-on-close :close-on-click-modal="false">
<div class="filter-container">
<el-input
v-model="searchKeyword"
placeholder="请输入物料名称 或 规格型号 进行搜索"
style="width: 300px"
:prefix-icon="Search"
clearable
@input="filterStock"
/>
<span style="margin-left: 15px; color: #909399; font-size: 12px;">
提示点击表格行可勾选<span style="color: #F56C6C; font-weight: bold;">修改数量会自动勾选</span>
</span>
</div>
<el-table
ref="manualTableRef"
:data="stockList"
v-loading="stockLoading"
height="500"
border
row-key="uniqueKey"
@selection-change="handleStockSelection"
@row-click="handleRowClick"
style="cursor: pointer"
>
<el-table-column type="selection" width="55" align="center" :reserve-selection="true" />
<el-table-column label="类型" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="getTypeTag(row.type)">{{ row.typeLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" show-overflow-tooltip />
<el-table-column prop="standard" label="规格" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="120" show-overflow-tooltip />
<el-table-column prop="available_quantity" label="可用库存" width="100" align="right" />
<el-table-column label="本次出库" width="160" align="center" fixed="right">
<template #default="{ row }">
<el-input-number
v-model="row.export_quantity"
:min="1"
:max="row.available_quantity"
size="small"
style="width: 100%"
placeholder="0"
:disabled="!userStore.hasPermission('outbound_selection:operation')"
@click.stop
@change="(val) => handleManualQuantityChange(val, row)"
/>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top: 12px; justify-content: flex-end; display: flex;"
v-model:current-page="stockPage"
v-model:page-size="stockPageSize"
:total="stockTotal"
:page-sizes="[20, 50, 100, 200]"
layout="total, prev, pager, next"
background
@size-change="handleStockSizeChange"
@current-change="handleStockPageChange"
/>
<template #footer>
<span style="float: left; line-height: 32px; color: #909399;">
已勾选 {{ tempSelection.length }}
</span>
<el-button @click="manualDialogVisible = false">取消</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" @click="confirmManualAdd">确认添加</el-button>
</template>
</el-dialog>
<el-dialog v-model="bomSelectVisible" title="按 BOM 套餐添加" width="700px" destroy-on-close :close-on-click-modal="false">
<el-form label-width="100px">
<el-form-item label="选择产品">
<el-select v-model="selectedBomNo" filterable placeholder="请选择启用状态的 BOM 配方" style="width: 100%" @change="() => {}">
<el-option
v-for="b in bomOptions"
:key="`${b.bom_no}_${b.version}`"
:label="`${b.parent_name} - ${b.version}`"
:value="b.bom_no"
/>
</el-select>
</el-form-item>
<el-form-item label="生产套数">
<el-input-number v-model="bomSets" :min="1" label="套" style="width: 200px;" :disabled="!userStore.hasPermission('outbound_selection:operation')" />
<el-tag v-if="selectedBomNo && maxBuildableSets >= 0" type="success" style="margin-left: 16px;">
当前库存最多可成套出库: {{ maxBuildableSets }}
</el-tag>
</el-form-item>
</el-form>
<!-- 齐套性分析警告 -->
<el-alert
v-if="selectedBomNo && shortageList.length > 0 && bomSets > maxBuildableSets"
type="error"
:closable="false"
style="margin: 16px 0;"
>
<template #title>
<span style="font-weight: bold;">库存不足以组成 {{ bomSets }} 缺少以下物料</span>
</template>
<el-table :data="shortageList" size="small" border style="margin-top: 8px; width: 100%;">
<el-table-column prop="name" label="物料名称" min-width="120" />
<el-table-column prop="sku" label="SKU" width="100" />
<el-table-column label="需补足数量" width="100">
<template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold;">{{ row.shortage }}</span>
</template>
</el-table-column>
<el-table-column label="可用库存" width="80">
<template #default="{ row }">
<span>{{ row.available }}</span>
</template>
</el-table-column>
</el-table>
</el-alert>
<div style="margin-left: 100px; color: #909399; font-size: 12px;">
注意系统将自动计算所需原料数量 ( 配方用量 × 套数 )
</div>
<template #footer>
<el-button @click="bomSelectVisible = false">取消</el-button>
<el-button
v-if="userStore.hasPermission('outbound_selection:operation')"
type="primary"
@click="confirmBomAdd"
>
{{ hasShortage ? '仅添加现有库存' : '确认添加' }}
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="previewVisible"
title="出库单核对与打印"
width="800px"
destroy-on-close
class="no-print-content"
>
<div class="print-preview-content">
<el-alert title="请核对以下清单,确认无误后点击【确认打印】" type="info" :closable="false" style="margin-bottom: 10px;" />
<el-table :data="validSelectedItems" border size="small" style="width: 100%">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="typeLabel" label="类型" width="80" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="standard" label="规格" />
<el-table-column prop="warehouse_location" label="库位" width="100" />
<el-table-column prop="export_quantity" label="本次出库" width="120" align="center">
<template #default="{ row }">
<span style="font-weight: bold; color: #F56C6C; font-size: 16px;">{{ row.export_quantity }}</span>
</template>
</el-table-column>
</el-table>
<div class="summary-info" style="margin-top: 20px; text-align: right; font-weight: bold;">
总计出库: <span style="color: red; font-size: 18px;">{{ totalExportCount }}</span>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="previewVisible = false">取消</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="warning" :icon="Download" :loading="exportLoading" @click="confirmExport">
导出 Excel
</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" :icon="Printer" :loading="printLoading" @click="confirmPrint">
确认打印 (A4)
</el-button>
</span>
</template>
</el-dialog>
<div id="print-area">
<div class="print-header">
<h1>IRIS出库拣货确认单</h1>
<div class="print-meta-row">
<span>打印时间: {{ currentTime }}</span>
</div>
<div class="header-line"></div>
</div>
<table class="print-table">
<thead>
<tr>
<th style="width: 50px;">序号</th>
<th>物料名称</th>
<th>规格型号</th>
<th style="width: 80px;">库位</th>
<th style="width: 50px;">单位</th>
<th style="width: 90px;">出库数量</th>
<th style="width: 60px;">备注</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in validSelectedItems" :key="index">
<td style="text-align: center;">{{ index + 1 }}</td>
<td class="cell-padding">{{ item.name }}</td>
<td class="cell-padding">{{ item.standard }}</td>
<td style="text-align: center;">{{ item.warehouse_location || '-' }}</td>
<td style="text-align: center;"></td>
<td style="text-align: center; font-weight: bold; font-size: 16px;">{{ item.export_quantity }}</td>
<td></td>
</tr>
<tr v-if="validSelectedItems.length === 0">
<td colspan="7" style="text-align: center; padding: 20px;">无数据</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="5" style="text-align: right; font-weight: bold; padding-right: 15px;">合计:</td>
<td style="text-align: center; font-weight: bold; font-size: 18px;">{{ totalExportCount }}</td>
<td></td>
</tr>
</tfoot>
</table>
<div class="print-footer">
<div class="signature-item">
<span class="sig-label">库管员签字:</span>
<span class="sig-line"></span>
</div>
<div class="signature-item">
<span class="sig-label">领料人签字:</span>
<span class="sig-line"></span>
</div>
<div class="signature-item">
<span class="sig-label">日期:</span>
<span class="sig-line"></span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Printer, Search, Plus, Download, List } from '@element-plus/icons-vue'
import { ElMessage, ElTable, ElMessageBox } from 'element-plus'
import { getAllStock, getStockList, printSelectionList } from '@/api/inbound/stock'
import { getBomList, getBomDetail } from '@/api/bom'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// --- 状态变量 ---
const selectedItems = ref<any[]>([])
const selectedRows = ref<any[]>([])
const isBulkMode = ref(false)
// 按库位路径自然升序排序(优化拣货路径)
const sortedSelectedItems = computed(() => {
return [...selectedItems.value].sort((a, b) => {
const locA = a.warehouse_location || ''
const locB = b.warehouse_location || ''
return locA.localeCompare(locB, 'zh', { numeric: true })
})
})
const manualDialogVisible = ref(false)
const bomSelectVisible = ref(false)
const previewVisible = ref(false)
const exportLoading = ref(false)
const printLoading = ref(false)
const allStockData = ref<any[]>([])
const stockList = ref<any[]>([]) // 服务端分页数据
const stockTotal = ref(0)
const stockPage = ref(1)
const stockPageSize = ref(20)
const stockLoading = ref(false)
const searchKeyword = ref('')
const tempSelection = ref<any[]>([])
let stockSearchTimer: ReturnType<typeof setTimeout> | null = null
// 表格引用
const manualTableRef = ref<InstanceType<typeof ElTable>>()
const tableRef = ref<InstanceType<typeof ElTable>>()
// BOM 相关
const bomOptions = ref<any[]>([])
const selectedBomNo = ref('')
const bomSets = ref(1)
const currentBomDetail = ref<any[]>([]) // 当前选中的BOM明细
// 打印相关
const currentTime = ref('')
// --- 计算属性 ---
// ★ 优化:按库位排序,空库位排最后
const validSelectedItems = computed(() => {
const filtered = selectedItems.value.filter(item => item.export_quantity > 0)
return [...filtered].sort((a, b) => {
const locA = a.warehouse_location || a.warehouse_loc || '';
const locB = b.warehouse_location || b.warehouse_loc || '';
// 空库位沉底
if (!locA && locB) return 1;
if (locA && !locB) return -1;
if (!locA && !locB) return 0;
// 自然排序(支持 A-10 排在 A-2 后面)
return locA.localeCompare(locB, 'zh-CN', { numeric: true });
});
})
const totalExportCount = computed(() => {
return validSelectedItems.value.reduce((sum, item) => sum + (item.export_quantity || 0), 0)
})
// --- BOM 齐套性分析计算属性 ---
const maxBuildableSets = computed(() => {
if (currentBomDetail.value.length === 0 || allStockData.value.length === 0) return 0
let minSets = Infinity
currentBomDetail.value.forEach((bomItem: any) => {
const dosage = parseFloat(bomItem.dosage) || 0 // 单套需求量
if (dosage <= 0) return
// 匹配库存中的可用数量
const stockItem = allStockData.value.find((s: any) => s.base_id && s.base_id == bomItem.child_id)
const available = stockItem ? (stockItem.availableCount || 0) : 0
const buildable = Math.floor(available / dosage)
if (buildable < minSets) minSets = buildable
})
return minSets === Infinity ? 0 : minSets
})
const shortageList = computed(() => {
if (currentBomDetail.value.length === 0 || allStockData.value.length === 0 || bomSets.value <= 0) return []
const target = bomSets.value
const shortages: any[] = []
currentBomDetail.value.forEach((bomItem: any) => {
const dosage = parseFloat(bomItem.dosage) || 0 // 单套需求量
const totalNeed = dosage * target
const stockItem = allStockData.value.find((s: any) => s.base_id && s.base_id == bomItem.child_id)
const available = stockItem ? (stockItem.availableCount || 0) : 0
const shortage = totalNeed - available
if (shortage > 0) {
shortages.push({
name: bomItem.child_name || bomItem.name || '未知物料',
sku: bomItem.child_sku || bomItem.sku || '-',
need: totalNeed,
available: available,
shortage: shortage
})
}
})
return shortages
})
const hasShortage = computed(() => shortageList.value.length > 0 && bomSets.value > maxBuildableSets.value)
// --- 辅助方法 ---
const getTypeTag = (type: string) => {
switch (type) {
case 'material': return 'info'
case 'semi': return 'warning'
case 'product': return 'success'
default: return ''
}
}
// --- 核心逻辑 0加载全量库存数据BOM 齐套计算依赖此数据) ---
const ensureAllStockLoaded = async () => {
if (allStockData.value.length === 0) {
try {
const res: any = await getAllStock()
const rawMaterials = (res.materials || []).map((i: any) => ({ ...i, type: 'material', typeLabel: '采购件' }))
const rawSemis = (res.semis || []).map((i: any) => ({ ...i, type: 'semi', typeLabel: '半成品' }))
const rawProducts = (res.products || []).map((i: any) => ({ ...i, type: 'product', typeLabel: '成品' }))
const list = [...rawMaterials, ...rawSemis, ...rawProducts]
allStockData.value = list.map((i: any) => ({
...i,
name: i.name || i.material_name || i.product_name || '未知名称',
standard: i.standard || i.spec_model || '',
warehouse_location: i.warehouse_location || i.warehouse_loc || i.full_path || '',
uniqueKey: `${i.type}_${i.id}`,
available_quantity: parseFloat(i.available_quantity) || 0,
availableCount: parseFloat(i.available_quantity) || 0,
export_quantity: 1
}))
} catch (e) {
ElMessage.error('加载全量库存数据失败BOM 功能可能受影响)')
}
}
}
// --- 核心逻辑 1手动添加库存 ---
// 服务端加载库存列表
const loadStockList = async () => {
stockLoading.value = true
try {
const res: any = await getStockList({
page: stockPage.value,
pageSize: stockPageSize.value,
keyword: searchKeyword.value.trim()
})
// 为每个item添加uniqueKey和确保warehouse_location字段正确映射
stockList.value = (res.data?.list || []).map((item: any) => ({
...item,
uniqueKey: `${item.type}_${item.id}`,
warehouse_location: item.warehouse_location || item.warehouse_loc || item.full_path || ''
}))
stockTotal.value = res.data?.total || 0
} catch (e) {
ElMessage.error('加载库存列表失败')
} finally {
stockLoading.value = false
}
}
// 手动选库存弹窗:加载服务端分页数据 + BOM 用全量数据
const openManualSelect = async () => {
manualDialogVisible.value = true
stockPage.value = 1
searchKeyword.value = ''
await loadStockList()
await ensureAllStockLoaded()
allStockData.value.forEach(item => item.export_quantity = 0)
}
// 搜索框防抖触发服务端过滤
const filterStock = () => {
if (stockSearchTimer) clearTimeout(stockSearchTimer)
stockSearchTimer = setTimeout(() => {
stockPage.value = 1
loadStockList()
}, 350)
}
// 分页切换
const handleStockPageChange = (page: number) => {
stockPage.value = page
loadStockList()
}
const handleStockSizeChange = (size: number) => {
stockPageSize.value = size
stockPage.value = 1
loadStockList()
}
const handleStockSelection = (val: any[]) => { tempSelection.value = val }
// 点击行任意位置切换勾选
const handleRowClick = (row: any) => {
if (manualTableRef.value) {
manualTableRef.value.toggleRowSelection(row, undefined)
}
}
// 弹窗内:当数量变化时,自动联动勾选状态
const handleManualQuantityChange = (val: number | undefined, row: any) => {
if (!manualTableRef.value) return
if (val && val > 0) {
manualTableRef.value.toggleRowSelection(row, true)
} else {
manualTableRef.value.toggleRowSelection(row, false)
}
}
const confirmManualAdd = () => {
if (tempSelection.value.length === 0) return ElMessage.warning('请先勾选需要添加的物品')
const newItems = tempSelection.value.filter(item =>
!selectedItems.value.find(existing => existing.uniqueKey === item.uniqueKey)
)
if (newItems.length === 0) {
manualDialogVisible.value = false
return ElMessage.warning('选中的物品已全部在清单中')
}
const itemsToAdd = newItems.map(item => {
const copy = JSON.parse(JSON.stringify(item))
copy.export_quantity = item.export_quantity || 0
return copy
})
selectedItems.value.push(...itemsToAdd)
manualDialogVisible.value = false
tempSelection.value = []
ElMessage.success(`成功添加 ${itemsToAdd.length} 项物品`)
}
// 主界面数量变更逻辑 (降为0时弹窗移除)
const handleMainQuantityChange = (val: number | undefined, row: any) => {
if (val === 0) {
ElMessageBox.confirm(
`物品【${row.name}】数量为0确定要从清单中移除吗`,
'移除确认',
{
confirmButtonText: '移除',
cancelButtonText: '保留 (恢复为1)',
type: 'warning',
}
)
.then(() => {
const index = selectedItems.value.findIndex(item => item.uniqueKey === row.uniqueKey)
if (index > -1) {
selectedItems.value.splice(index, 1)
ElMessage.success('已移除')
}
})
.catch(() => {
row.export_quantity = 1
})
}
}
// --- 核心逻辑 2按 BOM 添加 ---
const openBomSelect = async () => {
bomSelectVisible.value = true
bomSets.value = 1
selectedBomNo.value = ''
currentBomDetail.value = []
try {
const res = await getBomList({ active_only: true })
bomOptions.value = res.data || []
} catch (e) {
ElMessage.error('加载 BOM 列表失败')
}
await ensureAllStockLoaded()
}
// 监听 BOM 选择变化,自动加载明细并计算齐套性
watch(selectedBomNo, async (newBomNo) => {
if (!newBomNo) {
currentBomDetail.value = []
return
}
try {
const detailRes = await getBomDetail(newBomNo)
currentBomDetail.value = detailRes.data?.children || []
} catch (e) {
ElMessage.error('加载 BOM 明细失败')
currentBomDetail.value = []
}
})
const confirmBomAdd = async () => {
if (!selectedBomNo.value) return ElMessage.warning('请选择 BOM')
if (allStockData.value.length === 0) {
await ensureAllStockLoaded()
}
if (currentBomDetail.value.length === 0) {
try {
const detailRes = await getBomDetail(selectedBomNo.value)
currentBomDetail.value = detailRes.data?.children || []
} catch (e) {
ElMessage.error('获取 BOM 详情失败')
return
}
}
const bomRows = currentBomDetail.value
let addedCount = 0
let skippedCount = 0
bomRows.forEach((bomItem: any) => {
const dosage = parseFloat(bomItem.dosage) || 0
const needQty = dosage * bomSets.value
const stockCandidate = allStockData.value.find(s =>
(s.base_id && s.base_id == bomItem.child_id)
)
if (stockCandidate) {
const availableQty = stockCandidate.availableCount || 0
const actualAddQty = Math.min(needQty, availableQty)
if (actualAddQty > 0) {
const existing = selectedItems.value.find(e => e.uniqueKey === stockCandidate.uniqueKey)
if (existing) {
existing.export_quantity += actualAddQty
} else {
const newItem = JSON.parse(JSON.stringify(stockCandidate))
if (bomItem.warehouse_location) {
newItem.warehouse_location = bomItem.warehouse_location
}
newItem.export_quantity = actualAddQty
selectedItems.value.push(newItem)
}
addedCount++
} else {
skippedCount++
}
} else {
skippedCount++
}
})
if (addedCount > 0) {
const tip = skippedCount > 0 ? `(跳过 ${skippedCount} 种缺货物料)` : ''
ElMessage.success(`成功添加 ${addedCount} 类物料${tip}`)
bomSelectVisible.value = false
} else {
ElMessage.warning('该 BOM 所有物料库存均为 0')
}
}
// --- 通用逻辑 ---
const handleSelectionChange = (val: any[]) => {
selectedRows.value = val
if (val.length === 0 && isBulkMode.value) {
isBulkMode.value = false
}
}
const handleBulkRowClick = (row: any) => {
if (isBulkMode.value && tableRef.value) {
tableRef.value.toggleRowSelection(row, undefined)
}
}
const getRowClassName = () => {
return isBulkMode.value ? 'bulk-clickable-row' : ''
}
const cancelBulkMode = () => {
isBulkMode.value = false
selectedRows.value = []
}
const clearAll = () => {
ElMessageBox.confirm(
'确定要清空当前拣货车中的所有物品吗?',
'清空确认',
{
confirmButtonText: '确定清空',
cancelButtonText: '取消',
type: 'warning',
}
).then(() => {
selectedItems.value = []
selectedRows.value = []
isBulkMode.value = false
ElMessage.success('已清空拣货车')
}).catch(() => {})
}
const batchRemove = () => {
if (selectedRows.value.length === 0) return
ElMessageBox.confirm(
`确定要移除选中的 ${selectedRows.value.length} 项物品吗?`,
'批量移除确认',
{
confirmButtonText: '确定移除',
cancelButtonText: '取消',
type: 'warning',
}
).then(() => {
const keysToRemove = new Set(selectedRows.value.map(row => row.uniqueKey))
selectedItems.value = selectedItems.value.filter(item => !keysToRemove.has(item.uniqueKey))
selectedRows.value = []
isBulkMode.value = false
ElMessage.success(`已移除 ${keysToRemove.size} 项物品`)
}).catch(() => {})
}
const removeRow = (index: number) => {
selectedItems.value.splice(index, 1)
}
const handlePreview = () => {
if (validSelectedItems.value.length === 0) {
ElMessage.warning('请先添加物品并填写出库数量')
return
}
const now = new Date();
currentTime.value = `${now.getFullYear()}-${now.getMonth()+1}-${now.getDate()} ${now.getHours()}:${now.getMinutes()}`;
previewVisible.value = true
}
const confirmPrint = async () => {
previewVisible.value = false;
try {
const payload = validSelectedItems.value.map(item => ({
name: item.name, standard: item.standard, quantity: item.export_quantity
}));
printSelectionList(JSON.parse(JSON.stringify(payload))).catch(() => {});
} catch (e) {}
setTimeout(() => {
// 1. 获取要打印的区域 DOM
const printElement = document.getElementById('print-area');
if (!printElement) return;
// 2. 创建并挂载隐藏的 iframe
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed';
iframe.style.right = '0';
iframe.style.bottom = '0';
iframe.style.width = '0';
iframe.style.height = '0';
iframe.style.border = '0';
document.body.appendChild(iframe);
const iframeDoc = iframe.contentWindow?.document || iframe.contentDocument;
if (!iframeDoc) return;
// 3. 安全初始化 iframe 骨架(只写基本结构,不拼接任何业务代码)
iframeDoc.open();
iframeDoc.write('<!DOCTYPE html><html><head><title>出库单打印</title></head><body></body></html>');
iframeDoc.close();
// 4. 【核心修复】安全克隆所有样式节点,彻底告别乱码
const styles = document.querySelectorAll('style, link[rel="stylesheet"]');
styles.forEach(styleNode => {
iframeDoc.head.appendChild(styleNode.cloneNode(true));
});
// 5. 动态追加针对打印的强制分页 CSS
const customStyle = iframeDoc.createElement('style');
customStyle.innerHTML = `
/* 重置基础布局,解除所有高度死锁 */
html, body {
height: auto !important;
min-height: 100% !important;
overflow: visible !important;
background: white !important;
margin: 0;
padding: 0;
}
/* 规范 A4 纸张 */
@page {
size: A4 portrait;
margin: 10mm;
}
/* 确保打印区正常流式显示 */
#print-area {
display: block !important;
position: static !important;
width: 100% !important;
height: auto !important;
}
/* 核心:保护表格不被跨页截断 */
.print-table {
width: 100% !important;
table-layout: auto !important;
border-collapse: collapse;
}
.print-table tr, .print-table td, .print-table th {
page-break-inside: avoid !important;
break-inside: avoid !important;
}
/* 隐藏不需要的全局 UI */
.el-overlay, .el-dialog__wrapper, .no-print-content {
display: none !important;
}
`;
iframeDoc.head.appendChild(customStyle);
// 6. 【核心修复】安全克隆打印区域到 body 中
iframeDoc.body.appendChild(printElement.cloneNode(true));
// 7. 延迟触发打印,等待样式完全渲染
setTimeout(() => {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
// 打印结束后清理 iframe
setTimeout(() => {
document.body.removeChild(iframe);
}, 1000);
}, 500); // 预留 500ms 渲染时间
}, 300);
}
const confirmExport = () => {
if (validSelectedItems.value.length === 0) return;
exportLoading.value = true;
try {
let csvContent = "\uFEFF";
csvContent += "类型,名称,规格型号,库位,本次出库数量\n";
validSelectedItems.value.forEach(item => {
const safeName = (item.name || '').replace(/,/g, ' ');
const safeStd = (item.standard || '').replace(/,/g, ' ');
const safeLoc = (item.warehouse_location || '').replace(/,/g, ' ');
csvContent += `${item.typeLabel},${safeName},${safeStd},${safeLoc},${item.export_quantity}\n`;
});
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
const timestamp = new Date().toISOString().slice(0,19).replace(/[-T:]/g, "");
link.download = `出库拣货单_${timestamp}.csv`;
link.click();
ElMessage.success('导出成功');
previewVisible.value = false;
} catch (err) {
ElMessage.error('导出文件失败');
} finally {
exportLoading.value = false;
}
}
</script>
<style scoped>
/* ================= 屏幕显示样式 ================= */
.card-header { display: flex; justify-content: space-between; align-items: center; }
.header-left .title { font-size: 18px; font-weight: bold; margin-right: 10px; }
.header-left .subtitle { font-size: 12px; color: #909399; }
.filter-container { margin-bottom: 20px; }
.app-container { height: 100vh; overflow: hidden; display: flex; flex-direction: column; padding: 20px; box-sizing: border-box; }
.app-container .el-card { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
::v-deep(.el-card__body) { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* ================= ★★★ 打印专用样式 ★★★ ================= */
/* ================= ★★★ 打印区域排版样式 ★★★ ================= */
#print-area { display: none; }
.print-header { text-align: center; margin-bottom: 20px; }
.print-header h1 { font-size: 24px; margin: 0 0 10px 0; font-weight: bold; color: #000; }
.print-meta-row { display: flex; justify-content: flex-start; font-size: 12px; margin-bottom: 5px; }
.header-line { border-bottom: 2px solid #000; margin-top: 5px; }
.print-table { width: 100%; border-collapse: collapse; margin-bottom: 40px; border: 1px solid #000; }
.print-table th, .print-table td { border: 1px solid #000; padding: 12px 8px; text-align: left; font-size: 14px; color: #000; }
.print-table th { text-align: center; font-weight: bold; }
.cell-padding { padding-left: 10px; }
.print-table tr {
page-break-inside: avoid !important;
break-inside: avoid !important;
}
.print-footer { display: flex; justify-content: space-between; margin-top: 60px; padding: 0 20px; }
.signature-item { display: flex; flex-direction: column; align-items: center; width: 30%; }
.sig-label { font-size: 14px; margin-bottom: 40px; text-align: left; width: 100%; }
.sig-line { border-bottom: 1px solid #000; width: 100%; height: 1px; display: block; }
/* ★★★ 修复预览弹窗中 el-table 打印分页截断问题 ★★★ */
.print-preview-content {
height: auto !important;
max-height: none !important;
overflow: visible !important;
}
.print-preview-content .el-table,
.print-preview-content .el-table__inner-wrapper,
.print-preview-content .el-table__body-wrapper,
.print-preview-content .el-table__body {
height: auto !important;
max-height: none !important;
overflow: visible !important;
}
.print-preview-content .el-scrollbar__wrap {
overflow: visible !important;
}
.print-preview-content .el-table tr {
page-break-inside: avoid !important;
break-inside: avoid !important;
}
.print-preview-content .el-table__body-wrapper is-scrollable-none {
overflow: visible !important;
}
:deep(.bulk-clickable-row) {
cursor: pointer;
}
</style>
<style>
@media print {
@page { margin: 10mm; size: auto; }
/* 1. 保留原始:隐藏系统全局的无关元素 */
body * { visibility: hidden; }
.el-dialog__wrapper, .v-modal, .el-message, .no-print-content { display: none !important; }
/* 2. 【核心修复】:打通 Vue 和 Element 框架的所有父级容器,解除裁剪和定位死锁 */
html, body, #app, .el-container, .el-main, .el-scrollbar__wrap {
position: static !important;
overflow: visible !important;
height: auto !important;
min-height: auto !important;
}
/* 3. 保留原始:让打印区域显形,并用 absolute 顶至左上角,允许自然向下分页 */
#print-area, #print-area * { visibility: visible; }
#print-area {
position: absolute !important;
left: 0 !important;
top: 0 !important;
width: 100% !important;
height: auto !important;
margin: 0 !important;
padding: 0 !important;
background-color: white;
display: block !important;
z-index: 99999;
}
/* 4. 【核心修复】:防止表格行在跨页时被水平拦腰切断 */
.print-table tr, .print-table td, .print-table th {
page-break-inside: avoid !important;
break-inside: avoid !important;
}
}
</style>