perf: 库位选择器改为按需懒加载——新增 /warehouse/children 接口,WarehouseSelector 移除全量树依赖
This commit is contained in:
@ -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(
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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) => {
|
||||
// 当前层父 id(null = 顶层)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
// 成本计算监听
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user