feat(stocktake): 抽盘库位选择改为左右双栏,已选明细可逐个移除

原库位树是单列、层级很深,用户勾完看不出到底选了哪些库位。

现改为左右双栏:
- 左 60%:el-tree(勾选),@check 触发 syncSelectedPaths
- 右 40%:已选明细面板 —— 顶部「已选 N 个库位」+ 清空按钮,
  el-scrollbar 内平铺 el-tag 展示完整路径(等宽字体)

双向互动:点击右侧标签的 × 会调 treeRef.setChecked(path, false, true)
同步取消左侧勾选后重算列表。注意 el-tree 的 setCheckedKeys / setChecked
都**不会**触发 @check 事件,因此这两处都显式调用了 syncSelectedPaths(),
保证左右状态强同步(获取推荐后右侧也会立刻渲染)。

★ 右侧刻意**不过滤非末级节点**,展示的就是提交给后端的 scope_config.locations
全集。原因:后端按 warehouse_location 精确匹配,而库存的库位可能落在层级 1/2
(实测 level1 有 9 个、level2 有 57 个),只提交叶子路径会把这些库存静默排除
在抽盘范围之外。面板所见 = 实际范围,才谈得上「强同步」。

配套:抽盘配置区是双栏、400px 窄容器装不下,故在展开该配置时把欢迎页
放宽到 780px(.idle-content.is-wide),其他状态维持原宽度。
This commit is contained in:
yueli
2026-09-11 14:22:14 +08:00
parent e9468673c0
commit 80bd45d5d4

View File

