@ -98,41 +98,61 @@
< el-dialog v-model = "warehouseDialogVisible" title="库位管理" width="700px" destroy-on-close :close-on-click-modal="false" >
< div class = "warehouse-dialog" >
< div class = "warehouse-header" >
<!-- 非批量 模式 -- >
< template v-if = "!isBulkD elete Mode" >
<!-- 普通 模式 -- >
< template v-if = "!s elec tMode" >
< el -button type = "primary" @click ="handleAddTopLevel" :icon = "Plus" >
新增顶级区域
< / el-button >
< el-button type = "success" @click ="openBatchGenerate" :icon = "Plus" >
批量生成
< / el-button >
< el-button type = "danger" @click ="isBulkD elete Mode = true " :icon = "Delete " >
批量删除
< el-button type = "warning" plain @click ="enterS elec tMode" :icon = "Checked " >
批量选择
< / el-button >
< / template >
<!-- 批量模式 -- >
<!-- 选择模式 : 勾中的节点既能批量删除 , 也能批量新增下级 -- >
< template v-else >
< el -button @click ="cancelBatch Mode" :icon = "Close" >
< el -button @click ="cancelSelect Mode" :icon = "Close" >
取消
< / el-button >
< el-button type = "danger" @click ="handleBatchDelete" : disabled = "sel ect edIds. length === 0" :icon = "Delete" >
确认删除 { { selectedIds . length > 0 ? ` ( ${ selectedIds . length } 项) ` : '' } }
< span class = "select-hint" > 已选 { { ch eck edIds. length } } 个 < / span >
< el-button
type = "primary"
@click ="openBatchAddForSelection"
: disabled = "checkedIds.length === 0"
:icon = "Plus"
>
批量新增下级
< / el-button >
< el-button
type = "danger"
@click ="handleBatchDelete"
: disabled = "selectedIds.length === 0"
:icon = "Delete"
>
批量删除 { { selectedIds . length > 0 ? ` (连同子级共 ${ batchDeleteTotal } 个) ` : '' } }
< / el-button >
< / template >
< / div >
< el-scrollbar height = "450px" >
< el-tree
ref = "treeRef"
:data = "treeData "
:data = "groupedTree "
node -key = " id "
:props = "treeProps"
:expand-on-click-node = "false"
:default-expand-all = "false"
:show-checkbox = "isBulkD elete Mode"
:show-checkbox = "s elec tMode"
:check-strictly = "selectMode"
@check ="handleTreeCheck"
>
< template # default = "{ node, data }" >
< div class = "tree-node" >
<!-- 公司分组头 : 只是个折叠容器 , 不能增删改 ( 它不是库位 ) -- >
< div v-if = "data.is_group" class="tree-group" @click="toggleGroup(data.id)" >
< span class = "group-label" > { { data . name } } < / span >
< span class = "group-count" > { { data . children ? . length || 0 } } 个区域 < / span >
< / div >
< div v-else class = "tree-node" >
< span class = "node-label" > { { node . label } } < / span >
< span class = "node-actions" >
< el-button
@ -179,6 +199,26 @@
< el-form-item label = "名称" required >
< el-input v-model = "locationForm.name" placeholder="请输入库位名称" / >
< / el-form-item >
<!-- 公司归属 :
· 顶级 + 跨域角色 → 可选 ( 含 「 全局共享 」 )
· 顶级 + 普通角色 → 只读 , 强制本公司 ( 后端同样强制 , 绕不过去 )
· 子级 → 只读 , 永远继承上级 ( 不允许一棵树跨两家 ) -- >
< el-form-item label = "公司归属" >
< el-select
v-if = "!locationForm.parent_id && hasCrossDomain"
v-model = "locationForm.company_name"
placeholder = "请选择公司"
style = "width: 100%"
>
< el-option v-for = "c in companies" :key="c" :label="c" :value="c" / >
< el-option label = "全局共享(两司均可见)" value = "" / >
< / el-select >
< el-input
v-else
: value = "locationForm.parent_id ? `继承上级:${companyLabel(locationForm.company_name)}` : companyLabel(locationForm.company_name)"
disabled
/ >
< / el-form-item >
< el-form-item label = "启用状态" >
< el-switch v-model = "locationForm.is_enabled" / >
< / el-form-item >
@ -192,10 +232,51 @@
<!-- 批量生成库位弹窗 -- >
< el-dialog v-model = "batchGenerateVisible" title="批量生成库位" width="800px" destroy -on -close >
< el-form :model = "batchGenerateForm" label -width = " 100px " >
< el-form-item label = "父级库位" >
< el-input :value = "batchGenerateForm.parentName" disabled / >
< el-button link size = "small" @click ="batchGenerateForm.parent_id = null; batchGenerateForm.parentName = '无(顶级)'" > 清空选择 < / el -button >
<!-- 起点来自树上勾选 : 不需要选父级 , 也不涉及归属 ( 各起点各继承自己的 ) -- >
< el-form-item v-if = "batchGenerateForm.fromSelection" label="起点" >
< el-input : value = "`已勾选的 ${batchGenerateForm.seedIds.length} 个库位`" disabled / >
< / el-form-item >
< template v-else >
< el -form -item label = "父级库位" >
<!-- 原来这里是只读输入框 + 一个恒为空的 「 清空选择 」 , 导致批量生成
只能产出顶级区域 ; 换成真正的库位选择器后可以从任意节点起算 -- >
< WarehouseSelector
:model-value = "batchGenerateForm.parentPath"
@ update :model-value = "onBatchParentChange"
/ >
< el-button
link
size = "small"
:disabled = "!batchGenerateForm.parent_id"
@click ="onBatchParentChange('')"
>
清空 ( 改为生成顶级区域 )
< / el-button >
< / el-form-item >
<!-- 起点范围 : 只在这个节点下 , 还是它子树里的每个末级各来一遍 -- >
< el-form-item v-if = "batchGenerateForm.parent_id" label="生成位置" >
< el -radio -group v-model = "batchGenerateForm.leaf_only" class="batch-scope" >
< el -radio :label = "false" > 仅在 「 { { batchGenerateForm . parentName } } 」 下生成 < / el-radio >
< el-radio :label = "true" >
在 「 { { batchGenerateForm . parentName } } 」 下的所有末级库位下各生成一份
< span class = "scope-hint" > ( 共 { { batchLeafCount } } 个末级 ) < / span >
< / el-radio >
< / el-radio-group >
< / el-form-item >
<!-- 归属 : 选中的父级决定归属 ; 父级为 「 无 ( 顶级 ) 」 时由跨域角色指定 -- >
< el-form-item label = "公司归属" >
< el-select
v-if = "!batchGenerateForm.parent_id && hasCrossDomain"
v-model = "batchGenerateForm.company_name"
placeholder = "请选择公司"
style = "width: 100%"
>
< el-option v-for = "c in companies" :key="c" :label="c" :value="c" / >
< el-option label = "全局共享(两司均可见)" value = "" / >
< / el-select >
< el-input v-else : value = "batchGenerateForm.parent_id ? '继承上级' : companyLabel(batchGenerateForm.company_name)" disabled / >
< / el-form-item >
< / template >
< el-divider > 层级规则 ( 按顺序生成 ) < / el-divider >
< div v-for = "(rule, index) in batchGenerateForm.rules" :key="index" class="rule-item" >
< el -row :gutter = "10" >
@ -219,8 +300,42 @@
< el-button type = "primary" plain @click ="batchGenerateForm.rules.push({ prefix: '', start: 1, end: 10, pad: 1 })" :icon = "Plus" >
添加层级
< / el-button >
< div class = "batch-preview" >
< span v-if = "previewCount > 0" > 即将生成 < strong > { { previewCount } } < / strong > 个库位 < / span >
< el-divider > 生成结构预览 < / el-divider >
<!-- 边填边看 : 几层 、 每层叫什么 、 多少个 , 不用在脑子里推演 -- >
< div v-if = "previewLevels.length" class="batch-structure" >
< div class = "struct-row struct-root" >
< span class = "struct-branch" > ● < / span >
< span class = "struct-root-label" > { { batchRootLabel } } < / span >
< / div >
< div
v-for = "lv in previewLevels"
:key = "lv.level"
class = "struct-row"
: style = "{ paddingLeft: 16 + lv.level * 18 + 'px' }"
>
< span class = "struct-branch" > └ < / span >
< span class = "struct-level" >
第 { { lv . level } } 层
< span v-if = "lv.level > 1" class="struct-hint" > 上一层每个之下 < / span >
< / span >
< span class = "struct-names" >
{ { lv . names . join ( ' / ' ) } } < template v-if = "lv.truncated" > … 等 {{ lv.per }} 个 < / template >
< / span >
< span class = "struct-count" > { { lv . total } } 个 < / span >
< / div >
< div class = "struct-sample" >
生成后的库位长这样 : < code > { { batchSamplePath } } < / code >
< / div >
< div class = "struct-total" >
共 < strong > { { previewLevels . length } } < / strong > 层 ·
新建 < strong > { { previewCount } } < / strong > 个库位
< span v-if = "batchSkippedCount > 0" class="struct-skip" >
( 另跳过 {{ batchSkippedCount }} 个已存在的同名库位 )
< / span >
< / div >
< / div >
< div v-else class = "batch-preview" >
< span v-if = "batchSkippedCount > 0" > 这些库位都已存在 , 无需生成 < / span >
< span v-else > 请完善规则 < / span >
< / div >
< / el -form >
@ -240,12 +355,13 @@ import { ref, reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
// 1. 引入 User Store
import { useUserStore } from '@/stores/user'
import WarehouseSelector from '@/components/WarehouseSelector.vue'
// 引入需要的图标
import { Box , TrendCharts , ShoppingCart , Operation , Setting , Location , Plus , Edit , Delete , Close , Search } from '@element-plus/icons-vue'
import { Box , TrendCharts , ShoppingCart , Operation , Setting , Location , Plus , Edit , Delete , Close , Search , Checked } from '@element-plus/icons-vue'
import { getPrinterConfig , updatePrinterConfig } from '@/api/common/print'
import request from '@/utils/request'
import { ElMessage , ElMessageBox } from 'element-plus'
import { getWarehouseTree , createWarehouse , updateWarehouse , deleteWarehouse , batchDeleteWarehouse , batchGenerateWarehouse } from '@/api/common/warehouse'
import { getWarehouseTree , createWarehouse , updateWarehouse , deleteWarehouse , batchDeleteWarehouse , batchGenerateWarehouse , getWarehouseCompanies } from '@/api/common/warehouse'
const router = useRouter ( )
// 2. 实例化 store
@ -392,6 +508,68 @@ const treeProps = {
children : 'children'
}
// 顶级库位按公司归属分组折叠。
// 归属是「区域级」属性(子级强制继承父级,不会跨公司),所以按顶级分组最自然:
// 挂在每个节点后面既挤又难扫,分组后一眼看出两家各有哪些区域,也不占横向空间。
// ★ 分组节点是**虚拟节点**( id 形如 'group:IRIS'),不对应任何库位行,
// 所以不提供新增下级/编辑/删除; 批量删除取的是叶子( getCheckedKeys(true)) ,
// 分组节点永远不是叶子,不会被误选。
const groupedTree = computed ( ( ) => {
const groups = new Map < string , { label : string ; company : string | null ; children : any [ ] } > ( )
for ( const root of treeData . value ) {
const company = root . company _name ? ? null
const key = company ? ? '__GLOBAL__'
if ( ! groups . has ( key ) ) groups . set ( key , { label : companyLabel ( company ) , company , children : [ ] } )
groups . get ( key ) ! . children . push ( root )
}
// 固定顺序:具体公司按字典序,全局共享垫底
return [ ... groups . entries ( ) ]
. sort ( ( [ a ] , [ b ] ) => ( a === '__GLOBAL__' ? 1 : b === '__GLOBAL__' ? - 1 : a . localeCompare ( b ) ) )
. map ( ( [ key , g ] ) => ( {
id : ` group: ${ key } ` ,
name : g . label ,
is _group : true ,
company _name : g . company ,
children : g . children ,
} ) )
} )
// 整行点击也能展开/收起分组( el-tree 的 expand-on-click-node 关着,
// 默认只有那个小三角能点,对分组头来说太难点中)
const toggleGroup = ( key : string ) => {
const node = treeRef . value ? . getNode ( key )
if ( node ) node . expanded = ! node . expanded
}
// ---- 公司归属 ----
// 只有跨域角色(超管,或持 crossDomain 权限,当前为 WAREHOUSE_MGR) 能指定归属 ——
// 与后端 get_current_company_filter() 的口径一致:对它们返回 None( 不隔离) 。
// ★ SUPER_ADMIN 的权限是通配符 ['*'], permissions.includes('crossDomain')
// 对它恒为 false, 所以角色与权限码两个条件都要判。
const hasCrossDomain = computed ( ( ) => {
const role = ( userStore . role || '' ) . toUpperCase ( )
if ( role === 'SUPER_ADMIN' ) return true
return userStore . hasPermission ( 'crossDomain' )
} )
const companies = ref < string [ ] > ( [ ] )
const loadCompanies = async ( ) => {
if ( ! hasCrossDomain . value ) return // 普通角色的归属字段是只读的,不需要公司清单
try {
const res : any = await getWarehouseCompanies ( )
if ( res . code === 200 ) companies . value = res . data || [ ]
} catch ( e ) {
console . error ( '获取公司清单失败' , e )
}
}
// null / 空串 = 全局共享(两司均可见)
const companyLabel = ( v ? : string | null ) => v || '全局共享'
// 编辑前的原归属,用于判断本次是否真的改了归属
const editingCompany = ref < string | null > ( null )
// 库位表单
const locationFormVisible = ref ( false )
const locationFormTitle = ref ( '' )
@ -400,29 +578,88 @@ const locationForm = reactive({
parent _id : null as number | null ,
parentName : '无(顶级)' ,
name : '' ,
is _enabled : true
is _enabled : true ,
// null = 尚未选择(仅顶级库位需要);'' = 全局共享;具体值 = 公司名
company _name : null as string | null
} )
const locationLoading = ref ( false )
// 批量删除相关
const selectedIds = ref < number [ ] > ( [ ] )
const isBulkDeleteMode = ref ( false )
// 批量选择模式:树上出勾选框,勾中的节点既能批量删除,也能批量新增下级。
// ★ check-strictly 只在选择模式打开 —— 勾父节点不再自动连带勾中子节点。
// 「勾哪些就作用到哪些」才是这个模式的语义:否则勾中一个 217 节点的区域
// 去「批量新增下级」,会给区域内**每一个**节点都长出子节点。
const selectMode = ref ( false )
const checkedIds = ref < number [ ] > ( [ ] ) // 勾中的全部节点(批量新增用)
const selectedIds = ref < number [ ] > ( [ ] ) // 收敛成最小集(批量删除用,见 handleTreeCheck)
const handleTreeCheck = ( data : any , checked : any ) => {
// 使用 getCheckedKeys(true) 只获取叶子节点,防止 el-tree 自动连带选中父节点导致误删
if ( treeRef . value ) {
selectedIds . value = treeRef . value . getCheckedKeys ( true ) as number [ ]
}
}
const cancelBatchMode = ( ) => {
isBulkDeleteMode . value = false
const enterSelectMode = ( ) => {
selectMode . value = true
checkedIds . value = [ ]
selectedIds . value = [ ]
if ( treeRef . value ) {
treeRef . value . setCheckedKeys ( [ ] )
}
treeRef . value ? . setCheckedKeys ( [ ] , false )
}
const cancelSelectMode = ( ) => {
selectMode . value = false
checkedIds . value = [ ]
selectedIds . value = [ ]
treeRef . value ? . setCheckedKeys ( [ ] , false )
}
// 勾选 → 两个集合
//
// ★ 删除用「最小集」。这里不能用 getCheckedKeys(true)(只取叶子)—— 旧实现就是
// 那么写的,理由是「防止 el-tree 连带选中父节点导致误删」,但反过来的坑更大:
// 勾中一个区域时只删掉它的叶子,中间层节点全部残留成孤儿(后端删除是级联的,
// 父删了子自然没,只传叶子等于专门把中间层挑出来留着)。
// 也不能把 getCheckedKeys() 原样全塞给后端 —— 祖先和子孙同时在列,后端虽不炸,
// 但会重复递归、deleted_count 失真。剔掉「祖先已在集合里」的冗余项即可。
//
// ★ 新增用「全集」:每个勾中的节点都要长出子级,父与子各自加一层是符合预期的,
// 不做同样的收敛。
const handleTreeCheck = ( ) => {
const tree = treeRef . value
if ( ! tree ) return
const isRealNode = ( id : any ) => {
const n = tree . getNode ( id )
return ! ! ( n && n . data && ! n . data . is _group ) // 分组是虚拟节点,不是库位
}
const checked = ( ( tree . getCheckedKeys ( ) as any [ ] ) || [ ] ) . filter ( isRealNode )
checkedIds . value = checked
const set = new Set ( checked )
selectedIds . value = checked . filter ( ( id ) => {
let parent = tree . getNode ( id ) ? . parent
while ( parent ) {
const pdata = parent . data
if ( ! pdata || pdata . is _group ) break // 走到分组就停,分组不参与
if ( set . has ( pdata . id ) ) return false // 祖先已在集合里 → 由祖先级联删除
parent = parent . parent
}
return true
} )
}
// 实际会被删掉的节点数(含子孙)。只显示 selectedIds.length 会严重低报 ——
// 勾一个区域可能实际删掉几百个节点,操作者得知道自己按下去会发生什么。
const batchDeleteTotal = computed ( ( ) => {
const tree = treeRef . value
if ( ! tree || ! selectedIds . value . length ) return 0
let n = 0
const walk = ( id : any ) => {
n += 1
const node = tree . getNode ( id )
for ( const child of node ? . childNodes || [ ] ) {
if ( ! child . data ? . is _group ) walk ( child . data . id )
}
}
selectedIds . value . forEach ( walk )
return n
} )
const handleBatchDelete = async ( ) => {
if ( selectedIds . value . length === 0 ) {
ElMessage . warning ( '请先选择要删除的库位' )
@ -431,7 +668,8 @@ const handleBatchDelete = async () => {
try {
await ElMessageBox . confirm (
` 确定要删除选中的 ${ selectedIds . value . length } 个库位吗?该操作将同时删除所有子库位。 ` ,
` 确定要删除选中的 ${ selectedIds . value . length } 个节点吗? ` +
` 连同其下所有子库位,共 ${ batchDeleteTotal . value } 个库位将被删除,且不可恢复。 ` ,
'警告' ,
{ type : 'warning' , confirmButtonText : '确定' , cancelButtonText : '取消' }
)
@ -439,7 +677,7 @@ const handleBatchDelete = async () => {
const res = await batchDeleteWarehouse ( selectedIds . value )
if ( res . code === 200 ) {
ElMessage . success ( '批量删除成功' )
cancelBatch Mode ( )
cancelSelect Mode ( )
await loadWarehouseTree ( )
} else {
ElMessage . error ( res . msg || '删除失败' )
@ -454,25 +692,216 @@ const batchGenerateVisible = ref(false)
const batchGenerateForm = reactive ( {
parent _id : null as number | null ,
parentName : '无(顶级)' ,
// 选择器绑的是 full_path; parent_id 由它反查得出(见 onBatchParentChange)
parentPath : '' ,
// 起点范围: false = 只在 parent 下; true = 它子树下的所有末级各来一遍
leaf _only : false ,
// 起点来自树上勾选(「批量选择」→「批量新增下级」),此时 parent_* 全部不参与
fromSelection : false ,
// 打开弹窗那一刻勾选的 id 快照 —— 弹窗打开期间树被遮住没法再改,但快照更保险
seedIds : [ ] as number [ ] ,
// 同 locationForm.company_name: null = 尚未选择,'' = 全局共享
company _name : null as string | null ,
rules : [ { prefix : '' , start : 1 , end : 10 , pad : 1 } ] as { prefix : string , start : number , end : number , pad : number } [ ]
} )
const batchLoading = ref ( false )
const previewCount = computed ( ( ) => {
let count = 1
for ( const rule of b atchGenerateForm . rules ) {
i f ( rule . start && rule . end && rule . start <= rule . end ) {
count *= ( rule . end - rule . start + 1 )
} else {
return 0
// 按 full_path 在已加载的树里反查节点
// ( WarehouseSelector 只吐 full_path, 后端要的是 id)
const findNodeByPath = ( nodes : any [ ] , p ath : string ) : any => {
for ( const n of nodes ) {
if ( n . full _path === path ) return n
if ( n . children ? . length ) {
const hit = findNodeByPath ( n . children , path )
if ( hit ) return hit
}
}
return count
return null
}
const findNodeById = ( nodes : any [ ] , id : number ) : any => {
for ( const n of nodes ) {
if ( n . id === id ) return n
if ( n . children ? . length ) {
const hit = findNodeById ( n . children , id )
if ( hit ) return hit
}
}
return null
}
const onBatchParentChange = ( path : string ) => {
batchGenerateForm . parentPath = path || ''
if ( ! path ) {
batchGenerateForm . parent _id = null
batchGenerateForm . parentName = '无(顶级)'
batchGenerateForm . leaf _only = false
batchGenerateForm . company _name = hasCrossDomain . value ? null : ( userStore . companyName || '' )
return
}
const node = findNodeByPath ( treeData . value , path )
if ( ! node ) {
ElMessage . warning ( '该库位不在当前库位树中,请重新选择' )
return
}
batchGenerateForm . parent _id = node . id
batchGenerateForm . parentName = node . full _path || node . name
}
// 末级 = 没有子节点(含父级自身),与后端 _subtree_leaf_ids 口径一致。
// ★ 不按「最深一层」判断 —— 那只在规则化生成的树上跟「没有子节点」等价,
// 手建的树深浅不一,按深度算会漏掉浅枝上的末级。
const collectLeaves = ( node : any ) : any [ ] =>
! node ? . children ? . length ? [ node ] : node . children . flatMap ( ( c : any ) => collectLeaves ( c ) )
const batchLeafCount = computed ( ( ) => {
if ( ! batchGenerateForm . parent _id || ! batchGenerateForm . parentPath ) return 0
const node = findNodeByPath ( treeData . value , batchGenerateForm . parentPath )
return node ? collectLeaves ( node ) . length : 0
} )
const batchSeedCount = computed ( ( ) => {
if ( batchGenerateForm . fromSelection ) return batchGenerateForm . seedIds . length
if ( ! batchGenerateForm . parent _id ) return 1 // 顶级生成:起点是「根」,算一个
return batchGenerateForm . leaf _only ? batchLeafCount . value : 1
} )
// 每个起点下「已有的子库位名」集合 —— 用来判断哪些名字会被后端跳过。
// 顶级生成时没有父节点,要和「所有顶级库位」比名字。
const batchExistingNames = computed < Set < string > [ ] > ( ( ) => {
const namesOf = ( n : any ) => new Set < string > ( ( n . children || [ ] ) . map ( ( c : any ) => c . name ) )
if ( batchGenerateForm . fromSelection ) {
return batchGenerateForm . seedIds
. map ( ( id ) => findNodeById ( treeData . value , id ) )
. filter ( Boolean )
. map ( ( n : any ) => namesOf ( n ) )
}
if ( ! batchGenerateForm . parent _id ) {
return [ new Set < string > ( treeData . value . map ( ( n : any ) => n . name ) ) ]
}
if ( ! batchGenerateForm . parentPath ) return [ ]
const node = findNodeByPath ( treeData . value , batchGenerateForm . parentPath )
if ( ! node ) return [ ]
return batchGenerateForm . leaf _only
? collectLeaves ( node ) . map ( ( n : any ) => namesOf ( n ) )
: [ namesOf ( node ) ]
} )
// 会被跳过(已存在同名)的个数
const batchSkippedCount = computed ( ( ) => {
const first = batchGenerateForm . rules [ 0 ]
if ( ! first ) return 0
const s = Number ( first . start )
const e = Number ( first . end )
if ( ! ( s > 0 && e >= s ) ) return 0
const pad = Number ( first . pad ) || 1
const wanted : string [ ] = [ ]
for ( let n = s ; n <= e ; n ++ ) wanted . push ( ` ${ first . prefix || '' } ${ String ( n ) . padStart ( pad , '0' ) } ` )
let hit = 0
for ( const have of batchExistingNames . value ) {
hit += wanted . filter ( ( n ) => have . has ( n ) ) . length
}
return hit
} )
// 逐层的生成结构,供「生成结构预览」用。
// 每层的总量 = 上一层的总量 × 本层规则个数;第 1 层还要扣掉重名跳过的。
// (第 2 层往下的父节点都是本次新建的,不可能撞名。)
// ★ 预览数字唯一来源就是这里 —— 结构表上的每层数字加起来正好等于最终提交后
// 后端会新建的个数,不会出现"预览 10、实际 7"这种对不上的情况。
const previewLevels = computed ( ( ) => {
const rules = batchGenerateForm . rules
const seeds = batchSeedCount . value
if ( ! seeds ) return [ ]
const out : { level : number ; per : number ; total : number ; names : string [ ] ; truncated : boolean } [ ] = [ ]
let prevTotal = 0
for ( let i = 0 ; i < rules . length ; i ++ ) {
const r = rules [ i ]
const s = Number ( r . start )
const e = Number ( r . end )
if ( ! ( s > 0 && e >= s ) ) return [ ] // 规则没填完 → 整块不显示
const per = e - s + 1
const total = i === 0 ? seeds * per - batchSkippedCount . value : prevTotal * per
if ( total <= 0 ) break // 上一层没建成 → 下面也不会有
const pad = Number ( r . pad ) || 1
const cap = Math . min ( per , 6 ) // 名字只列前 6 个,其余折叠成「等 N 个」
const names : string [ ] = [ ]
for ( let k = s ; k < s + cap ; k ++ ) names . push ( ` ${ r . prefix || '' } ${ String ( k ) . padStart ( pad , '0' ) } ` )
out . push ( { level : i + 1 , per , total , names , truncated : per > cap } )
prevTotal = total
}
return out
} )
// 实际会**新建**的节点数 = 各层之和。必须扣掉跳过的那些 ——
// full_path 上有唯一约束 uniq_full_path, 同名不跳过的话整个请求会 500 并整体回滚,
// 连本该新建的也一并丢失(实测:已有 1/2/3 时生成 1~10, 4~10 那 7 个全没落库)。
const previewCount = computed ( ( ) => previewLevels . value . reduce ( ( s , l ) => s + l . total , 0 ) )
// 起点本身的 full_path( 顶级生成时为空 —— 没有父级可拼)
const batchRootPath = computed ( ( ) => {
if ( batchGenerateForm . fromSelection ) {
const n = findNodeById ( treeData . value , batchGenerateForm . seedIds [ 0 ] )
return n ? . full _path || ''
}
if ( ! batchGenerateForm . parent _id || ! batchGenerateForm . parentPath ) return ''
const node = findNodeByPath ( treeData . value , batchGenerateForm . parentPath )
if ( ! node ) return ''
if ( batchGenerateForm . leaf _only ) return collectLeaves ( node ) [ 0 ] ? . full _path || ''
return node . full _path || ''
} )
// 第一条完整路径示例 = 起点 full_path + 每层第一个名字。
// 这就是最终会落进库存表 warehouse_location 的值(图里那种 L3/4/4/1) ,
// 光看每层的 D1 / 1 / 1 还得自己在脑子里拼。
// ★ 第 1 层要跳过已存在的同名 —— 否则示例会给出一个实际根本没建的路径。
// 第 2 层往下的父节点都是本次新建的,不可能撞名。
const batchSamplePath = computed ( ( ) => {
const levels = previewLevels . value
if ( ! levels . length ) return ''
const existing = batchExistingNames . value [ 0 ]
const parts : string [ ] = [ ]
const root = batchRootPath . value
if ( root ) parts . push ( root )
for ( let i = 0 ; i < levels . length ; i ++ ) {
const names = levels [ i ] . names
const pick = ( i === 0 && existing )
? ( names . find ( ( n ) => ! existing . has ( n ) ) || names [ 0 ] )
: names [ 0 ]
parts . push ( pick )
}
return parts . join ( '/' )
} )
// 结构预览的根节点文案
const batchRootLabel = computed ( ( ) => {
if ( batchGenerateForm . fromSelection ) return ` 勾选的 ${ batchGenerateForm . seedIds . length } 个库位 `
if ( ! batchGenerateForm . parent _id ) return '顶级(无父级)'
if ( batchGenerateForm . leaf _only ) return ` ${ batchGenerateForm . parentName } 下的 ${ batchLeafCount . value } 个末级库位 `
return batchGenerateForm . parentName || '—'
} )
const openBatchGenerate = ( ) => {
batchGenerateForm . fromSelection = false
batchGenerateForm . seedIds = [ ]
batchGenerateForm . parent _id = null
batchGenerateForm . parentName = '无(顶级)'
batchGenerateForm . parentPath = ''
batchGenerateForm . leaf _only = false
batchGenerateForm . company _name = hasCrossDomain . value ? null : ( userStore . companyName || '' )
batchGenerateForm . rules = [ { prefix : '' , start : 1 , end : 10 , pad : 1 } ]
batchGenerateVisible . value = true
}
// 「批量选择」模式下点「批量新增下级」:把勾中的库位当起点
const openBatchAddForSelection = ( ) => {
if ( ! checkedIds . value . length ) return
batchGenerateForm . fromSelection = true
batchGenerateForm . seedIds = [ ... checkedIds . value ]
batchGenerateForm . parent _id = null
batchGenerateForm . parentName = ''
batchGenerateForm . parentPath = ''
batchGenerateForm . leaf _only = false
batchGenerateForm . rules = [ { prefix : '' , start : 1 , end : 10 , pad : 1 } ]
batchGenerateVisible . value = true
}
@ -483,14 +912,40 @@ const handleBatchGenerate = async () => {
return
}
const fromSelection = batchGenerateForm . fromSelection
const isTopLevel = ! fromSelection && ! batchGenerateForm . parent _id
if ( isTopLevel && hasCrossDomain . value && batchGenerateForm . company _name === null ) {
ElMessage . warning ( '请选择公司归属' )
return
}
if ( fromSelection && ! batchGenerateForm . seedIds . length ) {
ElMessage . warning ( '没有勾选任何库位' )
return
}
try {
batchLoading . value = true
const res = await batchGenerateWarehouse ( {
parent _id : batchGenerateForm . parent _id ,
rules : batchGenerateForm . rules
} )
const payload : any = { rules : batchGenerateForm . rules }
if ( fromSelection ) {
// 勾选模式下 parent_id / leaf_only / company_name 全部不参与 ——
// 归属由每个起点各自继承,后端按各自的父节点算
payload . parent _ids = batchGenerateForm . seedIds
} else {
payload . parent _id = batchGenerateForm . parent _id
if ( isTopLevel && hasCrossDomain . value ) payload . company _name = batchGenerateForm . company _name
if ( ! isTopLevel && batchGenerateForm . leaf _only ) payload . leaf _only = true
}
const res = await batchGenerateWarehouse ( payload )
if ( res . code === 200 ) {
ElMessage . success ( ` 生成成功,共生成 ${ res . data . generat ed_count } 个库位 ` )
const seed = res . data . se ed_count || 1
let scope = ''
if ( fromSelection ) scope = ` (起点:勾选的 ${ seed } 个库位) `
else if ( batchGenerateForm . leaf _only ) scope = ` (起点: ${ seed } 个末级库位) `
// 直接用后端的 msg —— 它会写明「新建 N 个、跳过 M 个已存在同名」,
// 前端另拼一套容易和真实结果对不上
ElMessage . success ( ` ${ res . msg } ${ scope } ` )
// 从勾选进来的,生成后清掉选择,免得同一批被重复点
if ( fromSelection ) cancelSelectMode ( )
batchGenerateVisible . value = false
await loadWarehouseTree ( )
} else {
@ -505,7 +960,7 @@ const handleBatchGenerate = async () => {
// 打开库位管理弹窗
const openWarehouseDialog = async ( ) => {
await loadWarehouseTree ( )
await Promise . all ( [ loadWarehouseTree ( ) , loadCompanies ( ) ] )
warehouseDialogVisible . value = true
}
@ -528,6 +983,9 @@ const handleAddTopLevel = () => {
locationForm . parentName = '无(顶级)'
locationForm . name = ''
locationForm . is _enabled = true
// 跨域角色必须显式选( null) , 不给默认值 —— 默认全局共享会让两司都凭空多出一个库位
locationForm . company _name = hasCrossDomain . value ? null : ( userStore . companyName || '' )
editingCompany . value = null
locationFormTitle . value = '新增顶级库位'
locationFormVisible . value = true
}
@ -539,6 +997,9 @@ const handleAddChild = (data: any) => {
locationForm . parentName = data . full _path || data . name
locationForm . name = ''
locationForm . is _enabled = true
// 子级归属由后端强制继承父级,这里只为把继承到的值显示出来
locationForm . company _name = data . company _name ? ? ''
editingCompany . value = null
locationFormTitle . value = ` 新增下级库位: ${ data . name } `
locationFormVisible . value = true
}
@ -550,6 +1011,8 @@ const handleEdit = (data: any) => {
locationForm . parentName = data . parent _id ? data . parent _full _path || '无(顶级)' : '无(顶级)'
locationForm . name = data . name
locationForm . is _enabled = data . is _enabled
locationForm . company _name = data . company _name ? ? ''
editingCompany . value = data . company _name ? ? ''
locationFormTitle . value = ` 编辑库位: ${ data . name } `
locationFormVisible . value = true
}
@ -581,18 +1044,46 @@ const saveLocation = async () => {
ElMessage . warning ( '请输入库位名称' )
return
}
const isTopLevel = ! locationForm . parent _id
// 顶级库位由跨域角色指定归属,必须显式选择 ——
// 否则 null 会静默变成「全局共享」(两司都能看见),事后很难发现。
if ( isTopLevel && hasCrossDomain . value && locationForm . company _name === null ) {
ElMessage . warning ( '请选择公司归属' )
return
}
// 归属变更会级联整棵子树,先让操作者确认影响面
const companyChanged = isTopLevel && hasCrossDomain . value && locationForm . id !== undefined
&& ( locationForm . company _name || '' ) !== ( editingCompany . value || '' )
if ( companyChanged ) {
try {
await ElMessageBox . confirm (
` 归属将由「 ${ companyLabel ( editingCompany . value ) } 」改为「 ${ companyLabel ( locationForm . company _name ) } 」, ` +
` 该库位下的所有子库位会一并变更。确定继续? ` ,
'确认变更归属' ,
{ type : 'warning' , confirmButtonText : '确定变更' , cancelButtonText : '取消' }
)
} catch ( e ) {
return // 用户取消
}
}
try {
locationLoading . value = true
const payload : any = {
name : locationForm . name . trim ( ) ,
is _enabled : locationForm . is _enabled
}
if ( locationForm . parent _id ) {
payload . parent _id = locationForm . parent _id
} else if ( hasCrossDomain . value ) {
// '' = 全局共享(后端 _normalize_company 折算成 NULL)
payload . company _name = locationForm . company _name
}
let res
if ( locationForm . id ) {
// 编辑
@ -602,9 +1093,16 @@ const saveLocation = async () => {
// 新增
res = await createWarehouse ( payload )
}
if ( res . code === 200 ) {
ElMessage . success ( '保存成功' )
// 把连带影响如实回显 —— 改名会同步台账快照,改归属会级联子树,
// 操作者不被告知的话,会以为只动了一个节点
let msg = '保存成功'
if ( res . company _affected > 1 ) msg += ` ,已同步变更 ${ res . company _affected } 个库位的归属 `
const snap = res . snapshot _affected || { }
const snapTotal = Object . values ( snap ) . reduce ( ( a : number , b : any ) => a + Number ( b ) , 0 )
if ( snapTotal > 0 ) msg += ` ,并同步了 ${ snapTotal } 条台账快照 `
ElMessage . success ( msg )
locationFormVisible . value = false
await loadWarehouseTree ( )
} else {
@ -716,6 +1214,23 @@ const handleNav = (path: string) => {
. node - label {
font - size : 14 px ;
}
/* 公司分组头 */
. tree - group {
flex : 1 ;
display : flex ;
align - items : center ;
gap : 10 px ;
cursor : pointer ;
font - weight : 600 ;
}
. group - label {
font - size : 14 px ;
}
. group - count {
font - size : 12 px ;
font - weight : 400 ;
color : # 909399 ;
}
. node - actions {
display : flex ;
gap : 5 px ;
@ -728,6 +1243,103 @@ const handleNav = (path: string) => {
background : # f5f7fa ;
border - radius : 4 px ;
}
/* 生成位置单选:两个选项文案都长,竖排免得挤出弹窗 */
. batch - scope {
display : flex ;
flex - direction : column ;
align - items : flex - start ;
gap : 4 px ;
}
. batch - scope . el - radio {
margin - right : 0 ;
height : auto ;
align - items : flex - start ;
}
. scope - hint {
color : # 909399 ;
}
/* 选择模式下的「已选 N 个」 */
. select - hint {
font - size : 13 px ;
color : # 909399 ;
margin : 0 4 px ;
}
/* 生成结构预览:逐层缩进,一眼看出几层、每层长什么样 */
. batch - structure {
border : 1 px solid # e4e7ed ;
border - radius : 4 px ;
padding : 10 px 12 px ;
background : # fafafa ;
font - size : 13 px ;
}
. struct - row {
display : flex ;
align - items : baseline ;
gap : 8 px ;
line - height : 22 px ;
}
. struct - root - label {
font - weight : 600 ;
color : # 303133 ;
}
. struct - branch {
color : # c0c4cc ;
flex : none ;
}
. struct - level {
flex : none ;
color : # 606266 ;
}
. struct - hint {
color : # c0c4cc ;
font - size : 12 px ;
margin - left : 4 px ;
}
. struct - names {
color : # 409 eff ;
font - family : monospace ;
flex : 1 ;
overflow : hidden ;
text - overflow : ellipsis ;
white - space : nowrap ;
}
. struct - count {
flex : none ;
color : # 909399 ;
}
. struct - sample {
margin - top : 8 px ;
padding - top : 8 px ;
border - top : 1 px dashed # dcdfe6 ;
color : # 909399 ;
text - align : center ;
}
. struct - sample code {
font - family : monospace ;
color : # 409 eff ;
background : # ecf5ff ;
padding : 1 px 8 px ;
border - radius : 3 px ;
margin - left : 2 px ;
}
. struct - total {
margin - top : 8 px ;
padding - top : 8 px ;
border - top : 1 px dashed # dcdfe6 ;
text - align : center ;
color : # 606266 ;
}
. struct - total strong {
color : # 409 eff ;
font - size : 15 px ;
}
. struct - skip {
color : # e6a23c ;
}
. batch - preview {
margin - top : 15 px ;
padding : 10 px ;