142 lines
16 KiB
Vue
142 lines
16 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminDensity.css'
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { Plus } from '@lucide/vue'
|
||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||
import { changeOverseasLocationStatus, createOverseasLocation, deleteOverseasLocation, listAllOverseasLocations, listOverseasLocations, updateOverseasLocation, type OverseasLocationDto, type OverseasLocationStatus, type OverseasLocationType } from '../api/locations'
|
||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||
|
||
const access = useAccessPreviewStore(); const warehouses = ref<ReceivingOverseasWarehouseDto[]>([]); const rows = ref<OverseasLocationDto[]>([])
|
||
const loading = ref(false); const contextLoading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||
const filters = ref<QueryModel>({ warehouseId: '', locationCode: '', locationType: '', status: '' }); const editorOpen = ref(false); const saving = ref(false); const editing = ref<OverseasLocationDto | null>(null)
|
||
const form = reactive({ warehouseId: '', locationCode: '', locationType: 'NORMAL' as const, maxWeightKg: null as number | null, status: 'ACTIVE' as 'ACTIVE' | 'INACTIVE', remark: '' })
|
||
const contextError = ref<ApiErrorDetails | null>(null)
|
||
const dialogError = ref<ApiErrorDetails | null>(null)
|
||
const mutationError = ref<ApiErrorDetails | null>(null)
|
||
const pendingAction = ref('')
|
||
const editorBusy = computed(() => saving.value || contextLoading.value)
|
||
const formLocked = computed(() => editorBusy.value || Boolean(contextError.value) || !access.can(editing.value ? permission.locationEdit : permission.locationCreate))
|
||
const queryFields = computed<QueryField[]>(() => [
|
||
{ key: 'warehouseId', type: 'select', label: '海外仓', width: 260, options: warehouses.value.map(item => ({ value: String(item.id), label: `${item.warehouseName}(${item.warehouseCode})` })), props: { filterable: true, clearable: true, disabled: contextLoading.value } },
|
||
{ key: 'locationCode', type: 'input', label: '库位编码', width: 180 },
|
||
{ key: 'locationType', type: 'select', label: '类型', width: 150, options: [{ label: '普通库位', value: 'NORMAL' }, { label: 'A0共享溢出位', value: 'SHARED_OVERFLOW' }, { label: '虚拟收货位', value: 'VIRTUAL' }] },
|
||
{ key: 'status', type: 'select', label: '状态', width: 140, options: [{ label: '可用', value: 'ACTIVE' }, { label: '空闲', value: 'IDLE' }, { label: '已占用', value: 'OCCUPIED' }, { label: '已停用', value: 'INACTIVE' }] },
|
||
])
|
||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' }, { key: 'locationCode', prop: 'locationCode', label: '库位编码', minWidth: 180 }, { key: 'warehouse', label: '海外仓', minWidth: 190, slotName: 'warehouse' }, { key: 'locationType', label: '类型', width: 130, slotName: 'locationType' }, { key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'occupiedPalletCount', prop: 'occupiedPalletCount', label: '托盘数', width: 100, align: 'right' }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 }]
|
||
const typeLabels: Record<OverseasLocationType, string> = { VIRTUAL: '虚拟收货位', NORMAL: '普通库位', SHARED_OVERFLOW: 'A0共享溢出位' }; const statusLabels: Record<OverseasLocationStatus, string> = { ACTIVE: '可用', IDLE: '空闲', OCCUPIED: '已占用', INACTIVE: '已停用', DISABLED: '已停用' }
|
||
const selectedWarehouse = computed(() => warehouses.value.find(item => String(item.id) === String(form.warehouseId)))
|
||
const warehouseNameById = computed(() => new Map(warehouses.value.map(item => [String(item.id), item.warehouseName])))
|
||
function isA0(row: OverseasLocationDto) { return row.sharedOverflow || row.locationType === 'SHARED_OVERFLOW' || row.locationCode.toUpperCase() === 'A0' }
|
||
function canEdit(row: OverseasLocationDto) { return access.can(permission.locationEdit) && !isA0(row) && row.locationType === 'NORMAL' && (row.status === 'ACTIVE' || row.status === 'INACTIVE' || row.status === 'DISABLED') }
|
||
function canStatus(row: OverseasLocationDto) { return access.can(permission.locationEdit) && !isA0(row) && row.locationType === 'NORMAL' && (row.status === 'ACTIVE' || row.status === 'INACTIVE' || row.status === 'DISABLED') }
|
||
function canDelete(row: OverseasLocationDto) { return access.can(permission.locationDelete) && !isA0(row) && row.locationType === 'NORMAL' && row.status === 'INACTIVE' && Number(row.occupiedPalletCount || 0) === 0 }
|
||
function actionError(error: unknown, fallback: string) { mutationError.value = getApiErrorDetails(error, fallback) }
|
||
async function loadWarehouses() {
|
||
if (contextLoading.value) return
|
||
contextLoading.value = true; contextError.value = null
|
||
try { warehouses.value = (await listReceivingOverseasWarehouses()).records; await load() }
|
||
catch (error) { contextError.value = getApiErrorDetails(error, '海外仓列表加载失败') }
|
||
finally { contextLoading.value = false }
|
||
}
|
||
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); if (!warehouses.value.length) { rows.value = []; total.value = 0; return }; loading.value = true; loadError.value = null; try { const selectedWarehouseId = String(filters.value.warehouseId || ''); const result = await (selectedWarehouseId ? listOverseasLocations(selectedWarehouseId, { page: 1, size: 200, keyword: String(filters.value.locationCode || '').trim() || undefined, locationType: String(filters.value.locationType || '') as OverseasLocationType | '', status: String(filters.value.status || '') as OverseasLocationStatus | '' }) : listAllOverseasLocations(warehouses.value.map(item => String(item.id)), { keyword: String(filters.value.locationCode || '').trim() || undefined, locationType: String(filters.value.locationType || '') as OverseasLocationType | '', status: String(filters.value.status || '') as OverseasLocationStatus | '' })); const records = result.records.map(row => ({ ...row, warehouseName: row.warehouseName || warehouseNameById.value.get(String(row.warehouseId)) || null })); total.value = records.length; const start = (page.value - 1) * pageSize.value; rows.value = records.slice(start, start + pageSize.value) } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '海外库位加载失败') } finally { loading.value = false } }
|
||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||
function resetForm() { Object.assign(form, { warehouseId: String(filters.value.warehouseId || ''), locationCode: '', locationType: 'NORMAL', maxWeightKg: null, status: 'ACTIVE', remark: '' }); editing.value = null }
|
||
function openCreate() {
|
||
if (editorOpen.value || editorBusy.value || pendingAction.value || !access.can(permission.locationCreate)) return
|
||
resetForm(); dialogError.value = null; mutationError.value = null; editorOpen.value = true
|
||
}
|
||
function openEdit(row: OverseasLocationDto) {
|
||
if (editorOpen.value || editorBusy.value || pendingAction.value || !canEdit(row)) return
|
||
Object.assign(form, { warehouseId: row.warehouseId, locationCode: row.locationCode, locationType: 'NORMAL', maxWeightKg: row.maxLoadKg == null ? null : Number(row.maxLoadKg), status: row.status === 'ACTIVE' ? 'ACTIVE' : 'INACTIVE', remark: row.remark || '' })
|
||
editing.value = row; dialogError.value = null; mutationError.value = null; editorOpen.value = true
|
||
}
|
||
async function save() {
|
||
if (!editorOpen.value || formLocked.value || pendingAction.value || (editing.value ? !canEdit(editing.value) : !access.can(permission.locationCreate))) return
|
||
if (!form.warehouseId || !form.locationCode.trim() || form.maxWeightKg == null || !Number.isFinite(form.maxWeightKg) || form.maxWeightKg <= 0) { ElMessage.warning('请填写海外仓、库位编码和大于0的最大承重'); return }
|
||
saving.value = true; dialogError.value = null
|
||
try {
|
||
const payload = { locationCode: form.locationCode.trim(), locationType: 'NORMAL' as const, maxLoadKg: form.maxWeightKg, status: form.status, remark: form.remark.trim() || null }
|
||
if (editing.value) await updateOverseasLocation(form.warehouseId, editing.value.id, { ...payload, version: editing.value.version })
|
||
else await createOverseasLocation(form.warehouseId, payload)
|
||
editorOpen.value = false; ElMessage.success('库位已保存'); await load()
|
||
} catch (error) { dialogError.value = getApiErrorDetails(error, '保存库位失败') }
|
||
finally { saving.value = false }
|
||
}
|
||
async function toggleStatus(row: OverseasLocationDto) {
|
||
if (!canStatus(row) || pendingAction.value || editorOpen.value || editorBusy.value) return
|
||
const next = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
|
||
pendingAction.value = String(row.id); mutationError.value = null
|
||
try {
|
||
await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'}库位“${row.locationCode}”?`, '请确认', { customClass: 'admin-density-confirm', type: next === 'ACTIVE' ? 'info' : 'warning' })
|
||
await changeOverseasLocationStatus(row.warehouseId, row.id, { locationCode: row.locationCode, locationType: 'NORMAL', maxLoadKg: Number(row.maxLoadKg || 0), status: next, remark: row.remark || null, version: row.version })
|
||
ElMessage.success(next === 'ACTIVE' ? '库位已启用' : '库位已停用'); await load()
|
||
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '更新库位状态失败') }
|
||
finally { pendingAction.value = '' }
|
||
}
|
||
async function remove(row: OverseasLocationDto) {
|
||
if (!canDelete(row) || pendingAction.value || editorOpen.value || editorBusy.value) return
|
||
pendingAction.value = String(row.id); mutationError.value = null
|
||
try {
|
||
await ElMessageBox.confirm(`确认删除库位“${row.locationCode}”?`, '请确认', { type: 'warning', customClass: 'admin-density-confirm' })
|
||
await deleteOverseasLocation(row.warehouseId, row.id, row.version); ElMessage.success('库位已删除'); await load()
|
||
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '删除库位失败') }
|
||
finally { pendingAction.value = '' }
|
||
}
|
||
onMounted(() => { void loadWarehouses() })
|
||
</script>
|
||
|
||
|
||
<template>
|
||
<AdminPage class="admin-density">
|
||
<ListPageLayout aria-label="海外仓库位管理">
|
||
<section>
|
||
<DataTable preference-key="overseas-warehouse-locationsview" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError && !contextError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" empty-text="暂无库位" @update:page="load()" @update:page-size="page = 1; load()">
|
||
<template #toolbar-start><h1>库位管理</h1></template>
|
||
<template #filters><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外库位筛选" preference-key="overseas-locations-query" @search="search" @reset="page = 1; load()" /></template>
|
||
<template #notices>
|
||
<LoadErrorState v-if="contextError" title="海外仓列表加载失败" :message="contextError.message" :loading="contextLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="contextError" /></template></LoadErrorState>
|
||
<LoadErrorState v-if="loadError" title="海外库位加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||
<LoadErrorState v-if="mutationError" title="库位操作失败" :message="mutationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
|
||
</template>
|
||
<template #toolbar-actions><el-button v-if="access.can(permission.locationCreate)" type="primary" :disabled="editorOpen || editorBusy || Boolean(pendingAction)" @click="openCreate"><Plus :size="16" />新增库位</el-button></template>
|
||
<template #actions="{ row }">
|
||
<div class="density-actions row-actions admin-text-actions">
|
||
<el-button v-if="canEdit(row as OverseasLocationDto)" size="small" class="density-action" :disabled="editorOpen || editorBusy || Boolean(pendingAction)" @click="openEdit(row as OverseasLocationDto)">编辑</el-button>
|
||
<el-button v-if="canStatus(row as OverseasLocationDto)" size="small" class="density-action" :data-tone="(row as OverseasLocationDto).status === 'ACTIVE' ? 'danger' : 'success'" :disabled="editorOpen || editorBusy || Boolean(pendingAction)" @click="toggleStatus(row as OverseasLocationDto)">{{ (row as OverseasLocationDto).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
|
||
<el-button v-if="canDelete(row as OverseasLocationDto)" size="small" class="density-action" data-tone="danger" :disabled="editorOpen || editorBusy || Boolean(pendingAction)" @click="remove(row as OverseasLocationDto)">删除</el-button>
|
||
</div>
|
||
</template>
|
||
<template #warehouse="{ row }">{{ (row as OverseasLocationDto).warehouseName || warehouseNameById.get(String((row as OverseasLocationDto).warehouseId)) || '—' }}</template>
|
||
<template #locationType="{ row }"><el-tag :type="isA0(row as OverseasLocationDto) ? 'warning' : 'info'">{{ typeLabels[(row as OverseasLocationDto).locationType] || '普通库位' }}</el-tag></template>
|
||
<template #status="{ row }"><el-tag>{{ statusLabels[(row as OverseasLocationDto).status] || '—' }}</el-tag></template>
|
||
<template #pagination-left>共 {{ total }} 个库位</template>
|
||
</DataTable>
|
||
</section>
|
||
</ListPageLayout>
|
||
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editing ? `编辑海外库位 · ${editing.locationCode}` : '新增海外库位'" width="min(560px, calc(100vw - 28px))" fullscreenable :loading="contextLoading" :confirm-loading="editorBusy" :confirm-disabled="formLocked" confirm-text="保存库位" @confirm="save">
|
||
<LoadErrorState v-if="contextError" title="海外仓列表加载失败" :message="contextError.message" :loading="contextLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="contextError" /></template></LoadErrorState>
|
||
<LoadErrorState v-if="dialogError" title="保存库位失败" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
|
||
<el-form class="density-form" label-position="left" label-width="110px" :disabled="formLocked">
|
||
<section class="density-dialog-section" data-tone="work">
|
||
<table class="density-table density-table--editable"><tbody>
|
||
<tr><td><el-form-item label="海外仓" required><span v-if="editing">{{ selectedWarehouse ? `${selectedWarehouse.warehouseName}(${selectedWarehouse.warehouseCode})` : '—' }}</span><el-select v-else v-model="form.warehouseId" placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :value="item.id" :label="`${item.warehouseName}(${item.warehouseCode})`" /></el-select></el-form-item></td></tr>
|
||
<tr><td><el-form-item label="库位编码" required><el-input v-model="form.locationCode" maxlength="50" placeholder="请输入库位编码" /></el-form-item></td></tr>
|
||
<tr><td><el-form-item label="最大承重(kg)" required><el-input-number v-model="form.maxWeightKg" :min="0.001" :precision="3" controls-position="right" placeholder="请输入最大承重" /></el-form-item></td></tr>
|
||
<tr><td><el-form-item label="备注"><el-input v-model="form.remark" maxlength="500" placeholder="请输入备注(选填)" /></el-form-item></td></tr>
|
||
</tbody></table>
|
||
</section>
|
||
</el-form>
|
||
</AppDialog>
|
||
</AdminPage>
|
||
</template>
|