Files
admin-web/src/modules/overseas-warehouse/pages/InventoryView.vue
T
2026-08-26 20:39:55 +08:00

108 lines
8.7 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, ref } from 'vue'
import { BookOpen, Eye } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.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 QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { toInventoryListItem } from '../inventoryPresentation'
import { overseasWarehousePermissions } from '../permissions'
import type { InventoryListItem } from '../types'
const router = useRouter()
const access = useAccessPreviewStore()
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
const rows = ref<InventoryListItem[]>([])
const summary = ref<InventoryDashboardDto | null>(null)
const loading = ref(false)
const contextLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const filters = ref<QueryModel>({ warehouseId: '', owner: '', sku: '', productName: '', batchNo: '', location: '', status: '' })
const fields = computed<QueryField[]>(() => [
...(access.can(overseasWarehousePermissions.warehouseList) ? [{ key: 'warehouseId', type: 'select' as const, label: '海外仓', width: 230, placeholder: '全部海外仓', options: warehouses.value.map((item) => ({ label: `${item.warehouseName}(${item.warehouseCode})`, value: String(item.id) })), props: { filterable: true, clearable: true, disabled: contextLoading.value } }] : []),
{ key: 'owner', type: 'input', label: '客户/货主', placeholder: '请输入客户或货主' },
{ key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入SKU编码' },
{ key: 'productName', type: 'input', label: '中文品名', placeholder: '请输入中文品名' },
{ key: 'batchNo', type: 'input', label: '批次号', placeholder: '请输入批次号' },
{ key: 'location', type: 'input', label: '库位', placeholder: '请输入库位(可空)' },
{ key: 'status', type: 'select', label: '库存状态', placeholder: '全部库存状态', options: [{ label: '有可用库存', value: 'AVAILABLE' }, { label: '存在暂扣', value: 'HOLD' }, { label: '已分配', value: 'ALLOCATED' }, { label: '在途', value: 'IN_TRANSIT' }, { label: '收货中', value: 'RECEIVING' }, { label: '采购库存', value: 'PROCUREMENT' }] },
])
const metrics = computed(() => [
['在库总量', summary.value?.totalPackages ?? 0, 'primary'], ['可用库存', summary.value?.availablePackages ?? 0, 'success'], ['暂扣库存', summary.value?.holdPackages ?? 0, 'danger'],
['美本土散板', '—', 'neutral'], ['在途库存', summary.value?.inTransitPackages ?? 0, 'warning'], ['采购库存', summary.value?.procurementPackages ?? 0, 'neutral'],
] as const)
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'owner', prop: 'owner', label: '客户', minWidth: 160 },
{ key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
{ key: 'sku', prop: 'sku', label: 'SKU编码', minWidth: 150 },
{ key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 },
{ key: 'location', prop: 'location', label: '库位', minWidth: 140, defaultVisible: false },
{ key: 'onHand', prop: 'onHand', label: '在库总量', width: 105, align: 'right' },
{ key: 'available', prop: 'available', label: '可用库存', width: 105, align: 'right' },
{ key: 'held', prop: 'held', label: '暂扣库存', width: 105, align: 'right' },
{ key: 'looseboard', prop: 'looseboard', label: '美本土散板', width: 115, align: 'right' },
{ key: 'inTransit', prop: 'inTransit', label: '在途库存', width: 105, align: 'right' },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150 },
]
async function loadWarehouses() {
if (!access.can(overseasWarehousePermissions.warehouseList)) return
contextLoading.value = true; loadError.value = null
try { warehouses.value = (await listReceivingOverseasWarehouses()).records }
catch (error) { loadError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') }
finally { contextLoading.value = false }
}
function query() {
return { page: page.value, size: pageSize.value, customerName: String(filters.value.owner || '').trim() || undefined, overseasWarehouseId: String(filters.value.warehouseId || '') || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, productName: String(filters.value.productName || '').trim() || undefined, batchNo: String(filters.value.batchNo || '').trim() || undefined, warehouseLocationCode: String(filters.value.location || '').trim() || undefined, inventoryStatus: String(filters.value.status || '') || undefined }
}
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
loading.value = true; loadError.value = null
try { const [pageResult, summaryResult] = await Promise.all([listInventoryBalances(query()), getInventorySummary(query())]); rows.value = pageResult.records.map(toInventoryListItem); total.value = pageResult.total; summary.value = summaryResult }
catch (error) { rows.value = []; total.value = 0; summary.value = null; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') }
finally { loading.value = false }
}
function search(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(source: object) { const row = source as InventoryListItem; void router.push({ name: 'overseas-inventory-detail', params: { inventoryId: row.id }, query: { overseasWarehouseId: row.warehouseId, inboundOrderNo: row.inboundOrderNo, inboundCode: row.inboundCode, skuCode: row.sku === '—' ? undefined : row.sku.split('、')[0] } }) }
onMounted(() => { void loadWarehouses(); void load() })
</script>
<template>
<AdminPage><ListPageLayout aria-label="海外仓库存管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading || contextLoading" ariaLabel="库存筛选" preference-key="overseas-inventory-query" @search="search" @reset="page = 1; rows = []; total = 0; summary = null" /></template>
<template #metrics><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}</span><strong>{{ metric[1] }}</strong></div></section></template>
<template v-if="loadError" #state><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory" row-key="id" empty-text="暂无库存记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #pagination-left>共 {{ total }} 条库存记录</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
<style scoped>
.inventory-metrics { display: grid; grid-template-columns: repeat(6, minmax(130px, 1fr)); overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.inventory-metric { display: flex; min-width: 0; flex-direction: column; gap: 5px; padding: 13px 16px; border-right: 1px solid var(--cc-border); }
.inventory-metric:last-child { border-right: 0; }
.inventory-metric span { color: #64748b; font-size: 12px; }
.inventory-metric strong { color: #334155; font-size: 22px; line-height: 1; }
.inventory-metric.is-primary strong { color: var(--cc-primary); }
.inventory-metric.is-success strong { color: #16a34a; }
.inventory-metric.is-danger strong { color: #dc2626; }
.inventory-metric.is-warning strong { color: #d97706; }
</style>