1193 lines
35 KiB
Vue
Executable File
1193 lines
35 KiB
Vue
Executable File
<template>
|
||
<div class="wh-srf-rdc">
|
||
<div class="wh-srf-rdc-toolbar">
|
||
<span class="wh-srf-rdc-title">已关联物料</span>
|
||
<el-input
|
||
v-model="searchKeyword"
|
||
clearable
|
||
placeholder="搜索名称或编码"
|
||
class="wh-srf-search"
|
||
size="small"
|
||
/>
|
||
<div class="wh-srf-display">
|
||
<span class="wh-srf-display-label">展示</span>
|
||
<el-radio-group v-model="displayMode" size="small" class="wh-srf-display-group">
|
||
<el-radio-button value="tree">树形</el-radio-button>
|
||
<el-radio-button value="flat">平铺</el-radio-button>
|
||
</el-radio-group>
|
||
</div>
|
||
<el-button
|
||
v-if="canUpdate && goodsRows.length"
|
||
type="primary"
|
||
plain
|
||
size="small"
|
||
class="wh-srf-rdc-batch-btn"
|
||
:disabled="!effectiveSelectedRows.length || unlinking"
|
||
@click="batchRegionVisible = true"
|
||
>
|
||
批量设置售卖区域
|
||
</el-button>
|
||
<el-button
|
||
v-if="canUpdate && goodsRows.length"
|
||
type="primary"
|
||
plain
|
||
size="small"
|
||
class="wh-srf-rdc-batch-btn"
|
||
:disabled="!effectiveSelectedRows.length || unlinking"
|
||
@click="openBatchDeployDlg"
|
||
>
|
||
批量关联源采购仓
|
||
</el-button>
|
||
<el-button
|
||
v-if="canUpdate && goodsRows.length"
|
||
type="danger"
|
||
plain
|
||
size="small"
|
||
class="wh-srf-rdc-batch-btn"
|
||
:disabled="!effectiveSelectedRows.length || unlinking"
|
||
:loading="unlinking"
|
||
@click="unlinkRows(effectiveSelectedRows)"
|
||
>
|
||
批量移出 RDC
|
||
</el-button>
|
||
<span v-if="effectiveSelectedRows.length" class="wh-srf-rdc-sel-hint">
|
||
已选 {{ effectiveSelectedRows.length }} 条
|
||
</span>
|
||
<span v-if="warehouseCode && (goodsTotal > 0 || goodsRows.length)" class="wh-srf-rdc-meta">
|
||
{{ listCountLabel }}
|
||
</span>
|
||
</div>
|
||
|
||
<div
|
||
v-loading="goodsLoading || linking || (isTreeMode && categoryLoading)"
|
||
class="wh-srf-rdc-body"
|
||
:class="{ 'is-tree-mode': isTreeMode }"
|
||
:element-loading-text="linking ? '关联中' : 'AI Thinking · RDC 物料'"
|
||
>
|
||
<template v-if="warehouseCode">
|
||
<div v-if="isTreeMode && filteredGoodsRows.length" class="wh-srf-tree-wrap wh-srf-tree-wrap--rdc">
|
||
<div class="wh-srf-tree-head">
|
||
<span class="wh-srf-tree-col wh-srf-tree-col--name wh-srf-tree-head-name">物料名称</span>
|
||
<div
|
||
class="wh-srf-tree-fixed-cols wh-srf-tree-fixed-cols--rdc"
|
||
:class="{ 'has-actions': canUpdate }"
|
||
>
|
||
<span class="wh-srf-tree-col wh-srf-tree-col--lot">管理精度</span>
|
||
<span v-if="canUpdate" class="wh-srf-tree-col wh-srf-tree-col--act">操作</span>
|
||
</div>
|
||
</div>
|
||
<el-tree
|
||
ref="goodsTreeRef"
|
||
class="wh-srf-goods-tree wh-srf-goods-tree--grid wh-srf-goods-tree--rdc"
|
||
:data="treeCheckData"
|
||
node-key="key"
|
||
:indent="0"
|
||
show-checkbox
|
||
check-strictly
|
||
default-expand-all
|
||
expand-on-click-node
|
||
:check-on-click-node="false"
|
||
:check-on-click-leaf="false"
|
||
:props="{ label: 'label', children: 'children', disabled: 'disabled' }"
|
||
@check="onGoodsTreeCheck"
|
||
>
|
||
<template #default="{ node, data }">
|
||
<div
|
||
class="wh-srf-tree-row"
|
||
:class="{
|
||
'is-cat': data.nodeType === 'cat',
|
||
'is-goods': data.nodeType === 'goods',
|
||
'is-active': data.nodeType === 'goods' && isActiveGoodsRow(data.goodsRow),
|
||
}"
|
||
@click="data.nodeType === 'goods' && selectGoodsRow(data.goodsRow)"
|
||
>
|
||
<span
|
||
class="wh-srf-tree-col wh-srf-tree-col--name"
|
||
:style="treeNameIndentStyle(node)"
|
||
>
|
||
<span
|
||
class="wh-srf-tree-name-text"
|
||
:title="data.nodeType === 'goods' ? goodsCategoryHoverTitle(data.goodsRow) : data.label"
|
||
>{{ data.label }}</span>
|
||
<el-tag
|
||
v-if="data.nodeType === 'cat' && data.isFinal"
|
||
size="small"
|
||
type="success"
|
||
effect="plain"
|
||
class="wh-srf-goods-final-tag"
|
||
>末端</el-tag>
|
||
<WhGoodsRelatedPopover
|
||
v-if="data.nodeType === 'goods'"
|
||
:goods-code="data.goodsRow?.goods_code"
|
||
/>
|
||
</span>
|
||
<div
|
||
v-if="data.nodeType === 'goods'"
|
||
class="wh-srf-tree-fixed-cols wh-srf-tree-fixed-cols--rdc"
|
||
:class="{ 'has-actions': canUpdate }"
|
||
>
|
||
<span class="wh-srf-tree-col wh-srf-tree-col--lot">
|
||
<WhSrfGoodsLotTypeCell
|
||
class="wh-srf-lot-select--rdc"
|
||
:model-value="data.goodsRow.log_type"
|
||
:editable="canUpdate"
|
||
:disabled="unlinking"
|
||
:loading="isRowLotTypeSaving(data.goodsRow)"
|
||
:has-code="rowLotTypeHasCode(data.goodsRow)"
|
||
@change="(v) => saveRowLotType(data.goodsRow, v)"
|
||
/>
|
||
</span>
|
||
<span v-if="canUpdate" class="wh-srf-tree-col wh-srf-tree-col--act">
|
||
<el-button
|
||
link
|
||
type="danger"
|
||
size="small"
|
||
:disabled="unlinking"
|
||
@click.stop="unlinkRows([data.goodsRow])"
|
||
>
|
||
移除
|
||
</el-button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-tree>
|
||
</div>
|
||
|
||
<!-- 平铺:与树形同一套行 grid(1fr 名称 + 右侧固定列),不再用 el-table 分列 -->
|
||
<div
|
||
v-else-if="!isTreeMode && filteredGoodsRows.length"
|
||
class="wh-srf-tree-wrap wh-srf-tree-wrap--rdc wh-srf-flat-wrap"
|
||
>
|
||
<div class="wh-srf-flat-head-row">
|
||
<el-checkbox
|
||
v-if="canUpdate"
|
||
class="wh-srf-flat-check-slot"
|
||
:model-value="flatPageAllSelected"
|
||
:indeterminate="flatPageIndeterminate"
|
||
:disabled="unlinking"
|
||
@click.stop
|
||
@change="toggleFlatPageSelection"
|
||
/>
|
||
<div class="wh-srf-tree-head wh-srf-flat-head-grid">
|
||
<span class="wh-srf-tree-col wh-srf-tree-col--name wh-srf-tree-head-name wh-srf-flat-head-name">物料名称</span>
|
||
<div
|
||
class="wh-srf-tree-fixed-cols wh-srf-tree-fixed-cols--rdc"
|
||
:class="{ 'has-actions': canUpdate }"
|
||
>
|
||
<span class="wh-srf-tree-col wh-srf-tree-col--lot">管理精度</span>
|
||
<span v-if="canUpdate" class="wh-srf-tree-col wh-srf-tree-col--act">操作</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="wh-srf-flat-list">
|
||
<div
|
||
v-for="row in filteredGoodsRows"
|
||
:key="row.rowKey || row.linkKey"
|
||
class="wh-srf-flat-row"
|
||
:class="{ 'is-active': isActiveGoodsRow(row) }"
|
||
@click="selectGoodsRow(row)"
|
||
>
|
||
<el-checkbox
|
||
v-if="canUpdate"
|
||
class="wh-srf-flat-check-slot"
|
||
:model-value="isFlatRowSelected(row)"
|
||
:disabled="unlinking"
|
||
@click.stop
|
||
@change="(checked) => toggleFlatRowSelection(row, checked)"
|
||
/>
|
||
<div class="wh-srf-tree-row is-goods">
|
||
<span class="wh-srf-tree-col wh-srf-tree-col--name">
|
||
<span
|
||
class="wh-srf-tree-name-text"
|
||
:title="goodsCategoryHoverTitle(row)"
|
||
>{{ row.name || row.goods_name || row.goods_code || '—' }}</span>
|
||
<WhGoodsRelatedPopover :goods-code="row.goods_code" />
|
||
</span>
|
||
<div
|
||
class="wh-srf-tree-fixed-cols wh-srf-tree-fixed-cols--rdc"
|
||
:class="{ 'has-actions': canUpdate }"
|
||
>
|
||
<span class="wh-srf-tree-col wh-srf-tree-col--lot">
|
||
<WhSrfGoodsLotTypeCell
|
||
class="wh-srf-lot-select--rdc"
|
||
:model-value="row.log_type"
|
||
:editable="canUpdate"
|
||
:disabled="unlinking"
|
||
:loading="isRowLotTypeSaving(row)"
|
||
:has-code="rowLotTypeHasCode(row)"
|
||
@change="(v) => saveRowLotType(row, v)"
|
||
/>
|
||
</span>
|
||
<span v-if="canUpdate" class="wh-srf-tree-col wh-srf-tree-col--act">
|
||
<el-button
|
||
link
|
||
type="danger"
|
||
size="small"
|
||
:disabled="unlinking"
|
||
@click.stop="unlinkRows([row])"
|
||
>
|
||
移除
|
||
</el-button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="!goodsLoading" class="wh-srf-rdc-empty">
|
||
<p>{{ emptyListTitle }}</p>
|
||
<p v-if="emptyListSub" class="wh-srf-rdc-empty-sub">{{ emptyListSub }}</p>
|
||
</div>
|
||
</template>
|
||
<div v-else class="wh-srf-rdc-empty">
|
||
<p>请先选择仓库</p>
|
||
</div>
|
||
</div>
|
||
|
||
<footer v-if="!regionSplitLayout && warehouseCode && goodsTotal > 0 && !isTreeMode" class="wh-srf-rdc-ft">
|
||
<el-pagination
|
||
:current-page="pageCurrent"
|
||
:page-size="pageSize"
|
||
:total="goodsTotal"
|
||
:page-sizes="[20, 50, 100]"
|
||
layout="total, sizes, prev, pager, next"
|
||
size="small"
|
||
background
|
||
:disabled="goodsLoading"
|
||
@current-change="onPageCurrentChange"
|
||
@size-change="onPageSizeChange"
|
||
/>
|
||
</footer>
|
||
|
||
<WhRdcRegionPickDialog
|
||
v-model="batchRegionVisible"
|
||
:goods-rows="effectiveSelectedRows"
|
||
:warehouse-code="warehouseCode"
|
||
:can-update="canUpdate"
|
||
/>
|
||
<WhRdcDeployLinkDialog
|
||
ref="batchDeployDlgRef"
|
||
v-model="batchDeployVisible"
|
||
:link-deploy-anchors="effectiveSelectedRows"
|
||
:warehouse-code="warehouseCode"
|
||
:can-update="canUpdate"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, ref, watch } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import WhGoodsRelatedPopover from './WhGoodsRelatedPopover.vue'
|
||
import {
|
||
warehouseRdcBatchLink,
|
||
warehouseRdcUnlinkGoods,
|
||
fetchWarehouseRdcGoodsPage,
|
||
fetchAllWarehouseRdcGoods,
|
||
pickRdcCodeFromGoodsRow,
|
||
} from '@/api/warehouseRdc'
|
||
import { pickLogTypeFromRow, canSetWarehouseGoodsLotType } from '@/constants/whLotType'
|
||
import { normalizeRdcGoodsRows, warehouseGoodsLinkKey, assignWarehouseGoodsRowKeys, rowSelectKey } from '@/utils/rdcGoodsLink'
|
||
import { useWhSrfGoodsDisplay } from '@/composables/useWhSrfGoodsDisplay'
|
||
import { useWhSrfGoodsLotTypeSave } from '@/composables/useWhSrfGoodsLotTypeSave'
|
||
import WhSrfGoodsLotTypeCell from './WhSrfGoodsLotTypeCell.vue'
|
||
import WhRdcRegionPickDialog from './WhRdcRegionPickDialog.vue'
|
||
import WhRdcDeployLinkDialog from './WhRdcDeployLinkDialog.vue'
|
||
import {
|
||
getWhSrfCheckedGoodsNodes,
|
||
handleWhSrfGoodsTreeCheck,
|
||
} from '@/utils/whSrfGoodsTreeCheck'
|
||
import { formatGoodsCategoryPath, loadCategoryPathLabelMap } from '@/utils/rfSceneGoodsFilter'
|
||
import { filterWhSrfGoodsByKeyword } from '@/utils/whSrfGoodsSearch'
|
||
|
||
/** el-tree indent=0 时,名称列每层缩进像素(与 Element 默认 indent 一致) */
|
||
const WH_SRF_TREE_INDENT = 18
|
||
|
||
const props = defineProps({
|
||
warehouseCode: { type: String, default: '' },
|
||
/** RDC 头部选中的场景;空表示不加载列表,需先点选场景 */
|
||
sceneCode: { type: String, default: '' },
|
||
canUpdate: { type: Boolean, default: false },
|
||
/** 与右侧可售卖区域并排时,分页底栏由外层 split 统一渲染 */
|
||
regionSplitLayout: { type: Boolean, default: false },
|
||
})
|
||
|
||
const emit = defineEmits(['linked-keys-change', 'busy-change', 'active-goods-change', 'split-footer-change'])
|
||
|
||
const goodsLoading = ref(false)
|
||
const linking = ref(false)
|
||
const unlinking = ref(false)
|
||
const batchRegionVisible = ref(false)
|
||
const batchDeployVisible = ref(false)
|
||
const batchDeployDlgRef = ref(null)
|
||
const goodsTotal = ref(0)
|
||
/** @type {import('vue').Ref<object[]>} */
|
||
const goodsRows = ref([])
|
||
/** @type {import('vue').Ref<object[]>} */
|
||
const flatSelectedRows = ref([])
|
||
/** @type {import('vue').Ref<object[]>} */
|
||
const treeSelectedRows = ref([])
|
||
const goodsTreeRef = ref(null)
|
||
const pageCurrent = ref(1)
|
||
const pageSize = ref(20)
|
||
const searchKeyword = ref('')
|
||
/** @type {import('vue').Ref<object|null>} */
|
||
const activeGoodsRow = ref(null)
|
||
/** @type {import('vue').Ref<Map<string, string>>} */
|
||
const categoryPathMap = ref(new Map())
|
||
|
||
const { isRowLotTypeSaving, saveRowLotType } = useWhSrfGoodsLotTypeSave('RDC', () => props.warehouseCode)
|
||
|
||
function rowLotTypeHasCode(row) {
|
||
return canSetWarehouseGoodsLotType(row, props.warehouseCode)
|
||
}
|
||
|
||
const effectiveSelectedRows = computed(() => (
|
||
isTreeMode.value ? treeSelectedRows.value : flatSelectedRows.value
|
||
))
|
||
|
||
|
||
const activeGoodsKey = computed(() => rowSelectKey(activeGoodsRow.value) || undefined)
|
||
|
||
function rowToLinkKey(row) {
|
||
return warehouseGoodsLinkKey(row?.goods_code)
|
||
}
|
||
|
||
/** 树不缩进整行(indent=0),仅名称列按层级缩进,右栏与表头列对齐 */
|
||
function treeNameIndentStyle(node) {
|
||
const level = Math.max(1, Number(node?.level) || 1)
|
||
const pad = (level - 1) * WH_SRF_TREE_INDENT
|
||
if (!pad) return undefined
|
||
return { paddingLeft: `${pad}px` }
|
||
}
|
||
|
||
/** 去掉最顶一级分类名(如工业制成品) */
|
||
function categoryPathWithoutTopLevel(pathStr) {
|
||
const parts = String(pathStr || '')
|
||
.split(/\s*\/\s*/)
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
if (parts.length <= 1) return parts.join('/') || '—'
|
||
return parts.slice(1).join('/')
|
||
}
|
||
|
||
/** 平铺分类列:category/listed 全路径(省略最顶一级),用 / 连接 */
|
||
function goodsCategoryPathFlat(row) {
|
||
const full = formatGoodsCategoryPath(row, categoryPathMap.value)
|
||
if (!full || full === '—') {
|
||
const fallback = String(row?.category_name || row?.category_code || '').trim()
|
||
return fallback || '—'
|
||
}
|
||
return categoryPathWithoutTopLevel(full.replace(/ \/ /g, '/'))
|
||
}
|
||
|
||
/** 分类全路径(含最顶一级),悬停物料名称用 */
|
||
function goodsCategoryPathFull(row) {
|
||
const full = formatGoodsCategoryPath(row, categoryPathMap.value)
|
||
if (!full || full === '—') {
|
||
const fallback = String(row?.category_name || row?.category_code || '').trim()
|
||
return fallback || '—'
|
||
}
|
||
return full.replace(/ \/ /g, '/')
|
||
}
|
||
|
||
/** 悬停物料名称时展示分类路径 */
|
||
function goodsCategoryHoverTitle(row) {
|
||
const path = goodsCategoryPathFull(row)
|
||
if (!path || path === '—') return ''
|
||
return `分类:${path}`
|
||
}
|
||
|
||
const filteredGoodsRows = computed(() => filterWhSrfGoodsByKeyword(
|
||
goodsRows.value,
|
||
searchKeyword.value,
|
||
(row) => goodsCategoryPathFlat(row),
|
||
))
|
||
|
||
const {
|
||
displayMode,
|
||
isTreeMode,
|
||
treeCheckData,
|
||
categoryLoading,
|
||
} = useWhSrfGoodsDisplay(filteredGoodsRows, { omitTopCategoryLevel: true })
|
||
|
||
const listCountLabel = computed(() => {
|
||
const total = goodsTotal.value
|
||
const pageRows = goodsRows.value.length
|
||
const shown = filteredGoodsRows.value.length
|
||
const q = String(searchKeyword.value || '').trim()
|
||
if (q && shown !== pageRows) return `本页 ${shown} / ${pageRows} · 共 ${total} 条`
|
||
return `共 ${total} 条`
|
||
})
|
||
|
||
const emptyListTitle = computed(() => {
|
||
if (!String(props.sceneCode || '').trim()) return '请先选择上方场景'
|
||
if (goodsTotal.value > 0 && String(searchKeyword.value || '').trim()) return '本页无匹配物料'
|
||
if (goodsTotal.value > 0) return '暂无关联物料'
|
||
return '该场景暂无已关联 RDC 物料'
|
||
})
|
||
|
||
const emptyListSub = computed(() => {
|
||
if (!String(props.sceneCode || '').trim()) return '点击场景查看已关联物料,或通过「关联物料」添加'
|
||
if (goodsTotal.value > 0 && String(searchKeyword.value || '').trim()) return '请调整搜索词'
|
||
if (goodsTotal.value > 0) return ''
|
||
return '可点击上方其他场景切换,或通过「关联物料」添加'
|
||
})
|
||
|
||
async function ensureCategoryPathMap() {
|
||
if (categoryPathMap.value.size) return
|
||
categoryPathMap.value = await loadCategoryPathLabelMap()
|
||
}
|
||
|
||
function emitLinkedKeys(keys) {
|
||
emit('linked-keys-change', Array.isArray(keys) ? keys : [])
|
||
}
|
||
|
||
function emitActiveGoods() {
|
||
emit('active-goods-change', activeGoodsRow.value)
|
||
}
|
||
|
||
function rowToUnlinkItem(row) {
|
||
const goods_code = String(row?.goods_code || row?.linkKey || '').trim()
|
||
const warehouse_code = String(
|
||
row?.warehouse_code
|
||
|| pickRdcCodeFromGoodsRow(row, props.warehouseCode)
|
||
|| props.warehouseCode
|
||
|| '',
|
||
).trim()
|
||
return { warehouse_code, goods_code }
|
||
}
|
||
|
||
function buildUnlinkList(rows) {
|
||
return (rows || [])
|
||
.map(rowToUnlinkItem)
|
||
.filter((x) => x.warehouse_code && x.goods_code)
|
||
}
|
||
|
||
function syncTreeSelectedRows() {
|
||
treeSelectedRows.value = getWhSrfCheckedGoodsNodes(goodsTreeRef)
|
||
.map((n) => n.goodsRow)
|
||
.filter(Boolean)
|
||
}
|
||
|
||
function onGoodsTreeCheck(nodeData, payload) {
|
||
handleWhSrfGoodsTreeCheck(goodsTreeRef, nodeData, payload, syncTreeSelectedRows)
|
||
}
|
||
|
||
function isFlatRowSelected(row) {
|
||
const key = rowSelectKey(row)
|
||
if (!key) return false
|
||
return flatSelectedRows.value.some((r) => rowSelectKey(r) === key)
|
||
}
|
||
|
||
const flatPageSelectableRows = computed(() => (
|
||
filteredGoodsRows.value.filter((row) => rowSelectKey(row))
|
||
))
|
||
|
||
const flatPageAllSelected = computed(() => {
|
||
const rows = flatPageSelectableRows.value
|
||
if (!rows.length) return false
|
||
return rows.every((row) => isFlatRowSelected(row))
|
||
})
|
||
|
||
const flatPageIndeterminate = computed(() => {
|
||
const rows = flatPageSelectableRows.value
|
||
if (!rows.length) return false
|
||
const selectedCount = rows.filter((row) => isFlatRowSelected(row)).length
|
||
return selectedCount > 0 && selectedCount < rows.length
|
||
})
|
||
|
||
function toggleFlatPageSelection(checked) {
|
||
const rows = flatPageSelectableRows.value
|
||
if (!rows.length) return
|
||
if (checked) {
|
||
const keys = new Set(flatSelectedRows.value.map((r) => rowSelectKey(r)).filter(Boolean))
|
||
const merged = [...flatSelectedRows.value]
|
||
for (const row of rows) {
|
||
const key = rowSelectKey(row)
|
||
if (key && !keys.has(key)) {
|
||
keys.add(key)
|
||
merged.push(row)
|
||
}
|
||
}
|
||
flatSelectedRows.value = merged
|
||
return
|
||
}
|
||
const pageKeys = new Set(rows.map((r) => rowSelectKey(r)).filter(Boolean))
|
||
flatSelectedRows.value = flatSelectedRows.value.filter((r) => !pageKeys.has(rowSelectKey(r)))
|
||
}
|
||
|
||
function toggleFlatRowSelection(row, checked) {
|
||
const key = rowSelectKey(row)
|
||
if (!key) return
|
||
if (checked) {
|
||
if (!isFlatRowSelected(row)) {
|
||
flatSelectedRows.value = [...flatSelectedRows.value, row]
|
||
}
|
||
return
|
||
}
|
||
flatSelectedRows.value = flatSelectedRows.value.filter((r) => rowSelectKey(r) !== key)
|
||
}
|
||
|
||
function clearAllSelection() {
|
||
flatSelectedRows.value = []
|
||
treeSelectedRows.value = []
|
||
goodsTreeRef.value?.setCheckedKeys([])
|
||
}
|
||
|
||
function isActiveGoodsRow(row) {
|
||
if (!row || !activeGoodsRow.value) return false
|
||
return rowSelectKey(row) === rowSelectKey(activeGoodsRow.value)
|
||
}
|
||
|
||
function selectGoodsRow(row) {
|
||
if (!row?.goods_code && !row?.linkKey) return
|
||
activeGoodsRow.value = row
|
||
emitActiveGoods()
|
||
}
|
||
|
||
function syncActiveGoodsAfterLoad() {
|
||
const key = activeGoodsKey.value
|
||
if (!key) {
|
||
emitActiveGoods()
|
||
return
|
||
}
|
||
const found = goodsRows.value.find((row) => rowSelectKey(row) === key)
|
||
activeGoodsRow.value = found || null
|
||
emitActiveGoods()
|
||
}
|
||
|
||
let loadGoodsSeq = 0
|
||
let loadLinkedKeysSeq = 0
|
||
|
||
function normalizeGoodsRows(rawList) {
|
||
const base = normalizeRdcGoodsRows(rawList)
|
||
.map((row) => ({
|
||
...row,
|
||
log_type: pickLogTypeFromRow(row),
|
||
}))
|
||
return assignWarehouseGoodsRowKeys(base, rowToLinkKey)
|
||
}
|
||
|
||
function applyGoodsPage(list, total) {
|
||
goodsRows.value = normalizeGoodsRows(list)
|
||
const n = Number(total)
|
||
goodsTotal.value = Number.isFinite(n) && n >= 0 ? n : goodsRows.value.length
|
||
syncActiveGoodsAfterLoad()
|
||
}
|
||
|
||
function applyGoodsFromRaw(raw) {
|
||
if (raw && typeof raw === 'object' && !Array.isArray(raw) && ('list' in raw || 'total' in raw)) {
|
||
applyGoodsPage(raw.list, raw.total)
|
||
return
|
||
}
|
||
const rows = normalizeGoodsRows(raw)
|
||
goodsRows.value = rows
|
||
goodsTotal.value = rows.length
|
||
pageCurrent.value = 1
|
||
syncActiveGoodsAfterLoad()
|
||
}
|
||
|
||
async function refreshLinkedKeys() {
|
||
const wh = String(props.warehouseCode || '').trim()
|
||
if (!wh) {
|
||
emitLinkedKeys([])
|
||
return
|
||
}
|
||
const seq = ++loadLinkedKeysSeq
|
||
try {
|
||
const raw = await fetchAllWarehouseRdcGoods({ warehouse_code: wh, scene_code: '' })
|
||
if (seq !== loadLinkedKeysSeq) return
|
||
const keys = normalizeGoodsRows(raw).map(rowToLinkKey).filter(Boolean)
|
||
emitLinkedKeys(keys)
|
||
} catch {
|
||
if (seq === loadLinkedKeysSeq) emitLinkedKeys([])
|
||
}
|
||
}
|
||
|
||
function clearGoodsDisplay() {
|
||
goodsRows.value = []
|
||
goodsTotal.value = 0
|
||
activeGoodsRow.value = null
|
||
emitActiveGoods()
|
||
clearAllSelection()
|
||
}
|
||
|
||
async function loadGoods() {
|
||
const wh = String(props.warehouseCode || '').trim()
|
||
const sc = String(props.sceneCode || '').trim()
|
||
if (!wh) {
|
||
clearGoodsDisplay()
|
||
emitLinkedKeys([])
|
||
return
|
||
}
|
||
if (!sc) {
|
||
clearGoodsDisplay()
|
||
return
|
||
}
|
||
goodsLoading.value = true
|
||
clearAllSelection()
|
||
const seq = ++loadGoodsSeq
|
||
try {
|
||
const [, pageRes] = await Promise.all([
|
||
ensureCategoryPathMap(),
|
||
fetchWarehouseRdcGoodsPage({
|
||
warehouse_code: wh,
|
||
scene_code: String(props.sceneCode || '').trim(),
|
||
page: pageCurrent.value,
|
||
limit: pageSize.value,
|
||
}),
|
||
])
|
||
if (seq !== loadGoodsSeq) return
|
||
applyGoodsPage(pageRes.list, pageRes.total)
|
||
} catch (e) {
|
||
goodsRows.value = []
|
||
goodsTotal.value = 0
|
||
ElMessage.error('加载 RDC 物料失败:' + (e?.data?.message || e?.message || '未知错误'))
|
||
} finally {
|
||
if (seq === loadGoodsSeq) goodsLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onPageCurrentChange(page) {
|
||
pageCurrent.value = Number(page) || 1
|
||
void loadGoods()
|
||
}
|
||
|
||
function onPageSizeChange(size) {
|
||
pageSize.value = Number(size) || 20
|
||
pageCurrent.value = 1
|
||
void loadGoods()
|
||
}
|
||
|
||
async function linkGoods(items) {
|
||
const wh = String(props.warehouseCode || '').trim()
|
||
if (!wh) {
|
||
ElMessage.warning('请先选择仓库')
|
||
return false
|
||
}
|
||
const list = (items || [])
|
||
.map((item) => ({
|
||
goods_code: String(item.goods_code || '').trim(),
|
||
}))
|
||
.filter((x) => x.goods_code)
|
||
if (!list.length) return false
|
||
|
||
linking.value = true
|
||
try {
|
||
await warehouseRdcBatchLink({ warehouse_code: wh, list })
|
||
ElMessage.success(`已关联 ${list.length} 条物料`)
|
||
await Promise.all([loadGoods(), refreshLinkedKeys()])
|
||
return true
|
||
} catch (e) {
|
||
ElMessage.error('关联失败:' + (e?.data?.message || e?.message || '未知错误'))
|
||
return false
|
||
} finally {
|
||
linking.value = false
|
||
}
|
||
}
|
||
|
||
async function openBatchDeployDlg() {
|
||
const rows = effectiveSelectedRows.value.filter(Boolean)
|
||
if (!rows.length) {
|
||
ElMessage.warning('请先勾选要关联源采购仓的 RDC 物料')
|
||
return
|
||
}
|
||
const prep = await batchDeployDlgRef.value?.prepareLinkDeploy?.(rows)
|
||
if (prep?.handled) return
|
||
batchDeployVisible.value = true
|
||
}
|
||
|
||
async function unlinkRows(rows) {
|
||
const wh = String(props.warehouseCode || '').trim()
|
||
const list = buildUnlinkList(rows)
|
||
if (!wh || !list.length) {
|
||
ElMessage.warning('无法移出:缺少物料编码')
|
||
return
|
||
}
|
||
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确定将 ${list.length} 条物料移出 RDC?移出后物料将回到左侧物料池。`,
|
||
'批量移出 RDC',
|
||
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' },
|
||
)
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
unlinking.value = true
|
||
try {
|
||
await warehouseRdcUnlinkGoods({ warehouse_code: wh, list })
|
||
await Promise.all([loadGoods(), refreshLinkedKeys()])
|
||
ElMessage.success(`已移出 ${list.length} 条物料`)
|
||
} catch (e) {
|
||
ElMessage.error('移出失败:' + (e?.data?.message || e?.message || '未知错误'))
|
||
} finally {
|
||
unlinking.value = false
|
||
}
|
||
}
|
||
|
||
watch(searchKeyword, () => {
|
||
clearAllSelection()
|
||
})
|
||
|
||
watch(displayMode, () => {
|
||
clearAllSelection()
|
||
})
|
||
|
||
watch(
|
||
() => props.warehouseCode,
|
||
(wh) => {
|
||
goodsRows.value = []
|
||
goodsTotal.value = 0
|
||
searchKeyword.value = ''
|
||
pageCurrent.value = 1
|
||
activeGoodsRow.value = null
|
||
emitActiveGoods()
|
||
clearAllSelection()
|
||
if (wh) {
|
||
void refreshLinkedKeys()
|
||
void loadGoods()
|
||
} else {
|
||
emitLinkedKeys([])
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
watch(
|
||
() => props.sceneCode,
|
||
() => {
|
||
pageCurrent.value = 1
|
||
void loadGoods()
|
||
},
|
||
)
|
||
|
||
watch([linking, unlinking], () => {
|
||
emit('busy-change', { linking: linking.value, unlinking: unlinking.value })
|
||
}, { immediate: true })
|
||
|
||
const splitFooterState = computed(() => ({
|
||
visible: props.regionSplitLayout
|
||
&& !!props.warehouseCode
|
||
&& goodsTotal.value > 0
|
||
&& !isTreeMode.value,
|
||
total: goodsTotal.value,
|
||
page: pageCurrent.value,
|
||
size: pageSize.value,
|
||
}))
|
||
|
||
watch(
|
||
splitFooterState,
|
||
(state) => {
|
||
if (props.regionSplitLayout) emit('split-footer-change', { ...state })
|
||
},
|
||
{ immediate: true, deep: true },
|
||
)
|
||
|
||
function setPageCurrent(page) {
|
||
onPageCurrentChange(page)
|
||
}
|
||
|
||
function setPageSize(size) {
|
||
onPageSizeChange(size)
|
||
}
|
||
|
||
async function refresh() {
|
||
await Promise.all([loadGoods(), refreshLinkedKeys()])
|
||
}
|
||
|
||
defineExpose({
|
||
linkGoods,
|
||
refresh,
|
||
applyGoodsFromRaw,
|
||
setPageCurrent,
|
||
setPageSize,
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.wh-srf-rdc {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.wh-srf-rdc-toolbar {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px 10px;
|
||
padding: 8px 10px;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
background: #fff;
|
||
}
|
||
.wh-srf-rdc-title {
|
||
font-size: calc(12px + var(--wh-fs-delta, 0px));
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
.wh-srf-search {
|
||
flex: 1 1 120px;
|
||
min-width: 100px;
|
||
max-width: 200px;
|
||
}
|
||
.wh-srf-search :deep(.el-input__wrapper) {
|
||
padding: 0 8px;
|
||
}
|
||
.wh-srf-search :deep(.el-input__inner) {
|
||
font-size: calc(12px + var(--wh-fs-delta, 0px));
|
||
}
|
||
.wh-srf-display {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.wh-srf-display-label {
|
||
font-size: calc(11px + var(--wh-fs-delta, 0px));
|
||
color: #909399;
|
||
white-space: nowrap;
|
||
}
|
||
.wh-srf-display-group {
|
||
font-size: inherit;
|
||
display: inline-flex;
|
||
flex-wrap: nowrap;
|
||
}
|
||
.wh-srf-display-group :deep(.el-radio-button__inner) {
|
||
padding: 0 0.9em;
|
||
line-height: 1.846em;
|
||
font-size: calc(11px + var(--wh-fs-delta, 0px));
|
||
}
|
||
.wh-srf-lot-type {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.wh-srf-lot-type-label {
|
||
font-size: calc(11px + var(--wh-fs-delta, 0px));
|
||
color: #909399;
|
||
white-space: nowrap;
|
||
}
|
||
.wh-srf-lot-type-select {
|
||
width: 108px;
|
||
}
|
||
.wh-srf-lot-type-select :deep(.el-input__inner) {
|
||
font-size: calc(12px + var(--wh-fs-delta, 0px));
|
||
}
|
||
.wh-srf-rdc-batch-btn {
|
||
margin-left: 0;
|
||
}
|
||
.wh-srf-rdc-sel-hint {
|
||
font-size: calc(11px + var(--wh-fs-delta, 0px));
|
||
color: #409eff;
|
||
}
|
||
.wh-srf-rdc-meta {
|
||
margin-left: auto;
|
||
font-size: calc(11px + var(--wh-fs-delta, 0px));
|
||
color: #909399;
|
||
white-space: nowrap;
|
||
}
|
||
.wh-srf-rdc-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.wh-srf-rdc-body.is-tree-mode {
|
||
overflow: auto;
|
||
}
|
||
.wh-srf-tree-wrap {
|
||
flex: 1;
|
||
min-height: 0;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow-x: hidden;
|
||
overflow-y: auto;
|
||
}
|
||
.wh-srf-tree-wrap--rdc {
|
||
--wh-srf-tree-fixed-w: calc(7.5em + 4px + 52px);
|
||
}
|
||
.wh-srf-tree-wrap--rdc:not(:has(.has-actions)) {
|
||
--wh-srf-tree-fixed-w: 7.5em;
|
||
}
|
||
.wh-srf-tree-wrap--rdc .wh-srf-tree-head {
|
||
flex-shrink: 0;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 2;
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) var(--wh-srf-tree-fixed-w, 120px);
|
||
align-items: center;
|
||
column-gap: 0;
|
||
padding: 6px 0;
|
||
font-size: calc(12px + var(--wh-fs-delta, 0px));
|
||
font-weight: 600;
|
||
color: #909399;
|
||
background: #fafbfc;
|
||
border-bottom: 1px solid #ebeef5;
|
||
box-sizing: border-box;
|
||
}
|
||
.wh-srf-tree-head-name {
|
||
min-width: 0;
|
||
padding-left: 24px;
|
||
font-weight: 600;
|
||
}
|
||
.wh-srf-tree-wrap--rdc .wh-srf-tree-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) var(--wh-srf-tree-fixed-w, 120px);
|
||
align-items: center;
|
||
column-gap: 0;
|
||
width: 100%;
|
||
min-width: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
.wh-srf-tree-row.is-cat .wh-srf-tree-col--name {
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
.wh-srf-tree-row.is-goods {
|
||
cursor: pointer;
|
||
}
|
||
.wh-srf-tree-row.is-goods.is-active {
|
||
background: rgba(64, 158, 255, 0.08);
|
||
border-radius: 4px;
|
||
}
|
||
.wh-srf-tree-fixed-cols {
|
||
display: grid;
|
||
width: var(--wh-srf-tree-fixed-w, 120px);
|
||
align-items: center;
|
||
gap: 4px;
|
||
min-width: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
.wh-srf-tree-fixed-cols--rdc {
|
||
grid-template-columns: 7.5em;
|
||
}
|
||
.wh-srf-tree-fixed-cols--rdc.has-actions {
|
||
grid-template-columns: 7.5em 52px;
|
||
}
|
||
.wh-srf-tree-wrap--rdc .wh-srf-tree-col--lot,
|
||
.wh-srf-tree-wrap--rdc .wh-srf-tree-col--act {
|
||
justify-self: center;
|
||
}
|
||
.wh-srf-tree-fixed-cols--rdc .wh-srf-tree-col--lot {
|
||
flex: none;
|
||
width: 100%;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
.wh-srf-tree-fixed-cols--rdc .wh-srf-tree-col--act {
|
||
flex: none;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
.wh-srf-tree-col--lot {
|
||
overflow: visible;
|
||
}
|
||
/* 平铺列表:行结构与树形物料行一致(勾选 + 同一 grid) */
|
||
.wh-srf-flat-wrap {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.wh-srf-flat-head-row,
|
||
.wh-srf-flat-row {
|
||
display: flex;
|
||
align-items: center;
|
||
min-width: 0;
|
||
}
|
||
.wh-srf-flat-check-slot {
|
||
flex: 0 0 36px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: 0;
|
||
}
|
||
.wh-srf-flat-head-grid {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) var(--wh-srf-tree-fixed-w, 120px);
|
||
align-items: center;
|
||
column-gap: 0;
|
||
padding: 6px 0;
|
||
font-size: calc(12px + var(--wh-fs-delta, 0px));
|
||
font-weight: 600;
|
||
color: #909399;
|
||
background: #fafbfc;
|
||
border-bottom: 1px solid #ebeef5;
|
||
box-sizing: border-box;
|
||
}
|
||
.wh-srf-flat-head-name {
|
||
padding-left: 0;
|
||
}
|
||
.wh-srf-flat-list {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
}
|
||
.wh-srf-flat-row {
|
||
min-height: calc(28px + var(--wh-fs-delta, 0px));
|
||
cursor: pointer;
|
||
border-bottom: 1px solid #f5f7fa;
|
||
}
|
||
.wh-srf-flat-row:hover {
|
||
background: #fafbfc;
|
||
}
|
||
.wh-srf-flat-row.is-active {
|
||
background: rgba(64, 158, 255, 0.08);
|
||
}
|
||
.wh-srf-flat-row .wh-srf-tree-row {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.wh-srf-tree-fixed-cols--fdc .wh-srf-tree-col--code {
|
||
flex: 0 0 76px;
|
||
width: 76px;
|
||
}
|
||
.wh-srf-tree-fixed-cols--fdc .wh-srf-tree-col--wh {
|
||
flex: 0 0 64px;
|
||
width: 64px;
|
||
}
|
||
.wh-srf-tree-fixed-cols--fdc.has-actions .wh-srf-tree-col--act {
|
||
flex: 0 0 88px;
|
||
width: 88px;
|
||
}
|
||
.wh-srf-tree-col {
|
||
min-width: 0;
|
||
font-size: calc(12px + var(--wh-fs-delta, 0px));
|
||
line-height: 1.35;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.wh-srf-tree-row .wh-srf-tree-col--name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: #303133;
|
||
}
|
||
.wh-srf-tree-name-text {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.wh-srf-tree-col--code {
|
||
color: #606266;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
font-size: calc(11px + var(--wh-fs-delta, 0px));
|
||
}
|
||
.wh-srf-tree-col--wh.is-linked {
|
||
color: #409eff;
|
||
}
|
||
.wh-srf-tree-col--wh.is-unlinked {
|
||
color: #e6a23c;
|
||
}
|
||
.wh-srf-tree-col--act {
|
||
white-space: nowrap;
|
||
min-width: 0;
|
||
overflow: visible;
|
||
}
|
||
.wh-srf-tree-col--act .el-button {
|
||
margin: 0;
|
||
padding: 0;
|
||
font-size: calc(12px + var(--wh-fs-delta, 0px));
|
||
line-height: 1.35;
|
||
}
|
||
.wh-srf-goods-tree {
|
||
flex: 1;
|
||
min-height: 120px;
|
||
background: transparent;
|
||
}
|
||
.wh-srf-goods-tree--rdc :deep(.el-tree-node__content) {
|
||
height: auto;
|
||
min-height: calc(28px + var(--wh-fs-delta, 0px));
|
||
padding: 0 !important;
|
||
align-items: stretch;
|
||
display: flex;
|
||
width: 100%;
|
||
min-width: 0;
|
||
box-sizing: border-box;
|
||
overflow: visible;
|
||
}
|
||
.wh-srf-goods-tree--rdc :deep(.el-tree-node__expand-icon) {
|
||
margin-right: 4px;
|
||
}
|
||
.wh-srf-goods-tree--grid :deep(.el-tree-node__content) {
|
||
height: auto;
|
||
min-height: calc(28px + var(--wh-fs-delta, 0px));
|
||
padding-top: 0;
|
||
padding-bottom: 0;
|
||
align-items: center;
|
||
display: flex;
|
||
width: 100%;
|
||
min-width: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
.wh-srf-goods-tree--rdc :deep(.el-tree-node__content > .wh-srf-tree-row) {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.wh-srf-goods-tree--grid :deep(.el-tree-node__content > .wh-srf-tree-row) {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.wh-srf-goods-final-tag {
|
||
flex-shrink: 0;
|
||
}
|
||
.wh-srf-rdc-empty {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
text-align: center;
|
||
font-size: calc(12px + var(--wh-fs-delta, 0px));
|
||
color: #909399;
|
||
}
|
||
.wh-srf-rdc-empty-sub {
|
||
margin: 6px 0 0;
|
||
font-size: calc(11px + var(--wh-fs-delta, 0px));
|
||
color: #c0c4cc;
|
||
}
|
||
.wh-srf-rdc-ft {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
padding: 6px 10px;
|
||
border-top: 1px solid #f0f0f0;
|
||
background: #fafbfc;
|
||
}
|
||
/* Element Plus:表格 / 按钮 / 分页 / 标签 */
|
||
.wh-srf-rdc :deep(.el-button--small) {
|
||
font-size: calc(12px + var(--wh-fs-delta, 0px));
|
||
}
|
||
.wh-srf-rdc :deep(.el-tag--small) {
|
||
font-size: calc(11px + var(--wh-fs-delta, 0px));
|
||
height: calc(20px + var(--wh-fs-delta, 0px));
|
||
}
|
||
.wh-srf-rdc-ft :deep(.el-pagination),
|
||
.wh-srf-rdc-ft :deep(.el-pagination button),
|
||
.wh-srf-rdc-ft :deep(.el-pagination .el-select .el-input__inner) {
|
||
font-size: calc(12px + var(--wh-fs-delta, 0px));
|
||
}
|
||
</style>
|