Files
admin-web/src/modules/overseas-master-data/components/FleetQuoteEditor.vue
T
2026-10-20 15:48:27 +08:00

93 lines
9.0 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 InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, reactive, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import FleetQuotePartySelect from './FleetQuotePartySelect.vue'
import { createFleetQuote, updateFleetQuote, isFleetCommandRejected, type FleetQuote, type FleetQuoteDraft, type FleetQuoteKind } from '../api/fleetQuotes'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ quote: FleetQuote | null; readonly?: boolean; kind: FleetQuoteKind }>()
const emit = defineEmits<{ saved: [] }>()
type QuoteForm = Omit<FleetQuoteDraft, 'distanceMiles'> & { distanceMiles: number | null }
const empty = (): QuoteForm => ({ kind: props.kind, partyId: '', originCity: '', destinationCode: '', distanceMiles: null, currency: 'USD', selfPickupPrice: null, fullDeliveryPrice: null, looseDeliveryPrice: null, validFrom: new Date().toISOString().slice(0, 10), validTo: '', remark: null })
const form = reactive<QuoteForm>(empty()), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
let pending: { idempotencyKey: string; details: FleetQuoteDraft } | null = null
const uncertain = ref(false)
const title = computed(() => {
const action = props.readonly ? '报价详情' : props.quote ? '编辑报价' : form.kind === 'PURCHASE' ? '新增车队采购报价' : '新增外接销售报价'
return props.quote?.quoteNo?.trim() ? `${action} · ${props.quote.quoteNo.trim()}` : action
})
const facts = computed(() => [
['用途', form.kind === 'PURCHASE' ? '车队采购报价' : '外接销售报价'],
[form.kind === 'PURCHASE' ? '海外车队供应商' : '委托客户', props.quote?.partyName || '—'],
['发货城市', form.originCity || '—'], ['目的仓码', form.destinationCode || '—'],
['预估距离(mile)', form.distanceMiles ?? '—'], ['币种', form.currency || '—'],
['整车自提 / 每单', form.selfPickupPrice ?? '未报价'], ['整车派送 / 每单', form.fullDeliveryPrice ?? '未报价'],
['散板派送 / 每托', form.looseDeliveryPrice ?? '未报价'], ['生效日', form.validFrom || '—'],
['失效日(含当天)', form.validTo || '—'], ['备注', form.remark || '—'],
])
watch([open, () => props.quote?.quoteNo], () => {
if (!open.value) return
uncertain.value = !props.quote && pending !== null
Object.assign(form, props.quote?.details || pending?.details || empty()); error.value = null
})
async function save() {
if (props.readonly || saving.value) return
const editing = props.quote
saving.value = true; error.value = null
try {
if (!form.partyId || !form.originCity.trim() || !form.destinationCode.trim() || !form.validFrom || !form.validTo) throw new Error('请选择往来单位并填写路线和有效期')
if (form.distanceMiles == null || !Number.isFinite(form.distanceMiles)) throw new Error('请填写有效的预估距离')
if (form.selfPickupPrice == null && form.fullDeliveryPrice == null && form.looseDeliveryPrice == null) throw new Error('至少填写一种价格,零表示明确零价')
const details: FleetQuoteDraft = { ...form, distanceMiles: form.distanceMiles }
if (editing) await updateFleetQuote(editing, details)
else { pending ??= { idempotencyKey: crypto.randomUUID(), details }; await createFleetQuote(pending.idempotencyKey, pending.details) }
if (!editing) pending = null
uncertain.value = false; open.value = false; emit('saved')
} catch (e) { error.value = getApiErrorDetails(e, '报价保存失败'); if (!editing && isFleetCommandRejected(e)) pending = null; uncertain.value = !editing && pending !== null }
finally { saving.value = false }
}
</script>
<template><AppDialog v-model="open" :title="title" width="780px" class="admin-density-dialog" fullscreenable :confirm-loading="saving" :show-confirm="false" :close-on-click-modal="false">
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
<p v-if="uncertain" role="status" class="density-dialog-note">保存结果尚未确认,重试将发送原请求和原内容。</p>
<section v-if="readonly" class="density-dialog-section" data-tone="source" aria-label="车队报价资料">
<h3 class="density-dialog-section__title">报价资料</h3>
<dl class="density-fields fleet-quote-facts"><div v-for="(fact,index) in facts" :key="index"><dt>{{fact[0]}}</dt><dd>{{fact[1]}}</dd></div></dl>
</section>
<el-form v-else label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="saving || uncertain">
<section class="density-dialog-section" data-tone="work" aria-label="报价路线与归属">
<h3 class="density-dialog-section__title">基础信息</h3>
<table class="density-table density-table--editable fleet-quote-form"><tbody>
<tr><td><el-form-item label="用途"><span>{{form.kind === 'PURCHASE' ? '车队采购报价' : '外接销售报价'}}</span></el-form-item></td><td><el-form-item :label="form.kind === 'PURCHASE' ? '海外车队供应商' : '委托客户'" required><FleetQuotePartySelect v-model="form.partyId" :kind="form.kind" :party-name="quote?.partyName" :disabled="saving || uncertain" /></el-form-item></td></tr>
<tr><td><el-form-item label="发货城市" required><el-input v-model="form.originCity" maxlength="128" placeholder="请输入发货城市" /></el-form-item></td><td><el-form-item label="目的仓码" required><el-input v-model="form.destinationCode" maxlength="64" placeholder="请输入目的仓码,如 ONT8" /></el-form-item></td></tr>
<tr><td><el-form-item label="预估距离(mile)" required><el-input-number :model-value="form.distanceMiles" @update:model-value="form.distanceMiles = $event ?? null" :min="0" :precision="1" placeholder="请输入预估英里数" /></el-form-item></td><td><el-form-item label="币种"><el-select v-model="form.currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="报价金额">
<h3 class="density-dialog-section__title">报价金额<InlineHelp title="报价金额说明"><p>留空表示未报价;报价变更不自动覆盖已确认承运价格,也不生成应收或应付。</p></InlineHelp></h3>
<table class="density-table density-table--editable fleet-quote-form"><tbody>
<tr><td><el-form-item label="整车自提 / 每单"><el-input-number :model-value="form.selfPickupPrice" @update:model-value="form.selfPickupPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item></td><td><el-form-item label="整车派送 / 每单"><el-input-number :model-value="form.fullDeliveryPrice" @update:model-value="form.fullDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="散板派送 / 每托"><el-input-number :model-value="form.looseDeliveryPrice" @update:model-value="form.looseDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="报价有效期与备注">
<h3 class="density-dialog-section__title">有效期与备注</h3>
<table class="density-table density-table--editable fleet-quote-form"><tbody>
<tr><td><el-form-item label="生效日" required><el-date-picker v-model="form.validFrom" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="失效日(含当天)" required><el-date-picker v-model="form.validTo" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input :model-value="form.remark || ''" @update:model-value="form.remark = $event || null" type="textarea" maxlength="1000" placeholder="选填,补充报价说明" /></el-form-item></td></tr>
</tbody></table>
</section>
</el-form>
<template #footer="{close}"><div class="density-actions density-actions--footer"><el-button :disabled="saving" @click="close">关闭</el-button><el-button v-if="!readonly" type="primary" :loading="saving" @click="save">{{ uncertain ? '按原请求重试' : '保存报价' }}</el-button></div></template>
</AppDialog></template>
<style scoped>
.fleet-quote-form { table-layout: fixed; }
.fleet-quote-form td:not([colspan]) { width: 50%; }
.fleet-quote-facts.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.fleet-quote-facts > div:last-child { grid-column: 1 / -1; }
@media (max-width: 600px) { .fleet-quote-form tr { display: grid; } .fleet-quote-form td:not([colspan]) { width: auto; } .fleet-quote-facts.density-fields { grid-template-columns: minmax(0, 1fr); } }
</style>