359 lines
22 KiB
Vue
359 lines
22 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
|
import { useAuthStore } from '@/modules/auth/public'
|
|
import {
|
|
createProcurement,
|
|
createTransport,
|
|
getBusinessOrder,
|
|
listBusinessOrders,
|
|
type CrossBorderBusinessKind,
|
|
type ProcurementDetail,
|
|
type ProcurementSummary,
|
|
type TransportDetail,
|
|
type TransportSummary,
|
|
} from '../api/businessOrders'
|
|
|
|
const props = defineProps<{ kind: CrossBorderBusinessKind }>()
|
|
const auth = useAuthStore()
|
|
|
|
type BusinessRow = ProcurementSummary | TransportSummary
|
|
type BusinessDetail = ProcurementDetail | TransportDetail
|
|
type PageMode = 'list' | 'create' | 'detail'
|
|
|
|
interface ColumnDefinition { key: string; label: string; width?: number }
|
|
|
|
interface BusinessForm {
|
|
purchaseCategory: string
|
|
productName: string
|
|
productSpecification: string
|
|
productUrl: string
|
|
purchaseQuantity: number
|
|
transportMode: string
|
|
palletRequired: string
|
|
transportType: string
|
|
origin: string
|
|
originAddress: string
|
|
destination: string
|
|
destinationAddress: string
|
|
containerType: string
|
|
palletCount: number
|
|
packageCount: number
|
|
totalWeightKg: number
|
|
totalVolumeCbm: number
|
|
budgetAmount?: number
|
|
currency: string
|
|
contactName: string
|
|
contactInfo: string
|
|
requestedDate: string
|
|
receivingAddress: string
|
|
remark: string
|
|
}
|
|
|
|
const configuration = computed(() => ({
|
|
procurement: {
|
|
title: '海外采购',
|
|
description: '查询并管理海外采购订单',
|
|
columns: [
|
|
{ key: 'orderNo', label: '订单号', width: 180 },
|
|
{ key: 'purchaseCategory', label: '采购品类', width: 130 },
|
|
{ key: 'productName', label: '商品名称', width: 160 },
|
|
{ key: 'productSpecification', label: '商品规格', width: 160 },
|
|
{ key: 'purchaseQuantity', label: '采购数量', width: 100 },
|
|
{ key: 'budgetUnitPrice', label: '预算单价', width: 110 },
|
|
{ key: 'status', label: '状态', width: 120 },
|
|
{ key: 'createdAt', label: '创建时间', width: 170 },
|
|
] satisfies ColumnDefinition[],
|
|
},
|
|
trucking: {
|
|
title: '美国本土卡',
|
|
description: '查询并管理美国本土卡订单',
|
|
columns: transportColumns(false),
|
|
},
|
|
looseboard: {
|
|
title: '美本土散板',
|
|
description: '查询并管理美本土散板订单',
|
|
columns: transportColumns(true),
|
|
},
|
|
})[props.kind])
|
|
|
|
function transportColumns(looseboard: boolean): ColumnDefinition[] {
|
|
return [
|
|
{ key: 'orderNo', label: '订单编号', width: 180 },
|
|
...(looseboard ? [{ key: 'transportMode', label: '运输方式', width: 120 }, { key: 'palletRequired', label: '是否打盘', width: 100 }] : []),
|
|
{ key: 'transportType', label: '运输类型', width: 130 },
|
|
{ key: 'origin', label: '起运地', width: 150 },
|
|
{ key: 'destination', label: '目的地', width: 150 },
|
|
{ key: 'containerType', label: '柜型', width: 100 },
|
|
{ key: 'totalWeightKg', label: '总重量(kg)', width: 120 },
|
|
{ key: 'budgetAmount', label: '预算总价', width: 110 },
|
|
{ key: 'requestedDate', label: '要求时间', width: 120 },
|
|
{ key: 'status', label: '状态', width: 130 },
|
|
]
|
|
}
|
|
|
|
const mode = ref<PageMode>('list')
|
|
const loading = ref(false)
|
|
const submitting = ref(false)
|
|
const submitKey = ref('')
|
|
const error = ref('')
|
|
const rows = ref<BusinessRow[]>([])
|
|
const detail = ref<BusinessDetail | null>(null)
|
|
const formRef = ref<FormInstance>()
|
|
const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' })
|
|
const total = ref(0)
|
|
|
|
const form = reactive<BusinessForm>(emptyForm())
|
|
const rules: FormRules<BusinessForm> = {
|
|
purchaseCategory: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
|
|
productName: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
|
|
purchaseQuantity: [{ required: true, type: 'number', min: 1, message: '采购数量至少为 1', trigger: 'change' }],
|
|
origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }],
|
|
destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
|
|
palletCount: [{ required: true, type: 'number', min: 1, message: '托盘数至少为 1', trigger: 'change' }],
|
|
contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
|
|
contactInfo: [{ required: true, message: '请输入联系电话或联系方式', trigger: 'blur' }],
|
|
requestedDate: [{ required: true, message: '请选择要求时间', trigger: 'change' }],
|
|
}
|
|
|
|
const detailFields = computed(() => {
|
|
if (!detail.value) return []
|
|
const summary = detail.value.summary
|
|
if (props.kind === 'procurement') {
|
|
const current = detail.value as ProcurementDetail
|
|
return [
|
|
['订单号', summary.orderNo], ['采购品类', read(summary, 'purchaseCategory')],
|
|
['商品名称', read(summary, 'productName')], ['商品规格', read(summary, 'productSpecification')],
|
|
['采购数量', read(summary, 'purchaseQuantity')], ['预算单价', money(read(summary, 'budgetUnitPrice'), read(summary, 'currency'))],
|
|
['联系人', current.contactName], ['联系电话', current.contactPhone],
|
|
['收货地址', current.receivingAddress], ['商品链接', current.productUrl],
|
|
['国内运单', joinText(current.domesticCarrier, current.domesticTrackingNo)],
|
|
['海外运单', joinText(current.overseasCarrier, current.overseasTrackingNo)],
|
|
['状态', statusLabel(summary.status)], ['备注', current.remark],
|
|
]
|
|
}
|
|
const current = detail.value as TransportDetail
|
|
return [
|
|
['订单编号', summary.orderNo], ['运输方式', read(summary, 'transportMode')],
|
|
['是否打盘', yesNo(read(summary, 'palletRequired'))], ['运输类型', transportTypeLabel(read(summary, 'transportType'))],
|
|
['起运地', read(summary, 'origin')], ['起运地址', current.originAddress],
|
|
['目的地', read(summary, 'destination')], ['目的地址', current.destinationAddress],
|
|
['柜型', read(summary, 'containerType')], ['托盘数', read(summary, 'palletCount')],
|
|
['件数', read(summary, 'packageCount')], ['总重量(kg)', read(summary, 'totalWeightKg')],
|
|
['总体积(m³)', read(summary, 'totalVolumeCbm')], ['预算总价', money(read(summary, 'budgetAmount'), read(summary, 'currency'))],
|
|
['联系人', read(summary, 'contactName')], ['联系方式', read(summary, 'contactInfo')],
|
|
['要求时间', read(summary, 'requestedDate')], ['状态', statusLabel(summary.status)], ['备注', current.remark],
|
|
]
|
|
})
|
|
|
|
function emptyForm(): BusinessForm {
|
|
return {
|
|
purchaseCategory: '', productName: '', productSpecification: '', productUrl: '', purchaseQuantity: 1,
|
|
transportMode: 'SHARED_TRUCK', palletRequired: 'NO', transportType: 'PORT_PICKUP', origin: '', originAddress: '',
|
|
destination: '', destinationAddress: '', containerType: '', palletCount: 1, packageCount: 0,
|
|
totalWeightKg: 0, totalVolumeCbm: 0, currency: 'USD', contactName: '', contactInfo: '', requestedDate: '',
|
|
receivingAddress: '', remark: '',
|
|
}
|
|
}
|
|
|
|
function resetForm() { Object.assign(form, emptyForm()); submitKey.value = ''; formRef.value?.clearValidate() }
|
|
function read(source: object, key: string) { return Reflect.get(source, key) as unknown }
|
|
function text(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
|
function joinText(...values: unknown[]) { const result = values.filter(Boolean).join(' / '); return result || '—' }
|
|
function money(amount: unknown, currency: unknown) { return amount === null || amount === undefined ? '—' : `${text(currency)} ${Number(amount).toFixed(2)}` }
|
|
function dateTime(value: unknown) { return value ? new Date(String(value)).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
|
function yesNo(value: unknown) { return value === 'YES' ? '是' : value === 'NO' ? '否' : text(value) }
|
|
function transportTypeLabel(value: unknown) { return ({ PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' } as Record<string, string>)[String(value)] || text(value) }
|
|
function transportModeLabel(value: unknown) { return ({ SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' } as Record<string, string>)[String(value)] || text(value) }
|
|
function statusLabel(value: unknown) {
|
|
return ({ PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value)
|
|
}
|
|
function displayCell(candidate: unknown, key: string) {
|
|
const row = candidate as BusinessRow
|
|
const value = read(row, key)
|
|
if (key === 'status') return statusLabel(value)
|
|
if (key === 'transportType') return transportTypeLabel(value)
|
|
if (key === 'transportMode') return transportModeLabel(value)
|
|
if (key === 'palletRequired') return yesNo(value)
|
|
if (key === 'createdAt') return dateTime(value)
|
|
if (key === 'budgetUnitPrice' || key === 'budgetAmount') return money(value, read(row, 'currency'))
|
|
return text(value)
|
|
}
|
|
function idempotencyKey() { return globalThis.crypto?.randomUUID?.() || `client-${Date.now()}-${Math.random().toString(16).slice(2)}` }
|
|
function errorMessage(cause: unknown) { return cause instanceof Error ? cause.message : '请求失败,请稍后重试' }
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
const params = {
|
|
page: query.page, size: query.size, orderNo: query.orderNo || undefined, status: query.status || undefined,
|
|
routeKeyword: query.routeKeyword || undefined, productName: query.productName || undefined,
|
|
purchaseCategory: query.purchaseCategory || undefined,
|
|
}
|
|
const result = props.kind === 'procurement'
|
|
? await listBusinessOrders<ProcurementSummary>('procurement', params)
|
|
: await listBusinessOrders<TransportSummary>(props.kind, params)
|
|
rows.value = result.records
|
|
total.value = result.total
|
|
} catch (cause) {
|
|
error.value = errorMessage(cause)
|
|
rows.value = []
|
|
total.value = 0
|
|
} finally { loading.value = false }
|
|
}
|
|
|
|
function resetQuery() {
|
|
Object.assign(query, { page: 1, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' })
|
|
void load()
|
|
}
|
|
|
|
async function openDetail(candidate: unknown) {
|
|
const row = candidate as BusinessRow
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
detail.value = props.kind === 'procurement'
|
|
? await getBusinessOrder<ProcurementDetail>('procurement', row.orderNo)
|
|
: await getBusinessOrder<TransportDetail>(props.kind, row.orderNo)
|
|
mode.value = 'detail'
|
|
} catch (cause) { ElMessage.error(errorMessage(cause)) }
|
|
finally { loading.value = false }
|
|
}
|
|
|
|
async function submit() {
|
|
if (!await formRef.value?.validate().catch(() => false)) return
|
|
submitting.value = true
|
|
const stableSubmitKey = submitKey.value || idempotencyKey()
|
|
submitKey.value = stableSubmitKey
|
|
try {
|
|
if (props.kind === 'procurement') {
|
|
await createProcurement({
|
|
customerName: auth.user?.tenantName || '',
|
|
purchaseCategory: form.purchaseCategory, productName: form.productName,
|
|
productSpecification: form.productSpecification || undefined, productUrl: form.productUrl || undefined,
|
|
purchaseQuantity: form.purchaseQuantity, budgetUnitPrice: form.budgetAmount,
|
|
currency: form.currency || undefined, receivingAddress: form.receivingAddress || undefined,
|
|
contactName: form.contactName, contactPhone: form.contactInfo, remark: form.remark || undefined,
|
|
idempotencyKey: stableSubmitKey,
|
|
})
|
|
} else {
|
|
const base = {
|
|
transportType: form.transportType, origin: form.origin, originAddress: form.originAddress || undefined,
|
|
destination: form.destination, destinationAddress: form.destinationAddress || undefined,
|
|
containerType: form.containerType || undefined, packageCount: form.packageCount,
|
|
totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm,
|
|
budgetAmount: form.budgetAmount, currency: form.currency || undefined,
|
|
contactName: form.contactName || undefined, contactInfo: form.contactInfo || undefined,
|
|
requestedDate: form.requestedDate || undefined, remark: form.remark || undefined,
|
|
idempotencyKey: stableSubmitKey,
|
|
}
|
|
await createTransport(props.kind, props.kind === 'trucking'
|
|
? { ...base, palletCount: form.palletCount }
|
|
: { ...base, transportMode: form.transportMode, palletRequired: form.palletRequired })
|
|
}
|
|
ElMessage.success('订单提交成功')
|
|
mode.value = 'list'
|
|
resetForm()
|
|
await load()
|
|
} catch (cause) { ElMessage.error(errorMessage(cause)) }
|
|
finally { submitting.value = false }
|
|
}
|
|
|
|
onMounted(() => void load())
|
|
</script>
|
|
|
|
<template>
|
|
<section v-loading="loading" class="business-orders" :aria-busy="loading">
|
|
<header class="page-head">
|
|
<div>
|
|
<h2>{{ mode === 'detail' ? `${configuration.title}订单详情` : mode === 'create' ? `${configuration.title}下单` : configuration.title }}</h2>
|
|
<p>{{ mode === 'list' ? configuration.description : mode === 'create' ? '请填写订单信息,带 * 项为必填项' : `订单号:${detail?.summary.orderNo || '—'}` }}</p>
|
|
</div>
|
|
<el-button v-if="mode !== 'list'" @click="mode = 'list'">返回列表</el-button>
|
|
<el-button v-else type="primary" @click="resetForm(); mode = 'create'">新增下单</el-button>
|
|
</header>
|
|
|
|
<template v-if="mode === 'list'">
|
|
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false">
|
|
<template #default><el-button size="small" @click="load">重新加载</el-button></template>
|
|
</el-alert>
|
|
<el-card shadow="never" class="filter-card">
|
|
<el-form inline @submit.prevent="query.page = 1; load()">
|
|
<el-form-item label="订单号"><el-input v-model.trim="query.orderNo" clearable placeholder="请输入订单号" /></el-form-item>
|
|
<el-form-item v-if="kind === 'procurement'" label="采购品类"><el-input v-model.trim="query.purchaseCategory" clearable placeholder="请输入采购品类" /></el-form-item>
|
|
<el-form-item v-if="kind === 'procurement'" label="商品名称"><el-input v-model.trim="query.productName" clearable placeholder="请输入商品名称" /></el-form-item>
|
|
<el-form-item v-else label="运输路线"><el-input v-model.trim="query.routeKeyword" clearable placeholder="起运地或目的地" /></el-form-item>
|
|
<el-form-item label="状态"><el-input v-model.trim="query.status" clearable placeholder="请输入状态" /></el-form-item>
|
|
<el-form-item><el-button type="primary" native-type="submit">查询</el-button><el-button @click="resetQuery">重置</el-button></el-form-item>
|
|
</el-form>
|
|
</el-card>
|
|
<el-card shadow="never">
|
|
<el-table :data="rows" empty-text="暂无订单" border>
|
|
<el-table-column label="操作" width="90" fixed="left"><template #default="scope"><el-button link type="primary" @click="openDetail(scope.row)">查看</el-button></template></el-table-column>
|
|
<el-table-column v-for="column in configuration.columns" :key="column.key" :label="column.label" :width="column.width" show-overflow-tooltip>
|
|
<template #default="scope">{{ displayCell(scope.row, column.key) }}</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" class="pagination" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next" @change="load" />
|
|
</el-card>
|
|
</template>
|
|
|
|
<el-card v-else-if="mode === 'detail'" shadow="never">
|
|
<dl class="detail-grid">
|
|
<div v-for="field in detailFields" :key="String(field[0])"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div>
|
|
</dl>
|
|
</el-card>
|
|
|
|
<el-card v-else shadow="never" class="form-card">
|
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
|
<template v-if="kind === 'procurement'">
|
|
<el-form-item label="采购品类" prop="purchaseCategory"><el-input v-model.trim="form.purchaseCategory" maxlength="128" placeholder="请输入采购品类" /></el-form-item>
|
|
<el-form-item label="商品名称" prop="productName"><el-input v-model.trim="form.productName" maxlength="255" placeholder="请输入商品名称" /></el-form-item>
|
|
<el-form-item label="商品规格"><el-input v-model.trim="form.productSpecification" maxlength="500" placeholder="请输入规格、型号等信息" /></el-form-item>
|
|
<el-form-item label="商品链接"><el-input v-model.trim="form.productUrl" maxlength="2000" placeholder="请输入商品链接" /></el-form-item>
|
|
<el-form-item label="采购数量" prop="purchaseQuantity"><el-input-number v-model="form.purchaseQuantity" :min="1" placeholder="请输入数量" /></el-form-item>
|
|
<el-form-item label="收货地址"><el-input v-model.trim="form.receivingAddress" maxlength="1000" placeholder="请输入收货地址" /></el-form-item>
|
|
</template>
|
|
<template v-else>
|
|
<el-form-item v-if="kind === 'looseboard'" label="运输方式"><el-radio-group v-model="form.transportMode"><el-radio-button value="SHARED_TRUCK">拼车</el-radio-button><el-radio-button value="PRIORITY_CONSOLIDATION">优先拼载</el-radio-button></el-radio-group></el-form-item>
|
|
<el-form-item v-if="kind === 'looseboard'" label="是否打盘"><el-radio-group v-model="form.palletRequired"><el-radio-button value="NO">否</el-radio-button><el-radio-button value="YES">是</el-radio-button></el-radio-group></el-form-item>
|
|
<el-form-item label="运输类型"><el-select v-model="form.transportType"><el-option label="港口提柜" value="PORT_PICKUP" /><el-option label="仓到仓" value="WAREHOUSE_TO_WAREHOUSE" /><el-option label="仓到门" value="WAREHOUSE_TO_DOOR" /></el-select></el-form-item>
|
|
<el-form-item label="起运地" prop="origin"><el-input v-model.trim="form.origin" maxlength="255" placeholder="请输入起运地" /></el-form-item>
|
|
<el-form-item label="起运地址"><el-input v-model.trim="form.originAddress" maxlength="500" placeholder="请输入详细起运地址" /></el-form-item>
|
|
<el-form-item label="目的地" prop="destination"><el-input v-model.trim="form.destination" maxlength="255" placeholder="请输入目的地" /></el-form-item>
|
|
<el-form-item label="目的地址"><el-input v-model.trim="form.destinationAddress" maxlength="500" placeholder="请输入详细目的地址" /></el-form-item>
|
|
<el-form-item label="柜型"><el-input v-model.trim="form.containerType" maxlength="32" placeholder="请输入柜型" /></el-form-item>
|
|
<el-form-item v-if="kind === 'trucking'" label="托盘数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" placeholder="请输入托盘数" /></el-form-item>
|
|
<el-form-item label="件数"><el-input-number v-model="form.packageCount" :min="0" placeholder="请输入件数" /></el-form-item>
|
|
<el-form-item label="总重量(kg)"><el-input-number v-model="form.totalWeightKg" :min="0" :precision="2" placeholder="请输入总重量" /></el-form-item>
|
|
<el-form-item label="总体积(m³)"><el-input-number v-model="form.totalVolumeCbm" :min="0" :precision="3" placeholder="请输入总体积" /></el-form-item>
|
|
<el-form-item label="要求时间" :prop="kind === 'trucking' ? 'requestedDate' : undefined"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
|
</template>
|
|
<el-form-item :label="kind === 'procurement' ? '预算单价' : '预算金额'"><el-input-number v-model="form.budgetAmount" :min="0" :precision="2" placeholder="请输入预算金额" /><el-select v-model="form.currency" class="currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
|
|
<el-form-item label="联系人" prop="contactName"><el-input v-model.trim="form.contactName" maxlength="128" placeholder="请输入联系人" /></el-form-item>
|
|
<el-form-item label="联系方式" prop="contactInfo"><el-input v-model.trim="form.contactInfo" maxlength="128" placeholder="请输入电话、微信或邮箱" /></el-form-item>
|
|
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="请输入其他要求" /></el-form-item>
|
|
<el-form-item><el-button @click="mode = 'list'">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">提交订单</el-button></el-form-item>
|
|
</el-form>
|
|
</el-card>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.business-orders { display: grid; gap: 18px; }
|
|
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
|
.page-head h2 { margin: 0; color: #111827; font-size: 26px; }
|
|
.page-head p { margin: 7px 0 0; color: #64748b; font-size: 14px; }
|
|
.filter-card :deep(.el-form-item) { margin-bottom: 0; }
|
|
.filter-card :deep(.el-input) { width: 190px; }
|
|
.pagination { justify-content: flex-end; margin-top: 18px; }
|
|
.detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; }
|
|
.detail-grid div { min-width: 0; padding: 18px 20px; border-right: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5; }
|
|
.detail-grid dt { margin-bottom: 7px; color: #8492a6; font-size: 13px; }
|
|
.detail-grid dd { margin: 0; overflow-wrap: anywhere; color: #1f2937; line-height: 1.55; }
|
|
.form-card :deep(.el-form) { max-width: 860px; }
|
|
.form-card :deep(.el-input), .form-card :deep(.el-textarea), .form-card :deep(.el-select) { width: 100%; }
|
|
.form-card .currency { width: 110px; margin-left: 12px; }
|
|
</style>
|