feat(client): add customs documents and sailing prices pages

This commit is contained in:
2026-09-25 22:23:11 +08:00
parent fc008886fc
commit e62278e1c9
5 changed files with 128 additions and 3 deletions
+17 -1
View File
@@ -1,4 +1,4 @@
import { FileText, Ship } from '@lucide/vue'
import { Calculator, FileText, Ship } from '@lucide/vue'
import type { ClientModuleManifest } from '../types.ts'
export const firstLegModule = {
@@ -44,5 +44,21 @@ export const firstLegModule = {
release: 'active',
meta: { title: '快速下单', description: '按渠道、目的仓和产品 SKU 快速生成订单草稿', permission: 'order:first-leg:quick-create' },
},
{
path: 'first-leg/customs-documents',
name: 'first-leg-customs-documents',
component: () => import('./views/CustomsDocumentsView.vue'),
release: 'active',
meta: { title: '报关资料', description: '管理装柜后的退税报关资料与回执', permission: 'order:first-leg:list' },
navigation: { key: 'first-leg-customs-documents', title: '报关资料', icon: FileText },
},
{
path: 'first-leg/sailing-prices',
name: 'first-leg-sailing-prices',
component: () => import('./views/SailingPricesView.vue'),
release: 'active',
meta: { title: '船期价格', description: '查询渠道、参考船期与预估运费', permission: 'order:first-leg:list' },
navigation: { key: 'first-leg-sailing-prices', title: '船期价格', icon: Calculator },
},
],
} satisfies ClientModuleManifest
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { FileText, RefreshCcw } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useFirstLegOrdersStore } from '../public'
import { getMerchantCustomsForOrder, type MerchantCustomsContainer } from '../api/customs'
import CustomsDocumentsDialog from '../components/CustomsDocumentsDialog.vue'
import type { FirstLegOrder } from '../types/firstLegOrder'
const store = useFirstLegOrdersStore()
const loading = ref(false)
const errorMessage = ref('')
const keyword = ref('')
const records = ref<Array<{ order: FirstLegOrder; detail: MerchantCustomsContainer }>>([])
const dialogOpen = ref(false)
const selectedOrderId = ref('')
const selectedDetail = ref<MerchantCustomsContainer | null>(null)
const filteredRecords = computed(() => {
const query = keyword.value.trim().toLowerCase()
if (!query) return records.value
return records.value.filter(({ order, detail }) => [order.orderNo, order.businessSummary.inboundCode, detail.containerNo, detail.cabinetNo].some((value) => String(value ?? '').toLowerCase().includes(query)))
})
function statusLabel(detail: MerchantCustomsContainer) {
return ({ WAITING_CUSTOMER_DOCUMENTS: '待上传资料', READY_TO_START: '待报关', DECLARING: '报关中', COMPLETED: '报关完成' } as Record<string, string>)[detail.status] ?? detail.status
}
function statusType(detail: MerchantCustomsContainer) {
return detail.status === 'COMPLETED' ? 'success' : detail.status === 'DECLARING' ? 'primary' : 'warning'
}
async function load() {
loading.value = true; errorMessage.value = ''
try {
await store.load()
const candidates = store.orders.filter((order) => order.status === 'LOADED' && order.taxMode === 'TAX_REFUND')
const results = await Promise.allSettled(candidates.map(async (order) => ({ order, detail: await getMerchantCustomsForOrder(order.id) })))
records.value = results.flatMap((result) => result.status === 'fulfilled' && result.value.detail.inbounds.some((item) => item.orderId === result.value.order.id && item.taxMode === 'TAX_REFUND') ? [result.value] : [])
} catch (error) {
records.value = []
errorMessage.value = error instanceof Error ? error.message : '报关资料加载失败,请稍后重试'
} finally { loading.value = false }
}
function openDetail(item: unknown) {
const value = item as { order: FirstLegOrder; detail: MerchantCustomsContainer }
selectedOrderId.value = value.order.id; selectedDetail.value = value.detail; dialogOpen.value = true
}
function updateDetail(value: MerchantCustomsContainer) {
const found = records.value.find((item) => item.order.id === selectedOrderId.value)
if (found) found.detail = value
}
onMounted(() => { void load() })
</script>
<template>
<div class="customs-page">
<section class="customs-hero"><div><span class="eyebrow">CUSTOMS DOCUMENTS</span><h1>报关资料</h1><p>查看装柜后退税订单的资料清单、文件版本与报关行回执。</p></div><el-button :loading="loading" @click="load"><RefreshCcw :size="15" />刷新</el-button></section>
<section class="customs-toolbar"><el-input v-model="keyword" clearable placeholder="搜索订单号、入仓码或柜号" /><span>共 {{ filteredRecords.length }} 个资料包</span></section>
<section class="customs-card">
<el-skeleton v-if="loading" :rows="5" animated />
<div v-else-if="errorMessage" class="state-error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load">重试</el-button></div>
<el-empty v-else-if="!filteredRecords.length" description="暂无待处理的报关资料" />
<el-table v-else :data="filteredRecords" row-key="order.id" stripe>
<el-table-column label="操作" width="120" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)"><FileText :size="15" />查看资料</el-button></template></el-table-column>
<el-table-column label="订单号" min-width="160"><template #default="{ row }">{{ row.order.orderNo || '—' }}</template></el-table-column>
<el-table-column label="入仓码" min-width="150"><template #default="{ row }">{{ row.order.businessSummary.inboundCode || '—' }}</template></el-table-column>
<el-table-column label="柜号" min-width="140"><template #default="{ row }">{{ row.detail.containerNo || row.detail.cabinetNo || '—' }}</template></el-table-column>
<el-table-column label="实际箱数" width="100" prop="detail.actualBoxCount" />
<el-table-column label="资料覆盖" width="110"><template #default="{ row }">{{ row.detail.coveredTaxRefundCount }} / {{ row.detail.taxRefundCount }}</template></el-table-column>
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="statusType(row.detail)">{{ statusLabel(row.detail) }}</el-tag></template></el-table-column>
</el-table>
</section>
<CustomsDocumentsDialog v-model="dialogOpen" :order-id="selectedOrderId" :can-edit="selectedDetail?.status !== 'COMPLETED'" :initial-detail="selectedDetail" @updated="updateDetail" />
</div>
</template>
<style scoped>
.customs-page { display:flex; flex-direction:column; gap:14px; }.customs-hero { display:flex; justify-content:space-between; align-items:flex-start; padding:22px 24px; border:1px solid var(--client-border); border-radius:var(--client-radius); background:linear-gradient(135deg,#f1f8f6,#fff); }.eyebrow { color:var(--primary); font-size:11px; letter-spacing:.12em; }.customs-hero h1 { margin:7px 0 5px; color:var(--navy); font-size:24px; }.customs-hero p { margin:0; color:var(--muted); font-size:13px; }.customs-toolbar { display:flex; align-items:center; gap:16px; }.customs-toolbar .el-input { max-width:360px; }.customs-toolbar span { color:var(--muted); font-size:12px; }.customs-card { min-height:280px; padding:4px; overflow:hidden; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; }.state-error { display:flex; min-height:220px; flex-direction:column; align-items:center; justify-content:center; gap:10px; color:#b42318; }.state-error p { margin:0; }
</style>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Calculator, RefreshCcw, Search } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { estimateMerchantFirstLegPrice, getMerchantPricingChannels, getMerchantWarehouses, type MerchantPriceEstimate, type MerchantPricingChannel, type MerchantWarehouse } from '../api/firstLeg'
const loading = ref(false); const querying = ref(false); const errorMessage = ref('')
const warehouses = ref<MerchantWarehouse[]>([]); const channels = ref<MerchantPricingChannel[]>([]); const quotes = ref<MerchantPriceEstimate[]>([])
const originWarehouseId = ref<string | number>(''); const destinationCode = ref(''); const weight = ref<number | undefined>(); const volume = ref<number | undefined>(); const taxMode = ref<'TAX_INCLUDED' | 'TAX_EXCLUDED'>('TAX_INCLUDED')
const hasInput = computed(() => Boolean(originWarehouseId.value && destinationCode.value.trim() && weight.value && volume.value))
function formatMoney(item: MerchantPriceEstimate) { return `${item.currency} ${item.estimatedAmount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` }
async function loadBase() { loading.value = true; errorMessage.value = ''; try { const result = await getMerchantWarehouses({ warehouseType: 'DOMESTIC' }); warehouses.value = result.records; if (result.records[0]) { originWarehouseId.value = result.records[0].id; await loadChannels() } } catch (error) { errorMessage.value = error instanceof Error ? error.message : '船期价格加载失败' } finally { loading.value = false } }
async function loadChannels() { if (!originWarehouseId.value) return; channels.value = await getMerchantPricingChannels(originWarehouseId.value) }
async function query() { if (!hasInput.value) { ElMessage.warning('请填写起运仓、目的仓、重量和体积'); return }; querying.value = true; quotes.value = []; try { const results = await Promise.allSettled(channels.value.map((channel) => estimateMerchantFirstLegPrice({ originWarehouseId: originWarehouseId.value, channelCode: channel.channelCode, destinationCode: destinationCode.value.trim(), destinationType: 'FBA', pricingTaxMode: taxMode.value, actualWeightKg: weight.value!, actualVolumeCbm: volume.value! }))); quotes.value = results.flatMap((item) => item.status === 'fulfilled' ? [item.value] : []) } finally { querying.value = false } }
watch(originWarehouseId, () => { void loadChannels() }); onMounted(() => { void loadBase() })
</script>
<template>
<div class="pricing-page"><section class="pricing-hero"><div><span class="eyebrow">SAILING PRICE INQUIRY</span><h1>船期价格查询</h1><p>输入货物重量和目的仓,获取匹配渠道、参考船期与预估运费。</p></div><el-button :loading="loading" @click="loadBase"><RefreshCcw :size="15" />刷新</el-button></section>
<section class="pricing-form"><el-form label-position="top"><el-form-item label="起运仓"><el-select v-model="originWarehouseId" filterable :loading="loading" placeholder="选择起运仓"><el-option v-for="item in warehouses" :key="String(item.id)" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="item.id" /></el-select></el-form-item><el-form-item label="货物重量"><el-input v-model.number="weight" type="number" placeholder="请输入重量"><template #append>KG</template></el-input></el-form-item><el-form-item label="货物立方数"><el-input v-model.number="volume" type="number" placeholder="请输入体积"><template #append>CBM</template></el-input></el-form-item><el-form-item label="目的仓"><el-input v-model="destinationCode" placeholder="如 ABE4" /></el-form-item><el-form-item label="报价方式"><el-radio-group v-model="taxMode"><el-radio-button label="TAX_INCLUDED">包税</el-radio-button><el-radio-button label="TAX_EXCLUDED">不包税</el-radio-button></el-radio-group></el-form-item><el-form-item><el-button type="primary" :loading="querying" @click="query"><Search :size="16" />立即查询</el-button></el-form-item></el-form></section>
<section class="pricing-results"><div class="section-title"><div><h2>匹配渠道</h2><span>{{ channels.length }} 个渠道 · 参考价格以服务端估价为准</span></div><Calculator :size="20" /></div><el-skeleton v-if="loading || querying" :rows="4" animated /><el-alert v-else-if="errorMessage" :title="errorMessage" type="error" show-icon /><el-empty v-else-if="!channels.length" description="暂无可用船期价格" /><el-table v-else :data="channels" stripe><el-table-column label="渠道" min-width="160"><template #default="{ row }"><strong>{{ row.channelName }}</strong><small>{{ row.channelCode }}</small></template></el-table-column><el-table-column label="参考船期" min-width="150"><template #default="{ row }">{{ row.estimatedDepartureText || row.estimatedDepartureDate || '待更新' }}</template></el-table-column><el-table-column label="最晚进仓" min-width="130"><template #default="{ row }">{{ row.latestWarehouseEntry || '—' }}</template></el-table-column><el-table-column label="参考时效" width="120"><template #default="{ row }">{{ row.referenceTransit || '—' }}</template></el-table-column><el-table-column label="预估运费" width="150"><template #default="{ row }"><span v-if="quotes.find((quote) => quote.channelCode === row.channelCode)">{{ formatMoney(quotes.find((quote) => quote.channelCode === row.channelCode)!) }}</span><span v-else class="muted">查询后显示</span></template></el-table-column><el-table-column label="附加费说明" min-width="180" prop="surchargeDescription" /></el-table></section>
</div>
</template>
<style scoped>
.pricing-page { display:flex; flex-direction:column; gap:14px; }.pricing-hero { display:flex; justify-content:space-between; align-items:flex-start; padding:22px 24px; border:1px solid var(--client-border); border-radius:var(--client-radius); background:linear-gradient(135deg,#eff7fb,#fff); }.eyebrow { color:var(--primary); font-size:11px; letter-spacing:.12em; }.pricing-hero h1 { margin:7px 0 5px; color:var(--navy); font-size:24px; }.pricing-hero p { margin:0; color:var(--muted); font-size:13px; }.pricing-form { padding:18px 20px 4px; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; }.pricing-form :deep(form) { display:grid; grid-template-columns:1.3fr 1fr 1fr 1.2fr 1.1fr auto; align-items:end; gap:14px; }.pricing-form :deep(.el-form-item) { margin-bottom:14px; }.pricing-results { padding:4px; overflow:hidden; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; }.section-title { display:flex; align-items:center; justify-content:space-between; padding:16px 18px; }.section-title h2 { margin:0 0 4px; color:var(--navy); font-size:16px; }.section-title span { color:var(--muted); font-size:12px; }.section-title svg { color:var(--primary); }.pricing-results small { display:block; margin-top:3px; color:var(--muted); font-size:11px; }.muted { color:var(--muted); }
</style>
+4 -2
View File
@@ -52,7 +52,7 @@ test('organises customer-relevant business pages under five task-oriented sideba
{ title: '首页', children: undefined },
{
title: '订单中心',
children: ['头程订单', '海外采购', '美国本土卡', '美本土散板'],
children: ['头程订单', '报关资料', '船期价格', '海外采购', '美国本土卡', '美本土散板'],
},
{
title: '海外仓',
@@ -86,6 +86,8 @@ test('flattens visible leaves with unique paths', () => {
assert.deepEqual(paths, [
'/',
'/orders/first-leg',
'/first-leg/customs-documents',
'/first-leg/sailing-prices',
'/cross-border/procurement',
'/cross-border/domestic-trucking',
'/cross-border/pallet-shipping',
@@ -109,7 +111,7 @@ test('flattens visible leaves with unique paths', () => {
test('filters inaccessible leaves and empty navigation groups', () => {
const visible = filterNavigationByPermissions(clientNavigation, ['account:profile:view', 'order:first-leg:list'])
assert.deepEqual(getNavigationLeaves(visible).map((item) => item.path), ['/orders/first-leg'])
assert.deepEqual(getNavigationLeaves(visible).map((item) => item.path), ['/orders/first-leg', '/first-leg/customs-documents', '/first-leg/sailing-prices'])
assert.deepEqual(visible.map((item) => item.title), ['订单中心'])
assert.equal(getFirstAccessiblePath(['account:profile:view']), '/account/profile')
assert.equal(getFirstAccessiblePath([]), '/forbidden')
+2
View File
@@ -52,6 +52,8 @@ export const clientNavigation: ClientNavigationNode[] = [
icon: ClipboardList,
children: [
requireLeaf('first-leg-orders', '头程订单'),
requireLeaf('first-leg-customs-documents', '报关资料'),
requireLeaf('first-leg-sailing-prices', '船期价格'),
requireLeaf('cross-border-procurement'),
requireLeaf('cross-border-domestic-trucking'),
requireLeaf('cross-border-pallet-shipping'),