@ -2,7 +2,7 @@
<div class="app-container mobile-optimized"> <div class="app-container mobile-optimized">
<el-card v-if="!isSessionActive" class="main-card idle-card" shadow="never"> <el-card v-if="!isSessionActive" class="main-card idle-card" shadow="never">
<div class="idle-content"> <div class="idle-content" :class="{ 'is-wide': idleWide }">
<div class="icon-area"> <div class="icon-area">
<el-icon :size="80" color="#409EFF"><VideoPlay /></el-icon> <el-icon :size="80" color="#409EFF"><VideoPlay /></el-icon>
</div> </div>
@ -119,21 +119,52 @@
</el-button> </el-button>
</div> </div>
<el-tree <!-- 库位树勾选 已选明细可关闭与左侧强同步 -->
ref="locTreeRef" <div class="scope-columns">
v-loading="treeLoading" <div class="scope-col-left">
class="scope-tree" <el-tree
:data="locTreeData" ref="locTreeRef"
node-key="full_path" v-loading="treeLoading"
show-checkbox class="scope-tree"
default-expand-all :data="locTreeData"
:props="{ label: 'name', children: 'children' }" node-key="full_path"
@check="updateCheckedLocCount" show-checkbox
/> default-expand-all
:props="{ label: 'name', children: 'children' }"
@check="syncSelectedPaths"
/>
</div>
<div class="scope-selected"> <div class="scope-col-right">
已选 <strong>{{ checkedLocCount }}</strong> 个库位 <div class="scope-selected-header">
<el-button link type="primary" size="small" @click="resetScopeSelection">清空</el-button> <span>已选 <strong>{{ checkedLocCount }}</strong> 个库位</span>
<el-button
v-if="checkedLocCount"
link
type="primary"
size="small"
@click="resetScopeSelection"
>
清空
</el-button>
</div>
<el-scrollbar class="scope-selected-scroll">
<div v-if="!selectedLocationPaths.length" class="scope-selected-empty">
尚未选择库位<br />可点获取推荐自动勾选或在左侧树上手动勾选
</div>
<el-tag
v-for="p in selectedLocationPaths"
:key="p"
closable
size="small"
type="info"
class="scope-loc-tag"
@close="uncheckLocation(p)"
>
{{ p }}
</el-tag>
</el-scrollbar>
</div>
</div> </div>
</div> </div>
<div v-if="creatingNew" class="task-form-warn"> <div v-if="creatingNew" class="task-form-warn">
@ -728,7 +759,19 @@ const recLoading = ref(false) // 获取推荐中
const treeLoading = ref(false) // 库位树加载中 const treeLoading = ref(false) // 库位树加载中
const locTreeData = ref<any[]>([]) // 库位树(已按当前公司前缀过滤) const locTreeData = ref<any[]>([]) // 库位树(已按当前公司前缀过滤)
const locTreeRef = ref() // el-tree 实例,用于 setCheckedKeys / getCheckedNodes const locTreeRef = ref() // el-tree 实例,用于 setCheckedKeys / getCheckedNodes
const checkedLocCount = ref(0) // 当前勾选的库位数(含父节点,实时展示避免误判规模) // 当前勾选的库位路径集合 —— 它与提交给后端的 scope_config.locations 完全一致。
// 刻意**不过滤非末级节点**:后端按 warehouse_location 精确匹配,而库存的库位
// 可能落在层级 1/2实测 level1 有 9 个、level2 有 57 个),只提交叶子会把
// 这些库存静默排除在抽盘范围之外。
const selectedLocationPaths = ref<string[]>([])
const checkedLocCount = computed(() => selectedLocationPaths.value.length)
// 创建表单当前是否可见(与模板里 v-else 分支的条件保持一致)
const showCreateForm = computed(() =>
!!selectedCompany.value && !probeFailed.value && !(activeSession.value && !creatingNew.value)
)
// 抽盘配置区是左右双栏400px 的窄容器装不下,此时把欢迎页放宽
const idleWide = computed(() => showCreateForm.value && newScopeType.value === 'active')
// 库位树按公司前缀过滤IRIS 只看 YLICA 看 C/L未配置的公司不过滤 // 库位树按公司前缀过滤IRIS 只看 YLICA 看 C/L未配置的公司不过滤
const filterTreeByCompany = (nodes: any[], company: string) => { const filterTreeByCompany = (nodes: any[], company: string) => {
@ -755,9 +798,16 @@ const loadLocationTree = async () => {
} }
} }
const updateCheckedLocCount = () => { // 树的勾选状态 → 响应式路径集合。唯一数据源,左右两侧都读它
const syncSelectedPaths = () => {
const nodes = locTreeRef.value?.getCheckedNodes() || [] const nodes = locTreeRef.value?.getCheckedNodes() || []
checkedLocCount.value = nodes.length selectedLocationPaths.value = nodes.map((n: any) => n.full_path).filter(Boolean)
}
// 点右侧标签的 × → 同步取消左侧树上对应的勾选
const uncheckLocation = (path: string) => {
locTreeRef.value?.setChecked(path, false, true)
syncSelectedPaths()
} }
// 取推荐并把它们在树上自动勾选 // 取推荐并把它们在树上自动勾选
@ -785,13 +835,12 @@ const fetchRecommendLocations = async () => {
// false = 不触发子节点级联之外的行为,按 el-tree 默认规则勾选 // false = 不触发子节点级联之外的行为,按 el-tree 默认规则勾选
locTreeRef.value?.setCheckedKeys(locs, false) locTreeRef.value?.setCheckedKeys(locs, false)
updateCheckedLocCount() // ★ 右侧明细与左侧树同源,勾完立即同步,保证两侧永远一致
syncSelectedPaths()
// 推荐里有、但树上勾不到的(不在当前公司前缀范围内等)要如实告知, // 推荐里有、但树上勾不到的(不在当前公司前缀范围内等)要如实告知,
// 否则这些库位会静默落选 —— 工人以为盘到了,其实没进范围 // 否则这些库位会静默落选 —— 工人以为盘到了,其实没进范围
const checkedPaths = new Set( const checkedPaths = new Set(selectedLocationPaths.value)
(locTreeRef.value?.getCheckedNodes() || []).map((n: any) => n.full_path)
)
const missing = locs.filter(l => !checkedPaths.has(l)) const missing = locs.filter(l => !checkedPaths.has(l))
if (missing.length) { if (missing.length) {
ElMessage.warning(`推荐中 ${missing.length} 个库位不在当前公司的库位树上,未能勾选`) ElMessage.warning(`推荐中 ${missing.length} 个库位不在当前公司的库位树上,未能勾选`)
@ -816,10 +865,10 @@ const buildScopeConfig = () => {
} }
} }
// 切换回全仓时清空树上的勾选,避免残留到下一次抽盘 // 清空勾选(切换回全仓、或点「清空」按钮)
const resetScopeSelection = () => { const resetScopeSelection = () => {
locTreeRef.value?.setCheckedKeys([], false) locTreeRef.value?.setCheckedKeys([], false)
checkedLocCount.value = 0 syncSelectedPaths()
} }
watch(newScopeType, (val) => { watch(newScopeType, (val) => {
@ -1719,6 +1768,8 @@ const goToVarianceReview = () => {
border-radius: 0; border-radius: 0;
} }
.idle-content { width: 100%; max-width: 400px; padding: 20px; } .idle-content { width: 100%; max-width: 400px; padding: 20px; }
/* 抽盘配置区是双栏,窄容器装不下,此时放宽欢迎页 */
.idle-content.is-wide { max-width: 780px; }
.app-title { font-size: 24px; margin-bottom: 10px; color: #303133; } .app-title { font-size: 24px; margin-bottom: 10px; color: #303133; }
.subtitle { color: #909399; margin-bottom: 40px; } .subtitle { color: #909399; margin-bottom: 40px; }
.icon-area { margin-bottom: 20px; } .icon-area { margin-bottom: 20px; }
@ -1818,9 +1869,27 @@ const goToVarianceReview = () => {
} }
.scope-recommend-text { font-size: 12px; color: #606266; } .scope-recommend-text { font-size: 12px; color: #606266; }
.scope-topn-input { width: 100px; } .scope-topn-input { width: 100px; }
/* 左右双栏:左 60% 库位树 右 40% 已选明细 */
.scope-columns {
display: flex;
gap: 10px;
align-items: stretch;
}
.scope-col-left { flex: 0 0 60%; max-width: 60%; min-width: 0; }
.scope-col-right {
flex: 1 1 40%;
min-width: 0;
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 6px 8px;
}
/* 平板大屏:定高 + 滚动,避免树把表单撑得过长 */ /* 平板大屏:定高 + 滚动,避免树把表单撑得过长 */
.scope-tree { .scope-tree {
max-height: 260px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
background: #fff; background: #fff;
border: 1px solid #ebeef5; border: 1px solid #ebeef5;
@ -1829,15 +1898,30 @@ const goToVarianceReview = () => {
font-size: 14px; font-size: 14px;
--el-tree-node-content-height: 30px; --el-tree-node-content-height: 30px;
} }
.scope-selected { .scope-selected-header {
margin-top: 8px;
font-size: 12px;
color: #606266;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; justify-content: space-between;
font-size: 12px;
color: #606266;
padding-bottom: 4px;
border-bottom: 1px solid #f2f3f5;
margin-bottom: 6px;
}
.scope-selected-header strong { color: #409eff; font-size: 14px; }
.scope-selected-scroll { flex: 1 1 auto; height: 260px; }
.scope-selected-empty {
font-size: 12px;
color: #c0c4cc;
line-height: 1.8;
text-align: center;
padding-top: 20px;
}
/* 已选库位标签:完整路径平铺,点 × 同步取消左侧勾选 */
.scope-loc-tag {
margin: 0 4px 4px 0;
font-family: ui-monospace, Menlo, Consolas, monospace;
} }
.scope-selected strong { color: #409eff; font-size: 14px; }
/* 场景 A 的进度提示与次要入口 */ /* 场景 A 的进度提示与次要入口 */
.active-session-tip { .active-session-tip {