230 lines
20 KiB
Vue
230 lines
20 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminWorkbench.css'
|
||
import '@/styles/adminDensity.css'
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { Layers, Package, PackageCheck, PackageX, Plus } from '@lucide/vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useRouter } from 'vue-router'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.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 OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||
import { createPallet, getPallet, getPalletLabelPdf, getPalletSummary, listPallets, listReceivingOverseasWarehouses, updatePallet, type PalletDto, type PalletSummaryDto, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||
import type { PalletListItem } from '../types'
|
||
|
||
const access = useAccessPreviewStore()
|
||
const router = useRouter()
|
||
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
|
||
const fbaWarehouses = ref<PlatformWarehouseResource[]>([])
|
||
const rows = ref<PalletListItem[]>([])
|
||
const loading = ref(false)
|
||
const contextLoading = ref(false)
|
||
const saving = ref(false)
|
||
const printingId = ref('')
|
||
const loadError = ref<ApiErrorDetails | null>(null)
|
||
const mutationError = ref<ApiErrorDetails | null>(null)
|
||
const loadErrorKind = ref<'context' | 'list'>('context')
|
||
const page = ref(1)
|
||
const pageSize = ref(10)
|
||
const total = ref(0)
|
||
const summary = ref<PalletSummaryDto>({ totalCount: 0, idleCount: 0, occupiedCount: 0, scrappedCount: 0 })
|
||
const filters = ref<QueryModel>({ warehouseId: '', code: '', type: '', status: '' })
|
||
const fields = computed<QueryField[]>(() => [
|
||
{ key: 'warehouseId', type: 'select', label: '仓库', width: 240, placeholder: contextLoading.value ? '仓库加载中' : '全部仓库', options: warehouses.value.map((item) => ({ label: `${item.warehouseName}(${item.warehouseCode})`, value: String(item.id) })), props: { filterable: true, clearable: true, disabled: contextLoading.value } },
|
||
{ key: 'code', type: 'input', label: '托盘编码', placeholder: '请输入托盘编码', width: 180 },
|
||
{ key: 'type', type: 'select', label: '托盘类型', placeholder: '全部类型', width: 160, options: ['WOOD', 'PLASTIC', 'METAL', 'PAPER', 'FUMIGATION_FREE'].map((value) => ({ label: palletTypeLabel(value), value })) },
|
||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 140, options: [{ label: '闲置', value: 'IDLE' }, { label: '占用', value: 'OCCUPIED' }, { label: '已报废', value: 'SCRAPPED' }] },
|
||
])
|
||
const columns: DataTableColumn[] = [
|
||
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
|
||
{ key: 'code', prop: 'code', label: '托盘编码', minWidth: 160 }, { key: 'type', prop: 'type', label: '托盘类型', minWidth: 130 },
|
||
{ key: 'warehouse', prop: 'warehouse', label: '仓库', minWidth: 180 }, { key: 'volume', prop: 'volume', label: '容积(m³)', width: 110, align: 'right' },
|
||
{ key: 'purchasePrice', prop: 'purchasePrice', label: '采购价格', width: 120, align: 'right' }, { key: 'fbaWarehouse', prop: 'fbaWarehouse', label: '所属FBA仓', minWidth: 140 },
|
||
{ key: 'status', prop: 'status', label: '状态', width: 100 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
|
||
]
|
||
const dialogOpen = ref(false)
|
||
const dialogMode = ref<'create' | 'edit'>('create')
|
||
const detailLoading = ref(false)
|
||
const editReady = ref(false)
|
||
const editingPallet = ref<Pick<PalletListItem, 'id' | 'warehouseId' | 'code'> | null>(null)
|
||
const dialogError = ref<ApiErrorDetails | null>(null)
|
||
const dialogErrorKind = ref<'read' | 'save'>('read')
|
||
const dialogBusy = computed(() => saving.value || detailLoading.value || contextLoading.value)
|
||
const dialogAllowed = computed(() => access.can(dialogMode.value === 'create' ? permission.palletCreate : permission.palletEdit))
|
||
const contextFailed = computed(() => Boolean(loadError.value && loadErrorKind.value === 'context'))
|
||
const formLocked = computed(() => dialogBusy.value || !dialogAllowed.value || contextFailed.value || (dialogMode.value === 'edit' && !editReady.value))
|
||
const form = reactive({ id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '', version: 0 })
|
||
|
||
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' }
|
||
function number(value: unknown) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : 0 }
|
||
function palletTypeLabel(value: string) { return ({ WOOD: '木托盘', PLASTIC: '塑料托盘', METAL: '金属托盘', PAPER: '纸托盘', FUMIGATION_FREE: '免熏蒸托盘' } as Record<string, string>)[value] || value }
|
||
function statusLabel(value: string) { return ({ IDLE: '闲置', OCCUPIED: '占用', SCRAPPED: '已报废' } as Record<string, string>)[value] || value }
|
||
function warehouseName(id: string) { const warehouse = warehouses.value.find((item) => String(item.id) === id); return warehouse ? `${warehouse.warehouseName}(${warehouse.warehouseCode})` : '—' }
|
||
function fbaWarehouseName(code: string) { const warehouse = fbaWarehouses.value.find((item) => item.warehouseCode === code); return warehouse ? `${warehouse.warehouseName}(${warehouse.warehouseCode})` : text(code) }
|
||
function toRow(dto: PalletDto): PalletListItem { return { id: String(dto.id), customerId: String(dto.customerId), warehouseId: String(dto.warehouseId), locationId: text(dto.locationId), code: text(dto.palletCode), type: palletTypeLabel(dto.palletType), warehouse: warehouseName(String(dto.warehouseId)), volume: number(dto.capacityCbm), purchasePrice: number(dto.unitPrice), fbaWarehouse: fbaWarehouseName(dto.fbaWarehouseCode), status: statusLabel(dto.status), remark: text(dto.remark) } }
|
||
|
||
async function loadWarehouses() {
|
||
warehouses.value = []; filters.value.warehouseId = ''; rows.value = []; total.value = 0
|
||
contextLoading.value = true; loadError.value = null; loadErrorKind.value = 'context'
|
||
try {
|
||
const [warehousePage, platformFbaWarehouses] = await Promise.all([
|
||
listReceivingOverseasWarehouses(),
|
||
listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' }),
|
||
])
|
||
warehouses.value = warehousePage.records
|
||
fbaWarehouses.value = platformFbaWarehouses
|
||
}
|
||
catch (error) { loadError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') }
|
||
finally { contextLoading.value = false }
|
||
}
|
||
|
||
async function load(model: QueryModel = filters.value) {
|
||
Object.assign(filters.value, model)
|
||
const warehouseId = String(filters.value.warehouseId || '')
|
||
loading.value = true; loadError.value = null; loadErrorKind.value = 'list'
|
||
try {
|
||
const query = { page: page.value, size: pageSize.value, keyword: String(filters.value.code || '').trim() || undefined, palletType: String(filters.value.type || '') || undefined, status: String(filters.value.status || '') || undefined }
|
||
const [result, summaryResult] = await Promise.all([listPallets(warehouseId, query), getPalletSummary(warehouseId, query)])
|
||
rows.value = result.records.map(toRow); total.value = result.total
|
||
summary.value = summaryResult
|
||
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '托盘列表加载失败,请重试') }
|
||
finally { loading.value = false }
|
||
}
|
||
|
||
function retryLoad() {
|
||
if (loading.value || contextLoading.value) return
|
||
if (loadErrorKind.value === 'list') void load()
|
||
else void loadWarehouses()
|
||
}
|
||
|
||
function openCreate() {
|
||
if (dialogOpen.value || dialogBusy.value || !access.can(permission.palletCreate)) return
|
||
dialogMode.value = 'create'; editingPallet.value = null; editReady.value = false; dialogError.value = null
|
||
Object.assign(form, { id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '', version: 0 }); mutationError.value = null; dialogOpen.value = true
|
||
}
|
||
|
||
async function openEdit(source: object) {
|
||
if (dialogOpen.value || dialogBusy.value || !access.can(permission.palletEdit)) return
|
||
const row = source as PalletListItem
|
||
editingPallet.value = { id: row.id, warehouseId: row.warehouseId, code: row.code }
|
||
dialogMode.value = 'edit'; editReady.value = false; dialogError.value = null; mutationError.value = null; dialogOpen.value = true
|
||
await loadEdit()
|
||
}
|
||
|
||
async function loadEdit() {
|
||
const row = editingPallet.value
|
||
if (!dialogOpen.value || dialogMode.value !== 'edit' || !row || dialogBusy.value || !dialogAllowed.value) return
|
||
detailLoading.value = true; editReady.value = false; dialogError.value = null; dialogErrorKind.value = 'read'
|
||
try {
|
||
const detail = await getPallet(row.warehouseId, row.id)
|
||
Object.assign(form, { id: String(detail.id), warehouseId: String(detail.warehouseId), fbaWarehouseCode: detail.fbaWarehouseCode, code: detail.palletCode, type: detail.palletType, volume: number(detail.capacityCbm), purchasePrice: number(detail.unitPrice), status: detail.status, remark: detail.remark || '', version: detail.version }); editReady.value = true
|
||
} catch (error) { dialogError.value = getApiErrorDetails(error, '托盘详情加载失败,请重试') }
|
||
finally { detailLoading.value = false }
|
||
}
|
||
|
||
async function save() {
|
||
if (!dialogOpen.value || formLocked.value || !access.can(dialogMode.value === 'create' ? permission.palletCreate : permission.palletEdit)) return
|
||
if (!form.warehouseId || !form.code.trim() || !form.type || !form.fbaWarehouseCode || form.volume <= 0 || form.purchasePrice < 0 || !form.status) { ElMessage.warning('请完整填写仓库、托盘编码、类型、容积、采购价格、所属 FBA 仓和状态'); return }
|
||
saving.value = true; dialogError.value = null; dialogErrorKind.value = 'save'
|
||
const payload = { palletCode: form.code.trim(), palletType: form.type, fbaWarehouseCode: form.fbaWarehouseCode, capacityCbm: form.volume, unitPrice: form.purchasePrice, status: form.status, locationId: null, remark: form.remark.trim() || null }
|
||
try {
|
||
if (dialogMode.value === 'edit') await updatePallet(form.warehouseId, form.id, { ...payload, version: form.version })
|
||
else await createPallet(form.warehouseId, payload)
|
||
dialogOpen.value = false; ElMessage.success(dialogMode.value === 'edit' ? '托盘已更新' : '托盘已新增'); await load()
|
||
} catch (error) { dialogError.value = getApiErrorDetails(error, '托盘保存失败,请重试') }
|
||
finally { saving.value = false }
|
||
}
|
||
|
||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||
async function printPallet(source: object) {
|
||
if (printingId.value) return
|
||
const row = source as PalletListItem
|
||
printingId.value = row.id; mutationError.value = null
|
||
try {
|
||
const blob = await getPalletLabelPdf(row.warehouseId, row.id)
|
||
const url = URL.createObjectURL(blob)
|
||
try {
|
||
const link = document.createElement('a')
|
||
link.href = url; link.download = `${row.code}-托盘码.pdf`; link.click()
|
||
} finally { window.setTimeout(() => URL.revokeObjectURL(url), 0) }
|
||
ElMessage.success('已生成 1 张托盘标签 PDF')
|
||
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘标签打印失败,请重试') }
|
||
finally { printingId.value = '' }
|
||
}
|
||
function resetSearch(model: QueryModel) { page.value = 1; void load(model) }
|
||
function openDetail(source: object) { const row = source as PalletListItem; void router.push({ name: 'overseas-pallet-detail', params: { palletId: row.id }, query: { warehouseId: row.warehouseId } }) }
|
||
async function initialize() { await loadWarehouses(); if (!loadError.value) await load() }
|
||
onMounted(initialize)
|
||
</script>
|
||
|
||
<template>
|
||
<AdminPage spacing="compact" class="pallet-page admin-density admin-workbench"><OperationErrorToast :details="mutationError" />
|
||
<section class="summary-grid" aria-label="海外托盘数量概览">
|
||
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
|
||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Layers aria-hidden="true" /></span><h2 class="workbench-metric-label">托盘总数</h2></div>
|
||
<div class="workbench-metric-value"><strong>{{ summary.totalCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
|
||
</article>
|
||
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
|
||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackageCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">闲置托盘</h2></div>
|
||
<div class="workbench-metric-value"><strong>{{ summary.idleCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
|
||
</article>
|
||
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
|
||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Package aria-hidden="true" /></span><h2 class="workbench-metric-label">占用托盘</h2></div>
|
||
<div class="workbench-metric-value"><strong>{{ summary.occupiedCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
|
||
</article>
|
||
<article class="workbench-metric workbench-tone is-compact" data-tone="rose">
|
||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackageX aria-hidden="true" /></span><h2 class="workbench-metric-label">已报废</h2></div>
|
||
<div class="workbench-metric-value"><strong>{{ summary.scrappedCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
|
||
</article>
|
||
</section>
|
||
<ListPageLayout aria-label="托盘管理列表">
|
||
|
||
|
||
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-pallets" 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="fields" :loading="loading || contextLoading" ariaLabel="托盘筛选" preference-key="overseas-pallets-query" @search="search" @reset="resetSearch" /></template>
|
||
<template #notices><template v-if="loadError"><LoadErrorState title="托盘数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
|
||
|
||
|
||
<template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" :disabled="dialogOpen || dialogBusy" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
|
||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button v-if="access.can(permission.palletList)" class="density-action" data-tone="neutral" size="small" :loading="printingId === row.id" :disabled="Boolean(printingId) && printingId !== row.id" @click="printPallet(row)">打印</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.palletEdit)" size="small" :disabled="dialogOpen || dialogBusy" @click="openEdit(row)">编辑</el-button></div></template>
|
||
<template #pagination-left>共 {{ total }} 条托盘</template>
|
||
</DataTable></div></section>
|
||
</ListPageLayout>
|
||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增托盘' : `编辑托盘 · ${text(editingPallet?.code)}`" width="min(760px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :loading="detailLoading || contextLoading" :confirm-loading="dialogBusy" :confirm-disabled="formLocked" @confirm="save">
|
||
<LoadErrorState v-if="contextFailed && loadError" title="托盘候选加载失败" :message="loadError.message" :loading="contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||
<LoadErrorState v-if="dialogError" :title="dialogErrorKind === 'read' ? '托盘详情加载失败' : '托盘保存失败'" :message="dialogError.message" :show-retry="dialogErrorKind === 'read'" :loading="detailLoading" @retry="loadEdit"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
|
||
<p v-if="detailLoading" class="density-dialog-note" role="status">正在读取托盘资料…</p>
|
||
<el-form v-if="dialogMode === 'create' || editReady" class="density-form density-dialog-sections" label-position="left" label-width="120px" :disabled="formLocked">
|
||
<section class="density-dialog-section" data-tone="work" aria-label="托盘基础资料">
|
||
<h3 class="density-dialog-section__title">托盘资料</h3>
|
||
<table class="density-table density-table--editable pallet-editor-table"><tbody>
|
||
<tr><td><el-form-item label="托盘编码" required><el-input v-model="form.code" maxlength="128" /></el-form-item></td><td><el-form-item label="托盘类型" required><el-select v-model="form.type" placeholder="请选择托盘类型"><el-option v-for="value in ['WOOD', 'PLASTIC', 'METAL', 'PAPER', 'FUMIGATION_FREE']" :key="value" :label="palletTypeLabel(value)" :value="value" /></el-select></el-form-item></td></tr>
|
||
<tr><td><el-form-item label="容积(m³)" required><el-input-number v-model="form.volume" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></td><td><el-form-item label="采购价格(USD)" required><el-input-number v-model="form.purchasePrice" :min="0" :precision="2" controls-position="right" /></el-form-item></td></tr>
|
||
<tr><td colspan="2"><el-form-item v-if="dialogMode === 'create'" label="仓库" required><el-select v-model="form.warehouseId" filterable placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item><el-form-item v-else label="仓库"><span>{{ warehouseName(form.warehouseId) }}</span></el-form-item></td></tr>
|
||
<tr><td><el-form-item label="所属 FBA 仓" required><el-select v-model="form.fbaWarehouseCode" filterable placeholder="请选择所属 FBA 仓"><el-option v-for="item in fbaWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="item.warehouseCode" /></el-select></el-form-item></td><td><el-form-item label="状态" required><el-select v-model="form.status" placeholder="请选择托盘状态"><el-option label="闲置" value="IDLE" /><el-option label="占用" value="OCCUPIED" /><el-option label="已报废" value="SCRAPPED" /></el-select></el-form-item></td></tr>
|
||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" show-word-limit /></el-form-item></td></tr>
|
||
</tbody></table>
|
||
</section>
|
||
</el-form>
|
||
</AppDialog>
|
||
</AdminPage>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.pallet-page.admin-page.is-spacing-compact { gap: var(--admin-space-3); }
|
||
.pallet-editor-table { table-layout: fixed; }
|
||
.pallet-editor-table td:not([colspan]) { width: 50%; }
|
||
|
||
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--workbench-grid-gap); }
|
||
|
||
</style>
|