Files
admin-web/src/modules/warehouse-resources/locations/pages/LocationsView.vue
T
2026-08-06 14:55:17 +08:00

792 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Eye, MapPin, MoreHorizontal, Pencil, Plus, Power, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import {
changePlatformLocationStatus,
createPlatformLocation,
deletePlatformLocation,
getPlatformLocation,
listPlatformLocations,
updatePlatformLocation,
type EditablePlatformLocationStatus,
type PlatformLocationDetail,
type PlatformLocationQuery,
type PlatformLocationResource,
type PlatformLocationStatus,
type PlatformLocationType,
type SavePlatformLocationRequest,
} from '../api/platformLocations'
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '../../../warehouses/api/platformWarehouses'
type DrawerMode = 'view' | 'create' | 'edit'
type LocationFilters = QueryModel & {
warehouseId: string
zoneCode: string
locationCode: string
locationType: PlatformLocationType | ''
status: PlatformLocationStatus | ''
}
type AppliedLocationFilters = {
warehouseId: string
zoneCode: string
locationCode: string
locationType: PlatformLocationType | ''
status: PlatformLocationStatus | ''
}
const access = useAccessPreviewStore()
const warehouses = ref<PlatformWarehouseResource[]>([])
const warehouseLoading = ref(false)
const warehouseError = ref<ApiErrorDetails | null>(null)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const rows = ref<PlatformLocationResource[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const drawerOpen = ref(false)
const drawerMode = ref<DrawerMode>('view')
const drawerLoading = ref(false)
const drawerError = ref<ApiErrorDetails | null>(null)
const detail = ref<PlatformLocationDetail | null>(null)
const saving = ref(false)
const actionLoadingIds = ref(new Set<string>())
const formRef = ref<FormInstance>()
let warehouseLoadSequence = 0
let locationLoadSequence = 0
const filters = ref<LocationFilters>({ warehouseId: '', zoneCode: '', locationCode: '', locationType: '', status: '' })
const appliedFilters = ref<AppliedLocationFilters>({ warehouseId: '', zoneCode: '', locationCode: '', locationType: '', status: '' })
const selectedWarehouseId = computed(() => String(filters.value.warehouseId || ''))
const queryFields = computed<QueryField[]>(() => [
{
key: 'warehouseId',
type: 'select',
width: 300,
placeholder: warehouseLoading.value ? '仓库加载中' : '全部仓库',
props: {
'aria-label': '所属仓库',
filterable: true,
disabled: selectionDisabled.value || warehouseLoading.value || Boolean(warehouseError.value),
},
options: warehouses.value.map((warehouse) => ({
value: String(warehouse.id),
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
})),
},
{ key: 'zoneCode', type: 'input', width: 210, placeholder: '库区编码', props: { maxlength: 50, 'aria-label': '库区编码' } },
{ key: 'locationCode', type: 'input', width: 220, placeholder: '库位编码', props: { maxlength: 50, 'aria-label': '库位编码' } },
{
key: 'locationType', type: 'select', width: 170, placeholder: '全部类型', props: { 'aria-label': '库位类型' },
options: [
{ label: '货架位', value: 'SHELF' },
{ label: '托盘位', value: 'PALLET' },
{ label: '地堆位', value: 'FLOOR' },
{ label: '流利架', value: 'FLOW_RACK' },
],
},
{
key: 'status', type: 'select', width: 160, placeholder: '全部状态', props: { 'aria-label': '库位状态' },
options: [
{ label: '可用', value: 'AVAILABLE' },
{ label: '已预占', value: 'RESERVED' },
{ label: '已占用', value: 'OCCUPIED' },
{ label: '已停用', value: 'DISABLED' },
],
},
])
const emptyForm = () => ({
id: '',
customerId: '',
warehouseId: '',
zoneCode: '',
zoneName: '',
locationCode: '',
locationName: '',
locationType: 'SHELF' as PlatformLocationType,
maxWeightKg: null as number | null,
status: 'AVAILABLE' as EditablePlatformLocationStatus,
remark: '',
version: 0,
})
const form = reactive(emptyForm())
const rules: FormRules<typeof form> = {
warehouseId: [{ required: true, message: '请选择国内仓', trigger: 'change' }],
zoneCode: [{ required: true, message: '请输入库区编码', trigger: 'blur' }],
zoneName: [{ required: true, message: '请输入库区名称', trigger: 'blur' }],
locationCode: [
{ required: true, message: '请输入库位编码', trigger: 'blur' },
{
validator: (_rule, value: string, callback) => {
if (value.trim().toUpperCase() === 'A0') callback(new Error('A0 是系统共享溢出位保留编码'))
else callback()
},
trigger: 'blur',
},
],
locationType: [{ required: true, message: '请选择库位类型', trigger: 'change' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
const selectedWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === form.warehouseId) ?? null)
const domesticWarehouses = computed(() => warehouses.value.filter((warehouse) => (
warehouse.warehouseType === 'DOMESTIC' && warehouse.status === 'ACTIVE'
)))
const editorOpen = computed(() => drawerOpen.value && drawerMode.value !== 'view')
const editorTitle = computed(() => drawerMode.value === 'create' ? '新增库位' : '编辑库位')
const drawerTitle = computed(() => drawerMode.value === 'view' ? '库位详情' : editorTitle.value)
const editorPermission = computed(() => drawerMode.value === 'create' ? 'platform:location:create' : 'platform:location:edit')
const selectionDisabled = computed(() => drawerOpen.value || saving.value || actionLoadingIds.value.size > 0)
const filtered = computed(() => Object.values(appliedFilters.value).some((value) => String(value || '').trim()))
const emptyDescription = computed(() => filtered.value ? '暂无符合筛选条件的库位' : '暂无库位数据')
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'location-editor',
label: editorTitle,
isOpen: editorOpen,
model: form,
requiredPermission: editorPermission,
})
const statusMeta: Record<PlatformLocationStatus, { label: string; type: 'success' | 'warning' | 'danger' | 'info' }> = {
AVAILABLE: { label: '可用', type: 'success' },
RESERVED: { label: '已预占', type: 'warning' },
OCCUPIED: { label: '已占用', type: 'danger' },
DISABLED: { label: '已停用', type: 'info' },
}
const typeLabels: Record<PlatformLocationType, string> = {
SHELF: '货架位',
PALLET: '托盘位',
FLOOR: '地堆位',
FLOW_RACK: '流利架',
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ key: 'location', label: '库位编码', minWidth: 190, slotName: 'location' },
{ key: 'warehouse', label: '所属仓库', minWidth: 210, slotName: 'warehouse' },
{ key: 'zone', label: '库区', minWidth: 150, slotName: 'zone' },
{ key: 'locationType', label: '库位类型', width: 120, slotName: 'locationType' },
{ key: 'maxWeightKg', label: '最大承重(kg)', minWidth: 140, slotName: 'maxWeightKg' },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'remark', label: '备注', minWidth: 220, slotName: 'remark' },
]
function apiId(value: string) {
return value
}
function optionalText(value: string) {
return value.trim() || null
}
function formatDateTime(value?: string | null) {
return value?.replace('T', ' ').slice(0, 16) || '—'
}
function formatWeight(value: number | string | null) {
if (value === null || value === '') return '—'
const numeric = Number(value)
return Number.isFinite(numeric) ? `${numeric.toLocaleString('zh-CN', { maximumFractionDigits: 3 })} kg` : `${value} kg`
}
function canEdit(row: PlatformLocationResource) {
return access.can('platform:location:edit')
&& isDomesticWarehouse(row.warehouseId)
&& !row.sharedOverflow
&& (row.status === 'AVAILABLE' || row.status === 'DISABLED')
}
function canChangeStatus(row: PlatformLocationResource) {
return access.can('platform:location:status')
&& !row.sharedOverflow
&& (row.status === 'AVAILABLE' || row.status === 'DISABLED')
}
function canDelete(row: PlatformLocationResource) {
return access.can('platform:location:delete') && !row.sharedOverflow && row.status === 'DISABLED'
}
function actionError(error: unknown, fallback: string) {
const details = getApiErrorDetails(error, fallback)
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
}
function isDomesticWarehouse(warehouseId: number | string) {
return warehouses.value.some((warehouse) => (
String(warehouse.id) === String(warehouseId)
&& warehouse.warehouseType === 'DOMESTIC'
))
}
function isActiveDomesticWarehouse(warehouseId: number | string) {
return domesticWarehouses.value.some((warehouse) => String(warehouse.id) === String(warehouseId))
}
function requireCreateScope() {
if (!selectedWarehouse.value || !form.warehouseId) {
ElMessage.warning('请选择国内仓')
return null
}
return { customerId: apiId(String(selectedWarehouse.value.tenantId)), warehouseId: apiId(form.warehouseId) }
}
function recordScope(row: PlatformLocationResource) {
return { customerId: apiId(String(row.tenantId)), warehouseId: apiId(String(row.warehouseId)) }
}
async function loadWarehouses() {
const sequence = ++warehouseLoadSequence
warehouseLoading.value = true
warehouseError.value = null
warehouses.value = []
try {
const records = await listAllPlatformWarehouses()
if (sequence !== warehouseLoadSequence) return
warehouses.value = records
} catch (error) {
if (sequence !== warehouseLoadSequence) return
warehouseError.value = getApiErrorDetails(error, '仓库列表加载失败,请稍后重试')
} finally {
if (sequence === warehouseLoadSequence) warehouseLoading.value = false
}
}
async function loadLocations() {
const sequence = ++locationLoadSequence
loading.value = true
loadError.value = null
try {
const query: PlatformLocationQuery = {
page: page.value,
size: pageSize.value,
zoneCode: appliedFilters.value.zoneCode,
locationCode: appliedFilters.value.locationCode,
locationType: appliedFilters.value.locationType,
status: appliedFilters.value.status,
}
if (appliedFilters.value.warehouseId) query.warehouseId = apiId(appliedFilters.value.warehouseId)
const result = await listPlatformLocations(query)
if (sequence !== locationLoadSequence) return
rows.value = result.records
total.value = result.total
} catch (error) {
if (sequence !== locationLoadSequence) return
rows.value = []
total.value = 0
loadError.value = getApiErrorDetails(error, '库位列表加载失败,请稍后重试')
} finally {
if (sequence === locationLoadSequence) loading.value = false
}
}
function applyFilters(model: QueryModel) {
appliedFilters.value = {
warehouseId: String(model.warehouseId || ''),
zoneCode: String(model.zoneCode || ''),
locationCode: String(model.locationCode || ''),
locationType: (model.locationType || '') as PlatformLocationType | '',
status: (model.status || '') as PlatformLocationStatus | '',
}
if (page.value === 1) void loadLocations()
else page.value = 1
}
function assignForm(resource: PlatformLocationResource) {
Object.assign(form, {
id: String(resource.id),
customerId: String(resource.tenantId),
warehouseId: String(resource.warehouseId),
zoneCode: resource.zoneCode,
zoneName: resource.zoneName,
locationCode: resource.locationCode,
locationName: resource.locationName ?? '',
locationType: resource.locationType,
maxWeightKg: resource.maxWeightKg === null ? null : Number(resource.maxWeightKg),
status: resource.status as EditablePlatformLocationStatus,
remark: resource.remark ?? '',
version: resource.version,
})
}
function openCreate() {
Object.assign(form, emptyForm())
form.warehouseId = isActiveDomesticWarehouse(selectedWarehouseId.value) ? selectedWarehouseId.value : ''
form.customerId = selectedWarehouse.value ? String(selectedWarehouse.value.tenantId) : ''
form.status = selectedWarehouse.value?.status === 'INACTIVE' ? 'DISABLED' : 'AVAILABLE'
detail.value = null
drawerError.value = null
drawerMode.value = 'create'
captureBaseline()
drawerOpen.value = true
void formRef.value?.clearValidate()
}
async function loadDetail(row: PlatformLocationResource, mode: 'view' | 'edit') {
if (mode === 'edit' && !isDomesticWarehouse(row.warehouseId)) {
ElMessage.warning('仅支持编辑国内仓库位')
return
}
const scope = recordScope(row)
drawerMode.value = mode
drawerOpen.value = true
drawerLoading.value = true
drawerError.value = null
detail.value = null
try {
const result = await getPlatformLocation(scope.customerId, scope.warehouseId, row.id)
detail.value = result
if (mode === 'edit') {
assignForm(result.location)
captureBaseline()
void formRef.value?.clearValidate()
}
} catch (error) {
drawerError.value = getApiErrorDetails(error, '库位详情加载失败,请稍后重试')
} finally {
drawerLoading.value = false
}
}
function buildPayload(): SavePlatformLocationRequest {
return {
warehouseId: apiId(form.warehouseId),
zoneCode: form.zoneCode.trim(),
zoneName: form.zoneName.trim(),
locationCode: form.locationCode.trim(),
locationName: optionalText(form.locationName),
locationType: form.locationType,
maxWeightKg: form.maxWeightKg,
status: form.status,
remark: optionalText(form.remark),
...(form.id ? { version: form.version } : {}),
}
}
async function saveLocation() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
const warehouseAllowed = form.id
? isDomesticWarehouse(form.warehouseId)
: isActiveDomesticWarehouse(form.warehouseId)
if (!warehouseAllowed) {
ElMessage.error(form.id ? '库位仅支持归属国内仓' : '新增库位仅支持选择启用的国内仓')
return
}
const scope = form.id
? { customerId: apiId(form.customerId), warehouseId: apiId(form.warehouseId) }
: requireCreateScope()
if (!scope) return
saving.value = true
try {
const payload = buildPayload()
if (form.id) await updatePlatformLocation(scope.customerId, scope.warehouseId, form.id, payload)
else await createPlatformLocation(scope.customerId, payload)
drawerOpen.value = false
ElMessage.success(form.id ? '库位资料已更新' : '库位已创建')
await loadLocations()
} catch (error) {
actionError(error, '库位保存失败,请检查后重试')
if (form.id) await loadLocations()
} finally {
saving.value = false
}
}
function setActionLoading(id: number | string, active: boolean) {
const next = new Set(actionLoadingIds.value)
if (active) next.add(String(id))
else next.delete(String(id))
actionLoadingIds.value = next
}
async function toggleStatus(row: PlatformLocationResource) {
const scope = recordScope(row)
if (!canChangeStatus(row)) return
const nextStatus: EditablePlatformLocationStatus = row.status === 'AVAILABLE' ? 'DISABLED' : 'AVAILABLE'
const action = nextStatus === 'AVAILABLE' ? '启用' : '停用'
try {
await ElMessageBox.confirm(
nextStatus === 'DISABLED'
? `确定停用“${row.locationCode}”吗?停用前后端会再次检查是否存在活动预占或占用。`
: `确定重新启用“${row.locationCode}”吗?仅启用仓库中的库位可以恢复可用。`,
`${action}库位`,
{ type: nextStatus === 'AVAILABLE' ? 'success' : 'warning', confirmButtonText: `确认${action}`, cancelButtonText: '取消' },
)
} catch {
return
}
setActionLoading(row.id, true)
try {
await changePlatformLocationStatus(scope.customerId, scope.warehouseId, row.id, nextStatus, row.version)
ElMessage.success(`库位 ${row.locationCode} 已${action}`)
await loadLocations()
} catch (error) {
actionError(error, `${action}库位失败`)
await loadLocations()
} finally {
setActionLoading(row.id, false)
}
}
async function removeLocation(row: PlatformLocationResource) {
const scope = recordScope(row)
if (!canDelete(row)) return
try {
await ElMessageBox.confirm(
`确定删除已停用库位“${row.locationCode}”吗?系统会保留审计记录,此操作不能直接恢复。`,
'删除库位',
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' },
)
} catch {
return
}
setActionLoading(row.id, true)
try {
await deletePlatformLocation(scope.customerId, scope.warehouseId, row.id, row.version)
ElMessage.success(`库位 ${row.locationCode} 已删除`)
await loadLocations()
} catch (error) {
actionError(error, '删除库位失败')
await loadLocations()
} finally {
setActionLoading(row.id, false)
}
}
function handleMoreAction(row: PlatformLocationResource, command: string) {
if (command === 'status') void toggleStatus(row)
if (command === 'delete') void removeLocation(row)
}
watch(
() => access.can(editorPermission.value),
(allowed) => {
if (allowed || !editorOpen.value) return
drawerOpen.value = false
},
{ flush: 'sync' },
)
watch([page, pageSize], () => { void loadLocations() })
watch(() => form.warehouseId, (warehouseId) => {
if (drawerMode.value !== 'create') return
const warehouse = warehouses.value.find((item) => String(item.id) === warehouseId)
form.customerId = warehouse ? String(warehouse.tenantId) : ''
form.status = warehouse?.status === 'INACTIVE' ? 'DISABLED' : 'AVAILABLE'
})
onMounted(() => {
void loadWarehouses()
void loadLocations()
})
</script>
<template>
<div class="business-page location-page">
<QueryToolbar
v-model="filters"
ariaLabel="库位筛选"
:fields="queryFields"
@search="applyFilters"
/>
<el-alert v-if="warehouseError" type="error" :closable="false" show-icon class="state-alert">
<template #title>仓库列表加载失败</template>
<p>{{ warehouseError.message }}</p>
<ApiErrorMeta :details="warehouseError" />
<el-button size="small" @click="loadWarehouses()">重新加载仓库</el-button>
</el-alert>
<el-alert v-else-if="!warehouseLoading && domesticWarehouses.length === 0" type="info" :closable="false" show-icon title="暂无可选国内仓">
请先在仓库管理中建立国内仓,再维护库位。
</el-alert>
<el-alert v-if="loadError" type="error" :closable="false" show-icon class="state-alert">
<template #title>库位列表加载失败</template>
<p>{{ loadError.message }}</p>
<ApiErrorMeta :details="loadError" />
<el-button size="small" @click="loadLocations()">重新查询</el-button>
</el-alert>
<section class="surface-panel table-panel" aria-label="库位列表区域">
<div class="panel-heading">
<div><h2>库位列表</h2><p>共 {{ total }} 个库位</p></div>
<el-button
v-if="access.can('platform:location:create')"
type="primary"
@click="openCreate"
><Plus :size="16" />新增库位</el-button>
</div>
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
:columns="columns"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100, 200]"
row-key="id"
aria-label="库位列表"
:empty-text="emptyDescription"
>
<template #actions="{ row }">
<div class="table-actions location-actions">
<el-button link type="primary" :disabled="loading" @click="loadDetail(row as PlatformLocationResource, 'view')">
<Eye :size="15" />详情
</el-button>
<el-button
v-if="canEdit(row as PlatformLocationResource)"
link
type="primary"
:disabled="loading"
@click="loadDetail(row as PlatformLocationResource, 'edit')"
><Pencil :size="15" />编辑</el-button>
<el-dropdown
v-if="canChangeStatus(row as PlatformLocationResource) || canDelete(row as PlatformLocationResource)"
trigger="click"
:disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
@command="handleMoreAction(row as PlatformLocationResource, String($event))"
>
<el-button link type="primary" aria-label="更多库位操作"><MoreHorizontal :size="16" />更多</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="canChangeStatus(row as PlatformLocationResource)" command="status">
<Power :size="15" />{{ (row as PlatformLocationResource).status === 'AVAILABLE' ? '停用' : '启用' }}
</el-dropdown-item>
<el-dropdown-item v-if="canDelete(row as PlatformLocationResource)" command="delete" divided>
<Trash2 :size="15" />删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<template #location="{ row }">
<div class="location-cell">
<span><MapPin :size="17" /></span>
<div>
<strong>{{ (row as PlatformLocationResource).locationCode }}</strong>
<small>{{ (row as PlatformLocationResource).locationName || '未命名库位' }}</small>
</div>
<el-tag v-if="(row as PlatformLocationResource).sharedOverflow" size="small" type="warning">系统位</el-tag>
</div>
</template>
<template #zone="{ row }">
<div class="stacked-value"><strong>{{ (row as PlatformLocationResource).zoneName }}</strong><small>{{ (row as PlatformLocationResource).zoneCode }}</small></div>
</template>
<template #warehouse="{ row }">
<div class="stacked-value">
<strong>{{ (row as PlatformLocationResource).warehouseName || warehouses.find((item) => String(item.id) === String((row as PlatformLocationResource).warehouseId))?.warehouseName || '—' }}</strong>
<small>{{ (row as PlatformLocationResource).warehouseCode || warehouses.find((item) => String(item.id) === String((row as PlatformLocationResource).warehouseId))?.warehouseCode || '—' }}</small>
</div>
</template>
<template #locationType="{ row }">{{ typeLabels[(row as PlatformLocationResource).locationType] }}</template>
<template #maxWeightKg="{ row }">{{ formatWeight((row as PlatformLocationResource).maxWeightKg) }}</template>
<template #status="{ row }">
<el-tag :type="statusMeta[(row as PlatformLocationResource).status].type" effect="light">
{{ statusMeta[(row as PlatformLocationResource).status].label }}
</el-tag>
</template>
<template #remark="{ row }"><span class="remark-cell">{{ (row as PlatformLocationResource).remark || '—' }}</span></template>
<template #empty>
<el-empty v-if="!loading" :description="emptyDescription">
<el-button
v-if="!filtered && access.can('platform:location:create')"
type="primary"
@click="openCreate"
><Plus :size="16" />新增第一个库位</el-button>
</el-empty>
</template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个库位</span></template>
</DataTable>
</section>
<el-drawer
v-model="drawerOpen"
:title="drawerTitle"
size="min(620px, 100vw)"
class="business-drawer location-drawer"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
:show-close="!saving"
>
<div v-loading="drawerLoading" class="drawer-content">
<el-alert v-if="drawerError" type="error" :closable="false" show-icon title="库位详情加载失败">
<p>{{ drawerError.message }}</p>
<ApiErrorMeta :details="drawerError" />
</el-alert>
<template v-else-if="drawerMode === 'view' && detail">
<section class="detail-identity">
<div class="detail-identity__icon"><MapPin :size="24" /></div>
<div>
<h3>{{ detail.location.locationCode }}</h3>
<p>{{ detail.location.locationName || '未命名库位' }}</p>
</div>
<el-tag :type="statusMeta[detail.location.status].type">{{ statusMeta[detail.location.status].label }}</el-tag>
</section>
<el-descriptions :column="2" border class="detail-descriptions">
<el-descriptions-item label="资源作用域">平台公共资源</el-descriptions-item>
<el-descriptions-item label="所属仓库">{{ detail.warehouseName }}({{ detail.warehouseCode }})</el-descriptions-item>
<el-descriptions-item label="库位编码">{{ detail.location.locationCode }}</el-descriptions-item>
<el-descriptions-item label="库位名称">{{ detail.location.locationName || '—' }}</el-descriptions-item>
<el-descriptions-item label="库区">{{ detail.location.zoneName }}({{ detail.location.zoneCode }})</el-descriptions-item>
<el-descriptions-item label="库位类型">{{ typeLabels[detail.location.locationType] }}</el-descriptions-item>
<el-descriptions-item label="最大承重">{{ formatWeight(detail.location.maxWeightKg) }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusMeta[detail.location.status].label }}</el-descriptions-item>
<el-descriptions-item label="资源版本">v{{ detail.location.version }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(detail.location.updatedAt) }}</el-descriptions-item>
<el-descriptions-item label="系统共享位">{{ detail.location.sharedOverflow ? '是,仅系统自动分配' : '否' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ detail.location.remark || '—' }}</el-descriptions-item>
</el-descriptions>
<el-alert
v-if="detail.location.status === 'RESERVED' || detail.location.status === 'OCCUPIED'"
type="warning"
:closable="false"
show-icon
title="当前库位正在被业务使用,只允许查看。"
/>
<el-alert
v-if="detail.location.sharedOverflow"
type="info"
:closable="false"
show-icon
title="A0 是系统共享溢出位,不能编辑、停用或删除。"
/>
</template>
<el-form
v-else-if="drawerMode !== 'view'"
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="drawer-form"
:disabled="saving"
>
<div v-if="drawerMode === 'edit'" class="editor-scope">
<MapPin :size="18" />
<div><strong>{{ detail?.warehouseName || selectedWarehouse?.warehouseName }}({{ detail?.warehouseCode || selectedWarehouse?.warehouseCode }})</strong><span>平台公共资源 · 保存后不可跨仓移动</span></div>
</div>
<el-form-item v-if="drawerMode === 'create'" label="所属仓库" prop="warehouseId" required>
<el-select v-model="form.warehouseId" filterable placeholder="请选择国内仓" :loading="warehouseLoading">
<el-option
v-for="warehouse in domesticWarehouses"
:key="String(warehouse.id)"
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`"
:value="String(warehouse.id)"
/>
</el-select>
</el-form-item>
<section class="form-section">
<h3>库位标识</h3>
<p class="section-description">库位编码在当前仓库内唯一;编辑后不能移动到其他仓库。</p>
<div class="form-grid two-cols">
<el-form-item label="库位编码" prop="locationCode" required>
<el-input v-model.trim="form.locationCode" maxlength="50" placeholder="例如 A1-01" />
</el-form-item>
<el-form-item label="库位名称">
<el-input v-model.trim="form.locationName" maxlength="100" placeholder="例如 A1 区 01 位" />
</el-form-item>
<el-form-item label="库区编码" prop="zoneCode" required>
<el-input v-model.trim="form.zoneCode" maxlength="50" placeholder="例如 A" />
</el-form-item>
<el-form-item label="库区名称" prop="zoneName" required>
<el-input v-model.trim="form.zoneName" maxlength="100" placeholder="例如 常规区" />
</el-form-item>
</div>
</section>
<section class="form-section">
<h3>资源属性</h3>
<div class="form-grid two-cols">
<el-form-item label="库位类型" prop="locationType" required>
<el-select v-model="form.locationType" placeholder="请选择库位类型">
<el-option label="货架位" value="SHELF" />
<el-option label="托盘位" value="PALLET" />
<el-option label="地堆位" value="FLOOR" />
<el-option label="流利架" value="FLOW_RACK" />
</el-select>
</el-form-item>
<el-form-item label="最大承重(kg)">
<el-input-number v-model="form.maxWeightKg" :min="0.001" :precision="3" :step="10" controls-position="right" placeholder="选填" />
</el-form-item>
</div>
<el-form-item v-if="drawerMode === 'create'" label="初始状态" prop="status" required>
<el-radio-group v-model="form.status">
<el-radio-button value="AVAILABLE" :disabled="selectedWarehouse?.status !== 'ACTIVE'">可用</el-radio-button>
<el-radio-button value="DISABLED">停用</el-radio-button>
</el-radio-group>
<p v-if="selectedWarehouse?.status !== 'ACTIVE'" class="form-help">当前仓库已停用,新库位只能以停用状态创建。</p>
</el-form-item>
<div v-else class="readonly-status">
<span>当前状态</span>
<el-tag :type="statusMeta[form.status].type">{{ statusMeta[form.status].label }}</el-tag>
<small>状态只能通过列表中的启停操作变更。</small>
</div>
</section>
<section class="form-section">
<h3>备注</h3>
<el-form-item label="内部说明">
<el-input v-model.trim="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入库位位置、使用限制或其他说明" />
</el-form-item>
</section>
</el-form>
</div>
<template #footer>
<div class="drawer-footer">
<el-button :disabled="saving" @click="drawerOpen = false">{{ drawerMode === 'view' ? '关闭' : '取消' }}</el-button>
<el-button v-if="drawerMode !== 'view'" type="primary" :loading="saving" @click="saveLocation">保存库位</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.state-alert p { margin: 6px 0 0; line-height: 1.55; }
.state-alert .el-button { margin-top: 10px; }
.location-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.location-actions :deep(.el-button) { margin-left: 0; }
.location-cell { display: flex; min-width: 0; align-items: center; gap: 10px; }
.location-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
.location-cell > div, .stacked-value { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.location-cell strong, .stacked-value strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.location-cell small, .stacked-value small { color: var(--cc-muted); font-size: 11px; }
.remark-cell { display: -webkit-box; overflow: hidden; line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.drawer-content { min-height: 260px; }
.drawer-content > .el-alert + .el-alert, .detail-descriptions + .el-alert { margin-top: 14px; }
.detail-identity { display: grid; grid-template-columns: 48px minmax(0, 1fr) auto; align-items: center; gap: 12px; margin-bottom: 18px; }
.detail-identity__icon { display: grid; width: 48px; height: 48px; place-items: center; border-radius: 10px; background: #eef3fa; color: var(--cc-primary); }
.detail-identity h3 { margin: 0; font-size: 18px; }
.detail-identity p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
.detail-descriptions :deep(.el-descriptions__label) { width: 118px; }
.section-description { margin: -3px 0 14px; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.drawer-form :deep(.el-input-number), .drawer-form :deep(.el-select) { width: 100%; }
.editor-scope { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; padding: 12px; background: #f7f9fc; color: var(--cc-primary); }
.editor-scope div { display: grid; min-width: 0; gap: 2px; }
.editor-scope strong, .editor-scope span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.editor-scope span { color: var(--cc-muted); font-size: 12px; }
.form-help { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.readonly-status { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 12px; background: #f7f9fc; }
.readonly-status > span { color: var(--cc-text); font-size: 13px; font-weight: 600; }
.readonly-status small { color: var(--cc-muted); font-size: 11px; }
@media (max-width: 720px) {
.detail-identity { grid-template-columns: 44px minmax(0, 1fr); }
.detail-identity > .el-tag { grid-column: 2; justify-self: start; }
.detail-descriptions :deep(.el-descriptions__body), .detail-descriptions :deep(table), .detail-descriptions :deep(tbody) { display: block; width: 100%; }
.detail-descriptions :deep(tr) { display: grid; grid-template-columns: 108px minmax(0, 1fr); }
.detail-descriptions :deep(.el-descriptions__cell) { display: block; width: auto; }
}
</style>