Files
client-web/src/modules/first-leg/views/SailingPricesView.vue
T

69 lines
19 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, watch } from 'vue'
import { Calculator, Search } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
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 router = useRouter()
const warehouses = ref<MerchantWarehouse[]>([]); const destinationWarehouses = ref<MerchantWarehouse[]>([]); const channels = ref<MerchantPricingChannel[]>([]); const quotes = ref<MerchantPriceEstimate[]>([])
const quoteDialogOpen = ref(false); const originWarehouseId = ref<string | number>(''); const destinationWarehouseId = ref<string | number>(''); const weight = ref<number | undefined>(); const volume = ref<number | undefined>(); const taxMode = ref<'TAX_INCLUDED' | 'TAX_EXCLUDED'>('TAX_INCLUDED')
const selectedDestinationWarehouse = computed(() => destinationWarehouses.value.find((item) => String(item.id) === String(destinationWarehouseId.value)))
const destinationCode = computed(() => selectedDestinationWarehouse.value?.warehouseCode || '')
const hasInput = computed(() => Boolean(originWarehouseId.value && destinationWarehouseId.value && weight.value && volume.value))
const quoteCards = computed(() => quotes.value.map((quote) => ({ quote, channel: channels.value.find((item) => item.channelCode === quote.channelCode) })).filter((item): item is { quote: MerchantPriceEstimate; channel: MerchantPricingChannel } => Boolean(item.channel)).sort((a, b) => a.quote.estimatedAmount - b.quote.estimatedAmount).slice(0, 4))
const priceWinner = computed(() => quoteCards.value.reduce((winner, item) => !winner || item.quote.estimatedAmount < winner.quote.estimatedAmount ? item : winner, undefined as (typeof quoteCards.value)[number] | undefined))
const timeWinner = computed(() => quoteCards.value.reduce((winner, item) => { const days = transitDays(item.channel.referenceTransit); if (days === null) return winner; return !winner || days < transitDays(winner.channel.referenceTransit)! ? item : winner }, undefined as (typeof quoteCards.value)[number] | undefined))
const resultSummary = computed(() => `${volume.value || 0} CBM · ${destinationCode.value || '—'} · 匹配 ${quoteCards.value.length} 个渠道`)
function transitDays(value: string | null) { const match = value?.match(/\d+/); return match ? Number(match[0]) : null }
function formatMoney(item: MerchantPriceEstimate) { const currency = item.currency === 'CNY' ? '¥' : `${item.currency} `; return `${currency}${item.estimatedAmount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` }
function formatUnitPrice(item: MerchantPriceEstimate) { const currency = item.currency === 'CNY' ? '¥' : `${item.currency} `; return `${currency}${item.unitPrice.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}/${item.chargeUnit || 'CBM'}` }
function recommendation(item: { quote: MerchantPriceEstimate; channel: MerchantPricingChannel }) { if (priceWinner.value?.quote.channelCode === item.quote.channelCode) return { label: '价格优选', className: 'price' }; if (timeWinner.value?.quote.channelCode === item.quote.channelCode) return { label: '时间优选', className: 'time' }; return undefined }
async function loadBase() { loading.value = true; errorMessage.value = ''; try { const [originResult, destinationResult] = await Promise.all([getMerchantWarehouses({ warehouseType: 'DOMESTIC' }), getMerchantWarehouses({ warehouseType: 'FBA' })]); warehouses.value = originResult.records; destinationWarehouses.value = destinationResult.records; if (originResult.records[0]) originWarehouseId.value = originResult.records[0].id; if (destinationResult.records[0]) destinationWarehouseId.value = destinationResult.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; try { channels.value = await getMerchantPricingChannels(originWarehouseId.value) } catch (error) { channels.value = []; errorMessage.value = error instanceof Error ? error.message : '船期渠道加载失败' } }
async function query() { const destination = selectedDestinationWarehouse.value; if (!hasInput.value || !destination) { ElMessage.warning('请选择目的 FBA 仓,并填写重量和体积'); return }; if (!channels.value.length) { 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: destination.warehouseCode, destinationType: 'FBA', destinationWarehouseId: destination.id, destinationName: destination.warehouseName, pricingTaxMode: taxMode.value, actualWeightKg: weight.value!, actualVolumeCbm: volume.value! }))); quotes.value = results.flatMap((item) => item.status === 'fulfilled' ? [item.value] : []); if (!quotes.value.length) { ElMessage.warning('当前条件暂无匹配报价'); return }; quoteDialogOpen.value = true } finally { querying.value = false } }
function placeOrder(channelCode: string, channelName: string) {
if (!quotes.value.some((quote) => quote.channelCode === channelCode)) {
ElMessage.warning('请先查询该渠道价格')
return
}
router.push({
name: 'first-leg-order-create',
query: {
quick: '1',
receivingWarehouseId: String(originWarehouseId.value),
channelCode,
channelName,
destinationType: 'FBA',
destinationWarehouseId: String(selectedDestinationWarehouse.value!.id),
destinationWarehouseName: selectedDestinationWarehouse.value!.warehouseName,
amazonWarehouseCode: selectedDestinationWarehouse.value!.warehouseCode,
destinationCountryCode: selectedDestinationWarehouse.value!.countryCode || 'US',
pricingTaxMode: taxMode.value,
grossWeightKg: String(weight.value),
volumeCbm: String(volume.value),
cargo: JSON.stringify([{ sku: '', packageCount: 1 }]),
},
})
}
watch(originWarehouseId, () => { void loadChannels() }); onMounted(() => { void loadBase() })
</script>
<template>
<div class="pricing-page"><section class="pricing-inquiry"><div class="inquiry-copy"><span>SAILING PRICE INQUIRY</span><h2>船期价格查询</h2><p>输入货物重量和目的仓,快速获取匹配渠道、船期与预估运费</p></div><el-form class="pricing-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="货物重量"><div class="unit-input"><el-input v-model.number="weight" type="number" min="0" placeholder="请输入重量" /><span>KG</span></div></el-form-item><el-form-item label="货物立方数"><div class="unit-input"><el-input v-model.number="volume" type="number" min="0" placeholder="请输入体积" /><span>CBM</span></div></el-form-item><el-form-item label="目的 FBA 仓"><el-select v-model="destinationWarehouseId" filterable :loading="loading" placeholder="输入或选择仓库代码"><el-option v-for="item in destinationWarehouses" :key="String(item.id)" :label="`${item.warehouseCode} · ${item.warehouseName}`" :value="item.id" /></el-select></el-form-item><el-form-item label="报价方式"><el-radio-group v-model="taxMode"><el-radio-button value="TAX_INCLUDED" label="包税" /><el-radio-button value="TAX_EXCLUDED" label="不包税" /></el-radio-group></el-form-item><el-form-item class="query-item"><el-button type="primary" :loading="querying" @click="query"><Search :size="16" />立即查询</el-button></el-form-item></el-form>
<el-dialog v-model="quoteDialogOpen" class="quote-dialog" :show-close="false" width="min(1180px, calc(100vw - 48px))" top="6vh"><template #header><div class="quote-dialog-head"><div><h3>查询结果</h3><span>{{ resultSummary }}</span></div><button type="button" class="quote-close" aria-label="关闭" @click="quoteDialogOpen = false">×</button></div></template><div class="quote-grid"><article v-for="item in quoteCards" :key="item.quote.channelCode" class="quote-card" :class="recommendation(item)?.className"><div v-if="recommendation(item)" class="quote-ribbon">{{ recommendation(item)?.label }}</div><div class="quote-card-title"><strong>{{ item.channel.channelName }}</strong><span>{{ item.quote.pricingTaxMode === 'TAX_INCLUDED' ? '包税' : '不包税' }} / {{ item.quote.chargeUnit || 'CBM' }}</span></div><div class="quote-price"><b>{{ formatMoney(item.quote) }}</b><small>预估运费</small></div><div class="quote-meta"><div><span>单价</span><strong>{{ formatUnitPrice(item.quote) }}</strong></div><div><span>参考时效</span><strong>{{ item.channel.referenceTransit || '待更新' }}</strong></div><div><span>最晚进仓</span><strong>{{ item.channel.latestWarehouseEntry || '—' }}</strong></div><div><span>船期</span><strong>{{ item.channel.estimatedDepartureText || item.channel.estimatedDepartureDate || '待更新' }}</strong></div></div><el-button type="primary" plain class="quote-order" @click="placeOrder(item.quote.channelCode, item.channel.channelName)">下单</el-button></article></div></el-dialog></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-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button link type="primary" :disabled="!quotes.some((quote) => quote.channelCode === row.channelCode)" @click="placeOrder(row.channelCode, row.channelName)">下单</el-button></template></el-table-column></el-table></section>
</div>
</template>
<style scoped>
.pricing-page { display:flex; flex-direction:column; gap:16px; }.pricing-heading { display:flex; justify-content:space-between; align-items:flex-start; padding:0 2px; }.eyebrow { color:#5b82c9; font-size:11px; font-weight:750; letter-spacing:.14em; }.pricing-heading h1 { margin:6px 0 5px; color:#1f2f46; font-size:26px; line-height:1.25; letter-spacing:-.025em; }.pricing-heading p { margin:0; color:#7d8da2; font-size:13px; }.refresh-button { margin-top:3px; border-color:#d9e3ef; border-radius:9px; background:#fff; color:#61738b; box-shadow:0 3px 8px rgba(26,55,91,.04); }.pricing-inquiry { position:relative; overflow:hidden; padding:24px; border:1px solid rgba(99,135,255,.36); border-radius:18px; background:linear-gradient(112deg,#152754 0%,#203d8f 42%,#315bd9 75%,#4c70f5 100%); box-shadow:0 14px 36px rgba(31,61,140,.2); }.pricing-inquiry::before { content:''; position:absolute; right:-75px; top:-165px; width:410px; height:410px; border:48px solid rgba(255,255,255,.07); border-radius:50%; background:radial-gradient(circle,rgba(255,255,255,.07),transparent 64%); pointer-events:none; }.inquiry-copy { position:relative; z-index:1; margin-bottom:19px; }.inquiry-copy > span { color:rgba(255,255,255,.7); font-size:10px; font-weight:850; letter-spacing:.18em; }.inquiry-copy h2 { margin:5px 0 4px; color:#fff; font-size:22px; font-weight:850; }.inquiry-copy p { margin:0; color:rgba(255,255,255,.7); font-size:13px; }.pricing-form { position:relative; z-index:1; display:grid; grid-template-columns:minmax(130px,.9fr) minmax(120px,.75fr) minmax(120px,.75fr) minmax(170px,1.1fr) minmax(125px,.8fr) 128px; align-items:end; gap:10px; }.pricing-form :deep(.el-form-item) { margin-bottom:0; }.pricing-form :deep(.el-form-item__label) { height:auto; margin-bottom:8px; color:rgba(255,255,255,.82); font-size:12px; font-weight:750; line-height:1.2; }.pricing-form :deep(.el-input__wrapper), .pricing-form :deep(.el-select__wrapper) { min-height:48px; border:1px solid #d4e0ed; border-radius:11px; background:#fff; box-shadow:0 4px 13px rgba(45,72,105,.04); }.pricing-form :deep(.el-input__inner), .pricing-form :deep(.el-select__selected-item) { color:#24354d; font-size:14px; font-weight:600; }.unit-input { display:flex; min-height:48px; overflow:hidden; border:1px solid #d4e0ed; border-radius:11px; background:#fff; }.unit-input :deep(.el-input__wrapper) { min-height:46px; border:0; box-shadow:none; }.unit-input span { display:flex; align-items:center; padding:0 13px 0 4px; color:#7690ad; font-size:12px; font-weight:750; }.pricing-form :deep(.el-radio-group) { display:flex; min-height:48px; align-items:center; padding:0 4px; border-radius:11px; background:#fff; }.pricing-form :deep(.el-radio-button__inner) { height:40px; padding:12px 12px; border:0; border-radius:8px; color:#61738b; box-shadow:none; }.pricing-form :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) { border-color:#3d70e8; background:#3d70e8; color:#fff; box-shadow:0 5px 12px rgba(37,99,235,.2); }.query-item :deep(.el-form-item__content) { display:block; }.query-item :deep(.el-button) { width:100%; min-height:48px; border:0; border-radius:11px; background:linear-gradient(135deg,#ffca4b,#ffb82e); color:#48350b; box-shadow:0 9px 20px rgba(255,185,46,.25); font-size:14px; font-weight:850; }.query-item :deep(.el-button:hover) { background:linear-gradient(135deg,#ffd363,#ffc13e); color:#48350b; transform:translateY(-1px); }.pricing-results { padding:4px; overflow:hidden; border:1px solid #dce4ef; border-radius:16px; background:#fff; box-shadow:0 5px 16px rgba(31,56,88,.045); }.section-title { display:flex; align-items:center; justify-content:space-between; padding:17px 18px 13px; }.section-title h2 { margin:0 0 4px; color:#20324a; font-size:16px; }.section-title span { color:#7d8da2; font-size:12px; }.section-title svg { color:#3d70e8; }.pricing-results small { display:block; margin-top:3px; color:#7d8da2; font-size:11px; }.pricing-results :deep(.el-table) { --el-table-header-bg-color:#f4f7fb; --el-table-row-hover-bg-color:#eaf2ff; --el-table-border-color:#edf1f6; color:#53647a; }.pricing-results :deep(.el-table th.el-table__cell) { padding:13px 16px; color:#607089; font-size:12px; font-weight:750; }.pricing-results :deep(.el-table td.el-table__cell) { height:58px; padding:13px 16px; font-size:13px; }.pricing-results :deep(.el-table__row:nth-child(even)) { background:#f8fbff; }.pricing-results :deep(.el-button--link) { font-weight:750; }.muted { color:#94a0af; }
.quote-dialog { --el-dialog-bg-color:transparent; }.quote-dialog :deep(.el-dialog) { overflow:hidden; border-radius:22px; background:linear-gradient(125deg,#182e69 0%,#274cbd 55%,#416bf1 100%); box-shadow:0 24px 70px rgba(11,31,89,.34); }.quote-dialog :deep(.el-dialog__header) { margin:0; padding:0; }.quote-dialog :deep(.el-dialog__body) { padding:0 20px 22px; }.quote-dialog-head { display:flex; align-items:flex-start; justify-content:space-between; padding:22px 22px 16px; color:#fff; }.quote-dialog-head h3 { margin:0 0 3px; font-size:22px; }.quote-dialog-head span { color:rgba(255,255,255,.72); font-size:14px; }.quote-close { width:42px; height:42px; border:1px solid #cfddf2; border-radius:13px; background:#fff; color:#8392a8; font-size:31px; line-height:34px; cursor:pointer; }.quote-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:20px; }.quote-card { position:relative; min-height:300px; overflow:hidden; padding:28px 25px 21px; border:1px solid #d5deee; border-radius:20px; background:#f8f9fd; color:#263a55; box-shadow:0 8px 22px rgba(22,48,117,.12); }.quote-card.price { border:2px solid #ffae3c; }.quote-card.time { border:2px solid #27c28c; }.quote-ribbon { position:absolute; right:-42px; top:17px; width:145px; padding:7px 0; transform:rotate(43deg); background:#ff8d14; color:#fff; text-align:center; font-size:12px; font-weight:850; }.quote-card.time .quote-ribbon { background:#0db886; }.quote-card-title { display:flex; align-items:flex-start; justify-content:space-between; gap:8px; }.quote-card-title strong { font-size:19px; }.quote-card-title span { flex:none; padding:7px 10px; border-radius:18px; background:#edf5ff; color:#367ae0; font-size:11px; font-weight:750; }.quote-price { display:flex; align-items:baseline; gap:8px; margin:25px 0 22px; }.quote-price b { color:#ed5a2a; font-size:33px; line-height:1; }.quote-price small { color:#96a4b8; font-size:15px; }.quote-meta { display:grid; grid-template-columns:1fr 1fr; gap:17px 13px; }.quote-meta span { display:block; margin-bottom:5px; color:#93a0b2; font-size:12px; }.quote-meta strong { display:block; color:#344961; font-size:14px; line-height:1.45; }.quote-order { width:100%; margin-top:20px; border-radius:9px; }.quote-card:not(.price):not(.time) .quote-order { background:#fff; }
@media (max-width:1200px) { .pricing-form { grid-template-columns:repeat(3,minmax(0,1fr)); }.quote-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
@media (max-width:720px) { .quote-grid { grid-template-columns:1fr; }.pricing-form { grid-template-columns:1fr; } }
:global(.quote-dialog.el-dialog) { overflow:hidden; border-radius:22px; background:linear-gradient(125deg,#182e69 0%,#274cbd 55%,#416bf1 100%); box-shadow:0 24px 70px rgba(11,31,89,.34); }
:global(.quote-dialog.el-dialog .el-dialog__header) { margin:0; padding:0; }
:global(.quote-dialog.el-dialog .el-dialog__body) { padding:0 20px 22px; }
</style>