perf: 库位选择器改为按需懒加载——新增 /warehouse/children 接口,WarehouseSelector 移除全量树依赖

This commit is contained in:
yueli
2026-09-04 10:51:49 +08:00
parent 0535cd8bd7
commit 7cdc0bc3a1
6 changed files with 124 additions and 139 deletions

View File

@ -54,6 +54,41 @@ def get_tree():
}), 500
@warehouse_bp.route('/children', methods=['GET'])
def get_children():
"""
懒加载获取指定库位的直接子节点parent_id 省略/为空 = 顶层)。
每个节点附带 has_children 标记,前端据此渲染「进入下级」而非点进去才知道。
与 /tree 行为一致(不额外过滤 is_enabled、按 name 升序)。
"""
try:
parent_id = request.args.get('parent_id', type=int)
if parent_id is None:
nodes = SysWarehouseLocation.query.filter(
SysWarehouseLocation.parent_id.is_(None)
).order_by(SysWarehouseLocation.name.asc()).all()
else:
nodes = SysWarehouseLocation.query.filter(
SysWarehouseLocation.parent_id == parent_id
).order_by(SysWarehouseLocation.name.asc()).all()
# 一次查询所有"有子节点"的 parent_id用于 has_children 判断(避免 N+1
parent_with_children = set(
cid for (cid,) in db.session.query(SysWarehouseLocation.parent_id)
.filter(SysWarehouseLocation.parent_id.isnot(None)).distinct().all()
)
data = []
for node in nodes:
d = node.to_dict()
d['has_children'] = node.id in parent_with_children
data.append(d)
return jsonify({'code': 200, 'msg': 'success', 'data': data})
except Exception as e:
return jsonify({'code': 500, 'msg': str(e), 'data': None}), 500
@warehouse_bp.route('', methods=['POST'])
@jwt_required()
@audit_log(

View File

@ -8,6 +8,15 @@ export function getWarehouseTree() {
})
}
// 懒加载获取指定库位的直接子节点parentId 省略/为空 = 顶层)
export function getWarehouseChildren(parentId?: number | null) {
return request({
url: '/v1/warehouse/children',
method: 'get',
params: parentId != null ? { parent_id: parentId } : {}
})
}
// 创建库位
export function createWarehouse(data: any) {
return request({

View File

@ -69,18 +69,18 @@
<!-- 左侧热区点击进入下级或选中 -->
<div
class="item-main"
:class="{ 'has-children': item.children && item.children.length > 0 }"
:class="{ 'has-children': hasChild(item) }"
@click="handleItemClick(item)"
>
<el-icon class="item-icon"><Location /></el-icon>
<span class="item-name">{{ item.name }}</span>
<el-icon v-if="item.children && item.children.length > 0" class="item-arrow">
<el-icon v-if="hasChild(item)" class="item-arrow">
<ArrowRight />
</el-icon>
</div>
<!-- 右侧操作区 -->
<div class="item-actions">
<el-tag v-if="!item.children || item.children.length === 0" type="info" size="small">
<el-tag v-if="!hasChild(item)" type="info" size="small">
末级
</el-tag>
<el-button
@ -102,26 +102,27 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, reactive, computed, watch } from 'vue'
import { ArrowDown, ArrowLeft, ArrowRight, Location } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getWarehouseChildren } from '@/api/common/warehouse'
interface WarehouseItem {
id: number
parent_id: number | null
name: string
full_path: string
level: number
has_children?: boolean
children?: WarehouseItem[]
}
interface Props {
modelValue?: string
options: WarehouseItem[]
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
options: () => []
modelValue: ''
})
const emit = defineEmits<{
@ -131,83 +132,86 @@ const emit = defineEmits<{
const popoverRef = ref()
const popoverVisible = ref(false)
const triggerRef = ref<HTMLElement>()
const loadingKey = ref<string | null>(null)
// 当前导航路径(保存每一层的节点信息)
const currentPath = ref<WarehouseItem[]>([])
// 当前显示的列表数据
const currentList = computed(() => {
if (currentPath.value.length === 0) {
// 顶层:显示根节点列表
return props.options
}
// 非顶层:显示当前层级最后一个节点的 children
const lastNode = currentPath.value[currentPath.value.length - 1]
return lastNode?.children || []
// ★ 懒加载缓存key = 'root'(顶层)或 String(parentId)
const cache = reactive<Record<string, WarehouseItem[]>>({})
// 是否有下一级(优先用后端 has_children兼容旧树结构的 children 字段)
const hasChild = (item: WarehouseItem): boolean =>
!!item.has_children || !!(item.children && item.children.length > 0)
// 当前层缓存 key'root' 或父节点 id
const currentParentKey = computed<string>(() => {
if (currentPath.value.length === 0) return 'root'
return String(currentPath.value[currentPath.value.length - 1].id)
})
// 监听弹窗显示状态,恢复选中层级
watch(popoverVisible, (visible) => {
// 当前层父 idnull = 顶层)
const currentParentId = computed<number | null>(() => {
if (currentPath.value.length === 0) return null
return currentPath.value[currentPath.value.length - 1].id
})
// 当前显示的列表(懒加载缓存)
const currentList = computed<WarehouseItem[]>(() => cache[currentParentKey.value] || [])
// 懒加载某层子节点(已缓存则跳过)
const ensureLoaded = async (key: string, parentId: number | null) => {
if (cache[key]) return
if (loadingKey.value === key) return
loadingKey.value = key
try {
const res: any = await getWarehouseChildren(parentId)
cache[key] = res.data || []
} catch (e) {
cache[key] = [] // 失败置空,避免下次重复请求
} finally {
loadingKey.value = null
}
}
// 打开弹窗:加载顶层,有选中值时逐级恢复导航路径
watch(popoverVisible, async (visible) => {
if (visible) {
restoreSelection()
currentPath.value = []
await ensureLoaded('root', null)
if (props.modelValue) {
await restoreSelection(props.modelValue)
}
}
})
// 根据 modelValue 恢复选择状态
const restoreSelection = () => {
if (!props.modelValue) {
// 无选中值,重置到顶层
currentPath.value = []
return
}
// 根据 full_path 查找父级路径
const path = findPathByFullPath(props.options, props.modelValue)
if (path) {
// 找到路径:还原 currentPath不包含最后一个节点因为它是当前选中的节点
currentPath.value = path
} else {
// 未找到(可能树结构已变化),重置到顶层
currentPath.value = []
// 按 full_path 逐级懒加载并恢复路径(不含末级节点本身,末级是待选中的)
const restoreSelection = async (fullPath: string) => {
const parts = fullPath.split('/').filter(Boolean)
if (parts.length <= 1) return // 顶层库位无需恢复子路径
for (let i = 0; i < parts.length - 1; i++) {
const key = i === 0 ? 'root' : String(currentPath.value[i - 1]?.id ?? '')
const parentId = i === 0 ? null : (currentPath.value[i - 1]?.id ?? null)
await ensureLoaded(key, parentId)
const list = cache[key] || []
const expected = parts.slice(0, i + 1).join('/')
const node = list.find((n) => n.full_path === expected)
if (!node) {
currentPath.value = [] // 树结构可能变化,回到顶层
return
}
currentPath.value.push(node)
}
}
// 根据 full_path 在树中查找从根到目标节点的路径
const findPathByFullPath = (
tree: WarehouseItem[],
targetFullPath: string,
currentPath: WarehouseItem[] = []
): WarehouseItem[] | null => {
for (const node of tree) {
// 检查当前节点是否匹配
if (node.full_path === targetFullPath) {
return currentPath
}
// 递归检查子节点
if (node.children && node.children.length > 0) {
const result = findPathByFullPath(node.children, targetFullPath, [...currentPath, node])
if (result) {
return result
}
}
}
return null
}
// 处理弹窗显示/隐藏
// 弹窗可见性变化
const handleVisibleChange = (visible: boolean) => {
popoverVisible.value = visible
if (!visible) {
// 关闭时重置导航
currentPath.value = []
}
if (!visible) currentPath.value = []
}
// 打开弹窗
const handleOpen = () => {
popoverVisible.value = true
}
const handleOpen = () => { popoverVisible.value = true }
// 清空选择
const handleClear = () => {
@ -217,28 +221,23 @@ const handleClear = () => {
// 返回上一级
const handleBack = () => {
if (currentPath.value.length > 0) {
currentPath.value.pop()
}
if (currentPath.value.length > 0) currentPath.value.pop()
}
// 返回顶层
const handleGoHome = () => {
currentPath.value = []
}
const handleGoHome = () => { currentPath.value = [] }
// 进入下一级
const handleDrillDown = (item: WarehouseItem) => {
// 进入下一级:先确保子级已加载,再推入导航
const handleDrillDown = async (item: WarehouseItem) => {
await ensureLoaded(String(item.id), item.id)
currentPath.value.push(item)
}
// 点击列表项 - 左侧热区逻辑
// 点击列表项
const handleItemClick = (item: WarehouseItem) => {
if (item.children && item.children.length > 0) {
// 有子节点:进入下一级
if (hasChild(item)) {
handleDrillDown(item)
} else {
// 末级:直接选中
handleSelect(item)
}
}

View File

@ -384,10 +384,7 @@
</el-col>
<el-col :span="6">
<el-form-item label="库位" prop="warehouse_location">
<WarehouseSelector
v-model="form.warehouse_location"
:options="warehouseOptions"
/>
<WarehouseSelector v-model="form.warehouse_location" />
</el-form-item>
</el-col>
</el-row>
@ -806,7 +803,6 @@ import {
} from '@/api/inbound/buy'
import { getApprovedUnstockedRequests } from '@/api/purchase'
import {getLabelPreview, executePrint} from '@/api/common/print'
import { getWarehouseTree } from '@/api/common/warehouse'
import { usePasteUpload } from '@/hooks/usePasteUpload'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import WarehouseSelector from '@/components/WarehouseSelector.vue'
@ -980,9 +976,6 @@ const purchaseImportPage = ref(1)
const purchaseImportKeyword = ref('')
const purchaseImportSelected = ref<any>(null)
// 库位级联选择器数据
const warehouseOptions = ref<any[]>([])
const advancedFilterVisible = ref(false)
const advancedConditions = ref([{ field: '', operator: '', value: '' }])
const fieldOptions = computed(() => {
@ -1575,17 +1568,6 @@ const buildCategoryTree = (categories: string[]) => {
return root;
};
// 加载库位树数据
const loadWarehouseTree = async () => {
try {
const res = await getWarehouseTree()
if (res.code === 200) {
warehouseOptions.value = res.data || []
}
} catch (e) {
console.error('加载库位树失败', e)
}
}
const resetQuery = () => {
queryParams.keyword = ''
@ -2130,7 +2112,6 @@ onMounted(() => {
initColumnPermissions()
fetchData()
fetchOptions()
loadWarehouseTree()
})
</script>

View File

@ -354,10 +354,7 @@
<el-row :gutter="24">
<el-col :span="6"><el-form-item label="SKU" prop="sku"><el-input v-model="form.sku" placeholder="自动生成" disabled /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location">
<WarehouseSelector
v-model="form.warehouse_location"
:options="warehouseOptions"
/>
<WarehouseSelector v-model="form.warehouse_location" />
</el-form-item></el-col>
<el-col :span="6"><el-form-item label="入库日期"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled /></el-form-item></el-col>
</el-row>
@ -620,7 +617,6 @@ import WarehouseSelector from '@/components/WarehouseSelector.vue'
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
import TrackScanDialog from '@/components/TrackScanDialog.vue'
import { getLabelPreview, executePrint } from '@/api/common/print'
import { getWarehouseTree } from '@/api/common/warehouse'
import { usePasteUpload } from '@/hooks/usePasteUpload'
import { useUserStore } from '@/stores/user'
@ -759,9 +755,6 @@ const scannerDialogVisible = ref(false)
// 顶部扫码入库面板 (Track 身份证自动填充物料+序列号+订单号)
const trackScanVisible = ref(false)
// 库位级联选择器数据
const warehouseOptions = ref<any[]>([])
// ================= 第一步:声明基础数据 =================
// [核心优化] 所有列定义
@ -1208,18 +1201,6 @@ const buildCategoryTree = (categories: string[]) => {
return root;
};
// 加载库位树数据
const loadWarehouseTree = async () => {
try {
const res = await getWarehouseTree()
if (res.code === 200) {
warehouseOptions.value = res.data || []
}
} catch (e) {
console.error('加载库位树失败', e)
}
}
const resetQuery = () => {
queryParams.keyword = ''
queryParams.searchField = 'all'
@ -1532,7 +1513,6 @@ onMounted(() => {
initColumnPermissions()
fetchData()
fetchOptions()
loadWarehouseTree()
})
// 成本计算监听

View File

@ -393,10 +393,7 @@
<el-col :span="6"><el-form-item label="编码/SKU" prop="sku"><el-input v-model="form.sku" placeholder="系统自动生成" disabled/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="入库日期" prop="in_date"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location">
<WarehouseSelector
v-model="form.warehouse_location"
:options="warehouseOptions"
/>
<WarehouseSelector v-model="form.warehouse_location" />
</el-form-item></el-col>
</el-row>
@ -664,7 +661,6 @@ import WarehouseSelector from '@/components/WarehouseSelector.vue'
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
import TrackScanDialog from '@/components/TrackScanDialog.vue'
import {getLabelPreview, executePrint} from '@/api/common/print'
import { getWarehouseTree } from '@/api/common/warehouse'
import { usePasteUpload } from '@/hooks/usePasteUpload'
import { useUserStore } from '@/stores/user'
@ -826,9 +822,6 @@ const scannerDialogVisible = ref(false)
// 顶部扫码入库面板 (Track 身份证自动填充物料+序列号)
const trackScanVisible = ref(false)
// 库位级联选择器数据
const warehouseOptions = ref<any[]>([])
const entryMode = ref('batch')
const modeLocked = ref(false)
@ -1312,17 +1305,6 @@ const buildCategoryTree = (categories: string[]) => {
return root;
};
// 加载库位树数据
const loadWarehouseTree = async () => {
try {
const res = await getWarehouseTree()
if (res.code === 200) {
warehouseOptions.value = res.data || []
}
} catch (e) {
console.error('加载库位树失败', e)
}
}
const resetQuery = () => {
queryParams.keyword = ''
@ -1649,7 +1631,6 @@ onMounted(() => {
initColumnPermissions()
fetchData()
fetchOptions()
loadWarehouseTree()
})
</script>