2026-08-04 21:01:39 +08:00
< script setup lang = "ts" >
import { computed , onMounted , reactive , ref , watch } from 'vue'
import { ElMessage , ElMessageBox , type FormInstance , type FormRules } from 'element-plus'
2026-08-10 16:24:54 +08:00
import { Eye , MapPin , MoreHorizontal , Pencil , Plus , Power , Save , Trash2 } from '@lucide/vue'
2026-08-04 21:01:39 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
2026-08-10 16:24:54 +08:00
import AdminPage from '@/shared/components/AdminPage.vue'
2026-08-04 21:01:39 +08:00
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
2026-08-10 16:24:54 +08:00
import AppDrawer from '@/shared/components/AppDrawer.vue'
2026-08-04 21:01:39 +08:00
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
2026-08-10 16:24:54 +08:00
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
2026-08-04 21:01:39 +08:00
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'
2026-08-06 14:55:17 +08:00
import { listAllPlatformWarehouses , type PlatformWarehouseResource } from '../../../warehouses/api/platformWarehouses'
2026-08-04 21:01:39 +08:00
type DrawerMode = 'view' | 'create' | 'edit'
type LocationFilters = QueryModel & {
warehouseId : string
zoneCode : string
locationCode : string
locationType : PlatformLocationType | ''
status : PlatformLocationStatus | ''
}
type AppliedLocationFilters = {
2026-08-05 15:54:59 +08:00
warehouseId : string
2026-08-04 21:01:39 +08:00
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 >()
2026-08-05 15:54:59 +08:00
let warehouseLoadSequence = 0
2026-08-04 21:01:39 +08:00
let locationLoadSequence = 0
2026-08-07 20:07:37 +08:00
let drawerLoadSequence = 0
2026-08-04 21:01:39 +08:00
2026-08-05 15:54:59 +08:00
const filters = ref < LocationFilters >({ warehouseId : '' , zoneCode : '' , locationCode : '' , locationType : '' , status : '' })
const appliedFilters = ref < AppliedLocationFilters >({ warehouseId : '' , zoneCode : '' , locationCode : '' , locationType : '' , status : '' })
2026-08-04 21:01:39 +08:00
const selectedWarehouseId = computed (() => String ( filters . value . warehouseId || '' ))
const queryFields = computed < QueryField [] >(() => [
{
key : 'warehouseId' ,
type : 'select' ,
2026-08-06 19:03:13 +08:00
label : '所属仓库' ,
2026-08-05 15:54:59 +08:00
width : 300 ,
placeholder : warehouseLoading . value ? '仓库加载中' : '全部仓库' ,
2026-08-04 21:01:39 +08:00
props : {
'aria-label' : '所属仓库' ,
filterable : true ,
2026-08-05 15:54:59 +08:00
disabled : selectionDisabled . value || warehouseLoading . value || Boolean ( warehouseError . value ),
2026-08-04 21:01:39 +08:00
},
options : warehouses . value . map (( warehouse ) => ({
value : String ( warehouse . id ),
2026-08-06 13:56:04 +08:00
label : ` ${ warehouse . warehouseName } ( ${ warehouse . warehouseCode } ) `,
2026-08-04 21:01:39 +08:00
})),
},
2026-08-06 19:03:13 +08:00
{ key : 'zoneCode' , type : 'input' , width : 210 , label : '库区编码' , placeholder : '库区编码' , props : { maxlength : 50 , 'aria-label' : '库区编码' } },
{ key : 'locationCode' , type : 'input' , width : 220 , label : '库位编码' , placeholder : '库位编码' , props : { maxlength : 50 , 'aria-label' : '库位编码' } },
2026-08-04 21:01:39 +08:00
{
2026-08-06 19:03:13 +08:00
key : 'locationType' , type : 'select' , width : 170 , label : '库位类型' , placeholder : '全部类型' , props : { 'aria-label' : '库位类型' },
2026-08-04 21:01:39 +08:00
options : [
{ label : '货架位' , value : 'SHELF' },
{ label : '托盘位' , value : 'PALLET' },
{ label : '地堆位' , value : 'FLOOR' },
{ label : '流利架' , value : 'FLOW_RACK' },
],
},
{
2026-08-06 19:03:13 +08:00
key : 'status' , type : 'select' , width : 160 , label : '库位状态' , placeholder : '全部状态' , props : { 'aria-label' : '库位状态' },
2026-08-04 21:01:39 +08:00
options : [
{ label : '可用' , value : 'AVAILABLE' },
{ label : '已预占' , value : 'RESERVED' },
{ label : '已占用' , value : 'OCCUPIED' },
{ label : '已停用' , value : 'DISABLED' },
],
},
])
const emptyForm = () => ({
id : '' ,
2026-08-05 15:54:59 +08:00
warehouseId : '' ,
2026-08-04 21:01:39 +08:00
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 > = {
2026-08-06 14:55:17 +08:00
warehouseId : [{ required : true , message : '请选择国内仓' , trigger : 'change' }],
2026-08-04 21:01:39 +08:00
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' }],
}
2026-08-05 15:54:59 +08:00
const selectedWarehouse = computed (() => warehouses . value . find (( item ) => String ( item . id ) === form . warehouseId ) ?? null )
2026-08-06 14:55:17 +08:00
const domesticWarehouses = computed (() => warehouses . value . filter (( warehouse ) => (
warehouse . warehouseType === 'DOMESTIC' && warehouse . status === 'ACTIVE'
)))
2026-08-04 21:01:39 +08:00
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' },
2026-08-12 12:19:02 +08:00
{ key : 'maxWeightKg' , label : '最大承重(kg) ' , minWidth : 140 , slotName : 'maxWeightKg' , defaultVisible : false },
2026-08-04 21:01:39 +08:00
{ key : 'status' , label : '状态' , width : 110 , slotName : 'status' },
2026-08-12 12:19:02 +08:00
{ key : 'remark' , label : '备注' , minWidth : 220 , slotName : 'remark' , defaultVisible : false },
2026-08-04 21:01:39 +08:00
]
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' )
2026-08-06 14:55:17 +08:00
&& isDomesticWarehouse ( row . warehouseId )
2026-08-04 21:01:39 +08:00
&& ! 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'
}
2026-08-10 17:42:17 +08:00
function hasMoreLocationActions ( row : PlatformLocationResource ) {
return 1 + Number ( canEdit ( row )) + Number ( canChangeStatus ( row )) + Number ( canDelete ( row )) > 3
}
function locationMoreActionsLabel ( row : PlatformLocationResource ) {
2026-08-12 12:19:02 +08:00
const identifier = [ row . locationCode , row . locationName ]
2026-08-10 17:42:17 +08:00
. map (( value ) => String ( value ?? '' ). trim ())
. find ( Boolean ) || '当前库位'
return ` ${ identifier } 的更多操作`
}
2026-08-04 21:01:39 +08:00
function actionError ( error : unknown , fallback : string ) {
const details = getApiErrorDetails ( error , fallback )
ElMessage . error ( details . requestId ? ` ${ details . message } ( requestId: ${ details . requestId } ) ` : details . message )
}
2026-08-06 14:55:17 +08:00
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 ))
}
2026-08-04 21:01:39 +08:00
function requireCreateScope () {
2026-08-05 15:54:59 +08:00
if ( ! selectedWarehouse . value || ! form . warehouseId ) {
2026-08-06 14:55:17 +08:00
ElMessage . warning ( '请选择国内仓' )
2026-08-04 21:01:39 +08:00
return null
}
2026-08-06 15:32:02 +08:00
return { warehouseId : apiId ( form . warehouseId ) }
2026-08-04 21:01:39 +08:00
}
function recordScope ( row : PlatformLocationResource ) {
2026-08-06 15:32:02 +08:00
return { warehouseId : apiId ( String ( row . warehouseId )) }
2026-08-04 21:01:39 +08:00
}
2026-08-05 15:54:59 +08:00
async function loadWarehouses () {
const sequence = ++ warehouseLoadSequence
2026-08-04 21:01:39 +08:00
warehouseLoading . value = true
warehouseError . value = null
warehouses . value = []
try {
2026-08-06 15:32:02 +08:00
// 库位只属于国内仓,查询候选仓库时直接限制仓库类型,避免把 FBA/海外仓带入页面。
const records = await listAllPlatformWarehouses ({ warehouseType : 'DOMESTIC' })
2026-08-05 15:54:59 +08:00
if ( sequence !== warehouseLoadSequence ) return
2026-08-06 15:32:02 +08:00
// 保留前端兜底过滤,避免旧接口或异常响应返回其他仓库类型。
warehouses . value = records . filter (( warehouse ) => warehouse . warehouseType === 'DOMESTIC' )
2026-08-04 21:01:39 +08:00
} catch ( error ) {
2026-08-05 15:54:59 +08:00
if ( sequence !== warehouseLoadSequence ) return
2026-08-04 21:01:39 +08:00
warehouseError . value = getApiErrorDetails ( error , '仓库列表加载失败,请稍后重试' )
} finally {
2026-08-05 15:54:59 +08:00
if ( sequence === warehouseLoadSequence ) warehouseLoading . value = false
2026-08-04 21:01:39 +08:00
}
}
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 ,
}
2026-08-05 15:54:59 +08:00
if ( appliedFilters . value . warehouseId ) query . warehouseId = apiId ( appliedFilters . value . warehouseId )
2026-08-04 21:01:39 +08:00
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 = {
2026-08-05 15:54:59 +08:00
warehouseId : String ( model . warehouseId || '' ),
2026-08-04 21:01:39 +08:00
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 ),
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 () {
2026-08-05 15:54:59 +08:00
Object . assign ( form , emptyForm ())
2026-08-06 14:55:17 +08:00
form . warehouseId = isActiveDomesticWarehouse ( selectedWarehouseId . value ) ? selectedWarehouseId . value : ''
2026-08-05 15:54:59 +08:00
form . status = selectedWarehouse . value ? . status === 'INACTIVE' ? 'DISABLED' : 'AVAILABLE'
2026-08-04 21:01:39 +08:00
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' ) {
2026-08-06 14:55:17 +08:00
if ( mode === 'edit' && ! isDomesticWarehouse ( row . warehouseId )) {
ElMessage . warning ( '仅支持编辑国内仓库位' )
return
}
2026-08-07 20:07:37 +08:00
const sequence = ++ drawerLoadSequence
2026-08-04 21:01:39 +08:00
const scope = recordScope ( row )
drawerMode . value = mode
drawerOpen . value = true
drawerLoading . value = true
drawerError . value = null
detail . value = null
try {
2026-08-06 15:32:02 +08:00
const result = await getPlatformLocation ( scope . warehouseId , row . id )
2026-08-07 20:07:37 +08:00
if ( sequence !== drawerLoadSequence || ! drawerOpen . value ) return
2026-08-04 21:01:39 +08:00
detail . value = result
if ( mode === 'edit' ) {
assignForm ( result . location )
captureBaseline ()
void formRef . value ? . clearValidate ()
}
} catch ( error ) {
2026-08-07 20:07:37 +08:00
if ( sequence !== drawerLoadSequence ) return
2026-08-04 21:01:39 +08:00
drawerError . value = getApiErrorDetails ( error , '库位详情加载失败,请稍后重试' )
} finally {
2026-08-07 20:07:37 +08:00
if ( sequence === drawerLoadSequence ) drawerLoading . value = false
2026-08-04 21:01:39 +08:00
}
}
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 () {
2026-08-05 15:54:59 +08:00
const valid = await formRef . value ? . validate (). catch (() => false )
if ( ! valid ) return
2026-08-06 14:55:17 +08:00
const warehouseAllowed = form . id
? isDomesticWarehouse ( form . warehouseId )
: isActiveDomesticWarehouse ( form . warehouseId )
if ( ! warehouseAllowed ) {
ElMessage . error ( form . id ? '库位仅支持归属国内仓' : '新增库位仅支持选择启用的国内仓' )
return
}
2026-08-06 15:32:02 +08:00
const scope = form . id ? { warehouseId : apiId ( form . warehouseId ) } : requireCreateScope ()
2026-08-04 21:01:39 +08:00
if ( ! scope ) return
saving . value = true
try {
const payload = buildPayload ()
2026-08-06 15:32:02 +08:00
if ( form . id ) await updatePlatformLocation ( scope . warehouseId , form . id , payload )
else await createPlatformLocation ( payload )
2026-08-04 21:01:39 +08:00
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 {
2026-08-06 15:32:02 +08:00
await changePlatformLocationStatus ( scope . warehouseId , row . id , nextStatus , row . version )
2026-08-04 21:01:39 +08:00
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 {
2026-08-06 15:32:02 +08:00
await deletePlatformLocation ( scope . warehouseId , row . id , row . version )
2026-08-04 21:01:39 +08:00
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 () })
2026-08-07 20:07:37 +08:00
watch ( drawerOpen , ( open ) => {
if ( open ) return
drawerLoadSequence += 1
drawerLoading . value = false
drawerError . value = null
detail . value = null
})
2026-08-05 15:54:59 +08:00
watch (() => form . warehouseId , ( warehouseId ) => {
if ( drawerMode . value !== 'create' ) return
const warehouse = warehouses . value . find (( item ) => String ( item . id ) === warehouseId )
form . status = warehouse ? . status === 'INACTIVE' ? 'DISABLED' : 'AVAILABLE'
2026-08-04 21:01:39 +08:00
})
onMounted (() => {
2026-08-05 15:54:59 +08:00
void loadWarehouses ()
2026-08-04 21:01:39 +08:00
void loadLocations ()
})
</ script >
< template >
2026-08-10 16:24:54 +08:00
< AdminPage as = "div" class = "location-page" >
< ListPageLayout aria -label =" 库位列表页面 " >
< template # toolbar >
< QueryToolbar
v-model = "filters"
ariaLabel = "库位筛选"
:fields = "queryFields"
preference -key =" locations -query "
@search ="applyFilters"
/>
</ template >
< template # state >
< LoadErrorState
v-if = "warehouseError"
title = "仓库列表加载失败"
:message = "warehouseError.message"
:loading = "warehouseLoading"
@retry ="loadWarehouses"
/>
< el-alert v-else-if = "!warehouseLoading && domesticWarehouses.length === 0" type="info" :closable="false" show-icon title="暂无可选国内仓" >
请先在仓库管理中建立国内仓 , 再维护库位 。
</ el -alert >
< LoadErrorState
v-else-if = "loadError"
title = "库位列表加载失败"
:message = "loadError.message"
:loading = "loading"
@retry ="loadLocations"
/>
</ template >
2026-08-04 21:01:39 +08:00
2026-08-10 16:24:54 +08:00
< section v-if = "!loadError" class="surface-panel table-panel" aria-label="库位列表区域" >
2026-08-04 21:01:39 +08:00
< DataTable
v -model:page = "page"
v -model:page-size = "pageSize"
:data = "rows"
:columns = "columns"
2026-08-06 19:03:13 +08:00
preference -key =" locations -table "
2026-08-04 21:01:39 +08:00
:loading = "loading"
:total = "total"
: page -sizes ="[ 20 , 50 , 100 , 200 ]"
row -key =" id "
aria -label =" 库位列表 "
:empty-text = "emptyDescription"
>
2026-08-06 20:14:56 +08:00
< template # toolbar -actions >< el-button v-if = "access.can('platform:location:create')" type="primary" @click="openCreate"><Plus :size="16" /> 新增库位 </ el-button ></ template >
2026-08-04 21:01:39 +08:00
< 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 >
2026-08-10 17:42:17 +08:00
< el-button
v-if = "canChangeStatus(row as PlatformLocationResource)"
link
: type = "(row as PlatformLocationResource).status === 'AVAILABLE' ? 'danger' : 'success'"
: disabled = "loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
@click ="toggleStatus(row as PlatformLocationResource)"
>< Power :size = "15" />{{ ( row as PlatformLocationResource ). status === 'AVAILABLE' ? '停用' : '启用' }}</ el-button >
< el-button
v-if = "canDelete(row as PlatformLocationResource) && !hasMoreLocationActions(row as PlatformLocationResource)"
link
type = "danger"
: disabled = "loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
@click ="removeLocation(row as PlatformLocationResource)"
>< Trash2 :size = "15" /> 删除 </ el-button >
2026-08-04 21:01:39 +08:00
< el-dropdown
2026-08-10 17:42:17 +08:00
v-if = "hasMoreLocationActions(row as PlatformLocationResource)"
2026-08-04 21:01:39 +08:00
trigger = "click"
2026-08-10 17:42:17 +08:00
popper -class =" location -action-menu "
2026-08-04 21:01:39 +08:00
: disabled = "loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
@command ="handleMoreAction(row as PlatformLocationResource, String($event))"
>
2026-08-10 17:42:17 +08:00
< el-button link type = "primary" : aria -label =" locationMoreActionsLabel ( row as PlatformLocationResource )" : title = "locationMoreActionsLabel(row as PlatformLocationResource)" >< MoreHorizontal :size = "16" /></ el-button >
2026-08-04 21:01:39 +08:00
< template # dropdown >
< el-dropdown-menu >
2026-08-10 17:42:17 +08:00
< el-dropdown-item v-if = "canDelete(row as PlatformLocationResource)" command="delete" class="location-action-menu__danger" >
2026-08-04 21:01:39 +08:00
< 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" >
2026-08-05 15:54:59 +08:00
< 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 >
2026-08-04 21:01:39 +08:00
</ 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
2026-08-05 15:54:59 +08:00
v-if = "!filtered && access.can('platform:location:create')"
2026-08-04 21:01:39 +08:00
type = "primary"
@click ="openCreate"
>< Plus :size = "16" /> 新增第一个库位 </ el-button >
</ el-empty >
</ template >
< template # pagination -left =" { total : tableTotal } " >< span > 共 {{ tableTotal }} 个库位 </ span ></ template >
</ DataTable >
2026-08-10 16:24:54 +08:00
</ section >
</ ListPageLayout >
2026-08-04 21:01:39 +08:00
2026-08-10 16:24:54 +08:00
< AppDrawer
2026-08-04 21:01:39 +08:00
v-model = "drawerOpen"
:title = "drawerTitle"
2026-08-10 16:24:54 +08:00
size = "min(620px, 94vw)"
class = "location-drawer"
:loading = "drawerLoading"
:confirm-loading = "saving"
:show-confirm = "false"
2026-08-04 21:01:39 +08:00
:close-on-click-modal = "false"
:close-on-press-escape = "!saving"
:show-close = "!saving"
>
2026-08-10 16:24:54 +08:00
< div class = "drawer-content" >
2026-08-04 21:01:39 +08:00
< 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" >
2026-08-06 13:56:04 +08:00
< el-descriptions-item label = "资源作用域" > 平台公共资源 </ el-descriptions-item >
2026-08-04 21:01:39 +08:00
< 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"
>
2026-08-05 15:54:59 +08:00
< div v-if = "drawerMode === 'edit'" class="editor-scope" >
2026-08-04 21:01:39 +08:00
< MapPin :size = "18" />
2026-08-06 13:56:04 +08:00
< div >< strong >{{ detail ? . warehouseName || selectedWarehouse ? . warehouseName }} ( {{ detail ? . warehouseCode || selectedWarehouse ? . warehouseCode }} ) </ strong >< span > 平台公共资源 · 保存后不可跨仓移动 </ span ></ div >
2026-08-04 21:01:39 +08:00
</ div >
2026-08-05 15:54:59 +08:00
< el-form-item v-if = "drawerMode === 'create'" label="所属仓库" prop="warehouseId" required >
2026-08-06 14:55:17 +08:00
< el-select v-model = "form.warehouseId" filterable placeholder="请选择国内仓" :loading="warehouseLoading" >
2026-08-05 15:54:59 +08:00
< el-option
2026-08-06 14:55:17 +08:00
v-for = "warehouse in domesticWarehouses"
2026-08-05 15:54:59 +08:00
:key = "String(warehouse.id)"
2026-08-06 13:56:04 +08:00
:label = "`${warehouse.warehouseName}( ${warehouse.warehouseCode}) `"
2026-08-05 15:54:59 +08:00
:value = "String(warehouse.id)"
/>
</ el-select >
</ el-form-item >
2026-08-10 16:24:54 +08:00
< FormSection title = "库位标识" description = "库位编码在当前仓库内唯一;编辑后不能移动到其他仓库。" >
2026-08-04 21:01:39 +08:00
< 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 >
2026-08-10 16:24:54 +08:00
</ FormSection >
2026-08-04 21:01:39 +08:00
2026-08-10 16:24:54 +08:00
< FormSection title = "资源属性" >
2026-08-04 21:01:39 +08:00
< 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 >
2026-08-10 16:24:54 +08:00
</ FormSection >
2026-08-04 21:01:39 +08:00
2026-08-10 16:24:54 +08:00
< FormSection title = "备注" >
2026-08-04 21:01:39 +08:00
< el-form-item label = "内部说明" >
< el-input v -model .trim =" form.remark " type = "textarea" :rows = "4" maxlength = "500" show -word-limit placeholder = "请输入库位位置、使用限制或其他说明" />
</ el-form-item >
2026-08-10 16:24:54 +08:00
</ FormSection >
2026-08-04 21:01:39 +08:00
</ el-form >
</ div >
< template # footer >
< div class = "drawer-footer" >
< el-button :disabled = "saving" @click ="drawerOpen = false" >{{ drawerMode = == ' view ' ? ' 关闭 ' : ' 取消 ' }} </ el -button >
2026-08-10 16:24:54 +08:00
< el-button v-if = "drawerMode !== 'view'" type="primary" :loading="saving" @click="saveLocation"><Save :size="16" /> 保存库位 </ el-button >
2026-08-04 21:01:39 +08:00
</ div >
</ template >
2026-08-10 16:24:54 +08:00
</ AppDrawer >
</ AdminPage >
2026-08-04 21:01:39 +08:00
</ template >
< style scoped >
. state - alert p { margin : 6 px 0 0 ; line - height : 1.55 ; }
. state - alert . el - button { margin - top : 10 px ; }
. location - actions { display : flex ; align - items : center ; gap : 4 px ; white - space : nowrap ; }
. location - actions : deep (. el - button ) { margin - left : 0 ; }
. location - cell { display : flex ; min - width : 0 ; align - items : center ; gap : 10 px ; }
. location - cell > span { display : grid ; width : 34 px ; height : 34 px ; flex : 0 0 34 px ; place - items : center ; border - radius : 7 px ; background : # eef3fa ; color : var ( -- cc - primary ); }
. location - cell > div , . stacked - value { display : flex ; min - width : 0 ; flex - direction : column ; gap : 3 px ; }
. 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 : 11 px ; }
. remark - cell { display : - webkit - box ; overflow : hidden ; line - height : 1.5 ; - webkit - box - orient : vertical ; - webkit - line - clamp : 2 ; }
. drawer - content { min - height : 260 px ; }
. drawer - content > . el - alert + . el - alert , . detail - descriptions + . el - alert { margin - top : 14 px ; }
. detail - identity { display : grid ; grid - template - columns : 48 px minmax ( 0 , 1 fr ) auto ; align - items : center ; gap : 12 px ; margin - bottom : 18 px ; }
. detail - identity__icon { display : grid ; width : 48 px ; height : 48 px ; place - items : center ; border - radius : 10 px ; background : # eef3fa ; color : var ( -- cc - primary ); }
. detail - identity h3 { margin : 0 ; font - size : 18 px ; }
. detail - identity p { margin : 4 px 0 0 ; color : var ( -- cc - muted ); font - size : 12 px ; }
. detail - descriptions : deep (. el - descriptions__label ) { width : 118 px ; }
. section - description { margin : - 3 px 0 14 px ; color : var ( -- cc - muted ); font - size : 12 px ; 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 : 10 px ; margin - bottom : 18 px ; padding : 12 px ; background : # f7f9fc ; color : var ( -- cc - primary ); }
. editor - scope div { display : grid ; min - width : 0 ; gap : 2 px ; }
. editor - scope strong , . editor - scope span { overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
. editor - scope span { color : var ( -- cc - muted ); font - size : 12 px ; }
. form - help { margin : 6 px 0 0 ; color : var ( -- cc - muted ); font - size : 12 px ; line - height : 1.55 ; }
. readonly - status { display : flex ; align - items : center ; flex - wrap : wrap ; gap : 10 px ; padding : 12 px ; background : # f7f9fc ; }
. readonly - status > span { color : var ( -- cc - text ); font - size : 13 px ; font - weight : 600 ; }
. readonly - status small { color : var ( -- cc - muted ); font - size : 11 px ; }
2026-08-10 17:42:17 +08:00
: global (. location - action - menu . location - action - menu__danger : not (. is - disabled )) { color : var ( -- el - color - danger ); }
2026-08-04 21:01:39 +08:00
@ media ( max - width : 720 px ) {
. detail - identity { grid - template - columns : 44 px minmax ( 0 , 1 fr ); }
. 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 : 108 px minmax ( 0 , 1 fr ); }
. detail - descriptions : deep (. el - descriptions__cell ) { display : block ; width : auto ; }
}
</ style >