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

101 lines
11 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 { onMounted, reactive, ref } from 'vue'
import { Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.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 StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, type ExceptionOrderRow, type ExceptionRelation } from '../api/exceptionOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
const rows = ref<ExceptionOrderRow[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
const access = useAccessPreviewStore()
const filters = ref<QueryModel>({ keyword: '', exceptionType: '', status: '' })
const fields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '异常单号 / 关联单号 / 客户 / 原因' },
{ key: 'exceptionType', type: 'select', label: '异常类型', placeholder: '全部类型', options: [{ label: '退货', value: 'RETURN' }, { label: '拒收', value: 'REJECT' }, { label: '破损', value: 'DAMAGED' }, { label: '其他', value: 'OTHER' }] },
{ key: 'status', type: 'select', label: '处理状态', placeholder: '全部状态', options: [{ label: '待处理', value: 'PENDING' }, { label: '处理中', value: 'PROCESSING' }, { label: '已完成', value: 'COMPLETED' }] },
]
const columns: DataTableColumn[] = [
{ key: 'exceptionNo', prop: 'exceptionNo', label: '异常订单号', minWidth: 180, fixed: 'left' },
{ key: 'related', label: '关联订单号', minWidth: 230, slotName: 'related' },
{ key: 'exceptionType', label: '异常类型', width: 100, formatter: (row: ExceptionOrderRow) => typeLabels[row.exceptionType] || row.exceptionType },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 160 },
{ key: 'overseasWarehouseName', prop: 'overseasWarehouseName', label: '仓库', minWidth: 140 },
{ key: 'reason', prop: 'reason', label: '异常原因', minWidth: 200 },
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 120 },
{ key: 'packageCount', prop: 'packageCount', label: '箱数', width: 80 },
{ key: 'exceptionAt', label: '异常时间', minWidth: 170, formatter: (row: ExceptionOrderRow) => formatOverseasDateTime(row.exceptionAt) },
{ key: 'status', label: '处理状态', width: 100, slotName: 'status' },
{ key: 'handlingAction', prop: 'handlingAction', label: '处理动作', minWidth: 140 },
{ key: 'returnLogisticsNo', prop: 'returnLogisticsNo', label: '退回物流单号', minWidth: 160 },
{ key: 'handlerName', prop: 'handlerName', label: '处理人', minWidth: 110 },
{ key: 'updatedAt', label: '更新时间', minWidth: 170, formatter: (row: ExceptionOrderRow) => formatOverseasDateTime(row.updatedAt) },
]
const typeLabels: Record<string, string> = { RETURN: '退货', REJECT: '拒收', DAMAGED: '破损', OTHER: '其他' }
const statusLabels: Record<string, string> = { PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成' }
const createOpen = ref(false); const saving = ref(false); const resolving = ref(false); const relation = ref<ExceptionRelation | null>(null); const relationError = ref('')
const form = reactive({ customerId: '', inboundCode: '', lastMileOrderNo: '', exceptionType: '', reason: '', packageCount: undefined as number | undefined, skuCode: '' })
function openCreate() { Object.assign(form, { customerId: '', inboundCode: '', lastMileOrderNo: '', exceptionType: '', reason: '', packageCount: undefined, skuCode: '' }); relation.value = null; relationError.value = ''; createOpen.value = true }
async function resolveRelation() {
if (!form.inboundCode.trim() && !form.lastMileOrderNo.trim()) { relation.value = null; relationError.value = '入仓码和尾程订单号至少填写一个'; return }
resolving.value = true; relationError.value = ''
try { relation.value = await resolveExceptionRelation({ customerId: form.customerId.trim() || undefined, inboundCode: form.inboundCode.trim() || undefined, lastMileOrderNo: form.lastMileOrderNo.trim() || undefined }); if (!form.inboundCode && relation.value.inboundCode) form.inboundCode = relation.value.inboundCode; if (!form.lastMileOrderNo && relation.value.lastMileOrderNo) form.lastMileOrderNo = relation.value.lastMileOrderNo }
catch (cause) { relation.value = null; relationError.value = getApiErrorDetails(cause, '关联单据校验失败').message }
finally { resolving.value = false }
}
async function submit() {
await resolveRelation(); if (!relation.value) return
if (!form.exceptionType || !form.reason.trim()) { ElMessage.warning('请选择异常类型并填写异常原因'); return }
saving.value = true
try { const result = await createExceptionOrder({ customerId: form.customerId.trim() || undefined, inboundCode: form.inboundCode.trim() || undefined, lastMileOrderNo: form.lastMileOrderNo.trim() || undefined, exceptionType: form.exceptionType, reason: form.reason.trim(), packageCount: form.packageCount, skuCode: form.skuCode.trim() || undefined }); createOpen.value = false; ElMessage.success(`异常单 ${result.exceptionNo} 创建成功`); page.value = 1; await load() }
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '新增异常订单失败').message) } finally { saving.value = false }
}
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listExceptionOrders({ page: page.value, size: pageSize.value, keyword: text('keyword'), exceptionType: text('exceptionType'), status: text('status') }); rows.value = result.records; total.value = result.total } catch (cause) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '异常订单列表加载失败') } finally { loading.value = false } }
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
onMounted(load)
</script>
<template>
<AdminPage><ListPageLayout aria-label="异常订单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="异常订单筛选" preference-key="overseas-exception-orders-query" @search="search" @reset="reset" /></template>
<template v-if="error" #state><LoadErrorState title="异常订单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" 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-exception-orders" row-key="id" empty-text="暂无异常订单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.exceptionOrderCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增异常订单</el-button></template>
<template #related="{ row }"><div class="related-orders"><span v-if="row.lastMileOrderNo" class="related-order-reference">尾程 <OrderReferenceText :value="String(row.lastMileOrderNo)" /></span><span v-if="row.deliveryNo">派送 {{ row.deliveryNo }}</span><span v-if="row.inboundCode">入仓 {{ row.inboundCode }}</span><span v-if="row.outboundNo">出库 {{ row.outboundNo }}</span></div></template>
<template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status)" /></template><template #pagination-left>共 {{ total }} 条异常订单</template>
</DataTable></section>
</ListPageLayout></AdminPage>
<AppDialog v-model="createOpen" title="新增异常订单" width="min(760px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="saving" :loading="resolving" @confirm="submit">
<el-alert title="入仓码和尾程订单号至少填写一个;两项都填写时必须属于同一订单。" type="warning" :closable="false" />
<el-form label-position="top" class="create-form"><div class="form-grid">
<el-form-item label="入仓码"><el-input v-model="form.inboundCode" placeholder="请输入入仓码" @blur="resolveRelation" /></el-form-item>
<el-form-item label="尾程订单号"><el-input v-model="form.lastMileOrderNo" placeholder="请输入尾程订单号" @blur="resolveRelation" /></el-form-item>
<el-form-item label="异常类型" required><el-select v-model="form.exceptionType" placeholder="请选择异常类型"><el-option v-for="(label, value) in typeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="处理状态"><el-input model-value="待处理" readonly /></el-form-item>
<el-form-item label="箱数"><el-input-number v-model="form.packageCount" :min="1" :max="relation?.availablePackages || 999999" /></el-form-item>
<el-form-item label="SKU(可自由填写)"><el-input v-model="form.skuCode" maxlength="128" /></el-form-item>
</div><el-form-item label="异常原因" required><el-input v-model="form.reason" type="textarea" :rows="4" maxlength="1000" show-word-limit /></el-form-item>
<el-alert v-if="relation" type="success" :closable="false"><template #title><span class="relation-alert-title">已关联:<OrderReferenceText :value="relation.lastMileOrderNo || '无尾程单'" /> / {{ relation.inboundCode || '无入仓单' }},客户 {{ relation.customerName || relation.customerId }}</span></template></el-alert>
<el-alert v-else-if="relationError" :title="relationError" type="error" :closable="false" />
</el-form>
</AppDialog>
</template>
<style scoped>
.related-orders { display: grid; gap: 2px; font-size: 12px; }.related-order-reference,.relation-alert-title { display: flex; min-width: 0; align-items: baseline; gap: 3px; }.create-form { margin-top: 18px; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
@media (max-width: 720px) { .form-grid { grid-template-columns: 1fr; } }
</style>