93 lines
9.0 KiB
Vue
93 lines
9.0 KiB
Vue
<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>
|