Files
admin-web/src/modules/overseas-warehouse/pages/LocationsView.vue
T

142 lines
16 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 '@/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>