style(admin): unify dialog layouts and dense readonly presentation

This commit is contained in:
2026-10-20 10:11:31 +08:00
parent 7ff7c9914f
commit 3267679758
214 changed files with 10931 additions and 2654 deletions
@@ -0,0 +1,173 @@
import ElementPlus, { ElDatePicker, ElDialog, ElInputNumber, ElMessageBox } from 'element-plus'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
import RowActions from '@/shared/components/RowActions.vue'
import FleetQuoteEditor from './FleetQuoteEditor.vue'
import FleetQuoteImport from './FleetQuoteImport.vue'
import FleetQuotePartySelect from './FleetQuotePartySelect.vue'
import FleetQuotesView from '../pages/FleetQuotesView.vue'
import * as api from '../api/fleetQuotes'
import { fleetQuoteCsvHeader } from '../fleetQuoteCsv'
vi.mock('@/modules/downloads', () => ({ useExportTask: () => vi.fn() }))
vi.mock('../api/fleetQuotes', async original => ({ ...await original<typeof import('../api/fleetQuotes')>(), createFleetQuote: vi.fn(), updateFleetQuote: vi.fn(), importFleetQuotes: vi.fn(), fleetQuoteCustomers: vi.fn(), fleetQuoteSuppliers: vi.fn(), listFleetQuotes: vi.fn(), setFleetQuoteStatus: vi.fn(), deleteFleetQuote: vi.fn() }))
let quote: api.FleetQuote
const privateId = '9223372036854775807'
const plugins = [ElementPlus]
function button(label: string, selector = '.el-dialog button') {
const found = Array.from(document.querySelectorAll<HTMLButtonElement>(selector)).find(node => node.textContent?.trim() === label)
expect(found, label).toBeDefined(); return found!
}
async function editor(kind: api.FleetQuoteKind = 'PURCHASE', value: api.FleetQuote | null = quote, readonly = false) {
const wrapper = mount(FleetQuoteEditor, { attachTo: document.body, props: { modelValue: false, quote: value, kind, readonly }, global: { plugins } })
await wrapper.setProps({ modelValue: true }); await flushPromises(); return wrapper
}
async function importer(kind: api.FleetQuoteKind = 'SALE') {
const wrapper = mount(FleetQuoteImport, { attachTo: document.body, props: { modelValue: true, kind }, global: { plugins } })
await flushPromises()
wrapper.findComponent(FleetQuotePartySelect).vm.$emit('update:modelValue', privateId); await flushPromises(); return wrapper
}
async function pick(wrapper: Awaited<ReturnType<typeof importer>>, count = 1) {
const csv = fleetQuoteCsvHeader.join(',') + '\n' + Array.from({ length: count }, (_v,index) => `City-${index},ONT8,10.5,USD,,0,12.50,2026-01-01,2100-01-01,备注`).join('\n')
Object.defineProperty(wrapper.get('input[type="file"]').element, 'files', { configurable: true, value: [{ size: csv.length, text: async () => csv }] })
await wrapper.get('input[type="file"]').trigger('change'); await flushPromises()
}
enableAutoUnmount(afterEach)
afterEach(() => ElMessageBox.close())
beforeEach(() => {
vi.resetAllMocks(); document.body.innerHTML = ''; setActivePinia(createPinia())
const auth = useAuthStore(); auth.token = 'test-session'; auth.permissions = ['platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:supplier:list']
useAccessPreviewStore().initializeAfterLogin()
quote = { quoteNo: 'FQ-20261020-001', partyName: '测试车队', recordVersion: '9007199254740993', status: 'ACTIVE', updatedAt: '', details: { kind: 'PURCHASE', partyId: privateId, originCity: 'Los Angeles', destinationCode: 'ONT8', distanceMiles: 10.5, currency: 'USD', selfPickupPrice: null, fullDeliveryPrice: 0, looseDeliveryPrice: 12.5, validFrom: '2026-01-01', validTo: '2100-01-01', remark: '完整备注' } }
vi.mocked(api.createFleetQuote).mockImplementation(async () => quote)
vi.mocked(api.updateFleetQuote).mockImplementation(async () => quote)
vi.mocked(api.importFleetQuotes).mockResolvedValue({ importedRows: 1 })
vi.mocked(api.fleetQuoteCustomers).mockResolvedValue([]); vi.mocked(api.fleetQuoteSuppliers).mockResolvedValue([])
vi.mocked(api.listFleetQuotes).mockImplementation(async () => ({ records: [quote], total: 1, page: 1, size: 20, pages: 1 }))
vi.mocked(api.setFleetQuoteStatus).mockImplementation(async () => quote)
vi.mocked(api.deleteFleetQuote).mockImplementation(async () => quote)
})
describe('fleet quote unified dialogs', () => {
it.each(['PURCHASE','SALE'] as const)('renders %s details as full readonly facts without fetching party options', async kind => {
quote.details.kind = kind; quote.partyName = '完整往来单位名称'.repeat(20)
const wrapper = await editor(kind, quote, true)
const dialog = wrapper.findComponent(AppDialog)
expect(dialog.props()).toMatchObject({ title: '报价详情 · FQ-20261020-001', width: '780px', fullscreenable: true, showConfirm: false })
const facts = document.querySelector('[aria-label="车队报价资料"]')!
expect(facts.querySelectorAll('dt')).toHaveLength(12)
expect(facts.textContent).toContain(quote.partyName); expect(facts.textContent).toContain(kind === 'PURCHASE' ? '海外车队供应商' : '委托客户')
expect(facts.textContent).toContain('未报价'); expect(facts.querySelectorAll('dd')[7]!.textContent).toBe('0')
expect(facts.textContent).not.toContain(privateId)
expect(document.querySelectorAll('.el-dialog input, .el-dialog textarea')).toHaveLength(0)
expect(wrapper.findComponent(FleetQuotePartySelect).exists()).toBe(false)
expect(api.fleetQuoteCustomers).not.toHaveBeenCalled(); expect(api.fleetQuoteSuppliers).not.toHaveBeenCalled()
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click(); await flushPromises()
expect(dialog.findComponent(ElDialog).props('fullscreen')).toBe(true)
expect(button('关闭')).toBeDefined(); expect(document.querySelectorAll('.el-dialog__footer button')).toHaveLength(1)
})
it.each(['PURCHASE','SALE'] as const)('preserves %s draft and exact record version through fullscreen and save', async kind => {
quote.details.kind = kind
const wrapper = await editor(kind)
const party = wrapper.findComponent(FleetQuotePartySelect)
expect(party.props('kind')).toBe(kind)
const input = document.querySelector<HTMLInputElement>('[placeholder="请输入发货城市"]')!
input.value = 'Changed City'; input.dispatchEvent(new Event('input', { bubbles: true })); await flushPromises()
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click(); await flushPromises()
expect(document.querySelector<HTMLInputElement>('[placeholder="请输入发货城市"]')!.value).toBe('Changed City')
button('保存报价').click(); await flushPromises()
expect(api.updateFleetQuote).toHaveBeenCalledWith(quote, { ...quote.details, originCity: 'Changed City' })
expect(quote.details.originCity).toBe('Los Angeles')
expect(wrapper.emitted('saved')).toHaveLength(1)
})
it('retains create purpose and zero price and locks close while saving', async () => {
let release!: (result: api.FleetQuote) => void
vi.mocked(api.createFleetQuote).mockImplementation(() => new Promise(resolve => { release = resolve }))
const wrapper = await editor('SALE', null)
expect(wrapper.findComponent(AppDialog).props('title')).toBe('新增外接销售报价')
wrapper.findComponent(FleetQuotePartySelect).vm.$emit('update:modelValue', privateId)
for (const [placeholder,value] of [['请输入发货城市','City'],['请输入目的仓码,如 ONT8','ONT8']]) {
const input = document.querySelector<HTMLInputElement>(`[placeholder="${placeholder}"]`)!
input.value = value!; input.dispatchEvent(new Event('input', { bubbles: true }))
}
wrapper.findAllComponents(ElInputNumber)[0]!.vm.$emit('update:modelValue', 10.5)
wrapper.findAllComponents(ElInputNumber)[2]!.vm.$emit('update:modelValue', 0)
wrapper.findAllComponents(ElDatePicker)[1]!.vm.$emit('update:modelValue', '2100-01-01'); await flushPromises()
button('保存报价').click(); await flushPromises()
expect(api.createFleetQuote).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ kind: 'SALE', partyId: privateId, selfPickupPrice: null, fullDeliveryPrice: 0, looseDeliveryPrice: null }))
const dialog = wrapper.findComponent(ElDialog)
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
expect(button('关闭').disabled).toBe(true)
release(quote); await flushPromises(); expect(wrapper.emitted('saved')).toHaveLength(1)
})
it('keeps unknown creation locked for an exact retry after closing and changing purpose', async () => {
vi.mocked(api.createFleetQuote).mockRejectedValueOnce(new Error('network'))
const wrapper = await editor('PURCHASE', null)
wrapper.findComponent(FleetQuotePartySelect).vm.$emit('update:modelValue', privateId)
const inputs = [['请输入发货城市','Original'],['请输入目的仓码,如 ONT8','ONT8']]
for (const [placeholder,value] of inputs) { const input = document.querySelector<HTMLInputElement>(`[placeholder="${placeholder}"]`)!; input.value = value!; input.dispatchEvent(new Event('input', { bubbles: true })) }
wrapper.findAllComponents(ElInputNumber)[0]!.vm.$emit('update:modelValue', 10)
wrapper.findAllComponents(ElInputNumber)[1]!.vm.$emit('update:modelValue', 0)
wrapper.findAllComponents(ElDatePicker)[1]!.vm.$emit('update:modelValue', '2100-01-01'); await flushPromises()
button('保存报价').click(); await flushPromises()
const original = vi.mocked(api.createFleetQuote).mock.calls[0]!
expect(document.querySelector('.el-dialog [role="status"]')?.textContent).toContain('原请求和原内容')
expect(document.querySelector<HTMLInputElement>('[placeholder="请输入发货城市"]')!.disabled).toBe(true)
await wrapper.setProps({ modelValue: false, kind: 'SALE' }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.findComponent(AppDialog).props('title')).toBe('新增车队采购报价')
button('按原请求重试').click(); await flushPromises()
expect(vi.mocked(api.createFleetQuote).mock.calls[1]).toEqual(original)
})
it('previews only ten rows with actual total, zero prices and full original import payload', async () => {
const wrapper = await importer(); await pick(wrapper, 11)
expect(document.querySelector('[aria-live="polite"]')?.textContent).toContain('共 11 条')
const preview = document.querySelector('[aria-label="导入报价预览"] table')!
expect(preview.querySelectorAll('tbody tr')).toHaveLength(10)
const cells = preview.querySelectorAll('tbody tr:first-child td')
expect(cells[4]!.textContent).toBe('—'); expect(cells[5]!.textContent).toBe('0')
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click(); await flushPromises()
expect(wrapper.findComponent(ElDialog).props('fullscreen')).toBe(true)
button('确认导入').click(); await flushPromises()
expect(api.importFleetQuotes).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining([expect.objectContaining({ kind: 'SALE', partyId: privateId, fullDeliveryPrice: 0, selfPickupPrice: null })]))
expect(vi.mocked(api.importFleetQuotes).mock.calls[0]![1]).toHaveLength(11)
expect(wrapper.emitted('saved')).toHaveLength(1)
})
it('locks import file, party and all close paths during a pending request', async () => {
let rejectImport!: (error: Error) => void
vi.mocked(api.importFleetQuotes).mockImplementation(() => new Promise((_resolve,reject) => { rejectImport = reject }))
const wrapper = await importer('PURCHASE'); await pick(wrapper)
button('确认导入').click(); await flushPromises()
expect(wrapper.findComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
expect(button('关闭').disabled).toBe(true)
expect(wrapper.findComponent(FleetQuotePartySelect).props('disabled')).toBe(true)
expect(document.querySelector<HTMLInputElement>('[aria-label="选择报价CSV"]')!.disabled).toBe(true)
rejectImport(new Error('network')); await flushPromises()
expect(wrapper.findComponent(ElDialog).props('showClose')).toBe(true)
expect(button('关闭').disabled).toBe(false)
expect(button('按原请求重试')).toBeDefined()
})
it.each(['stop','enable','delete'] as const)('identifies the %s confirmation and sends the existing command only after confirmation', async action => {
if (action === 'enable') quote.status = 'INACTIVE'
const table = { props: ['data'], template: '<div><slot v-if="data?.length" name="actions" :row="data[0]"/></div>' }
const wrapper = mount(FleetQuotesView, { attachTo: document.body, global: { plugins, stubs: { DataTable: table, QueryToolbar: true } } }); await flushPromises()
wrapper.findComponent(RowActions).vm.$emit('action', action === 'delete' ? 'delete' : 'status'); await flushPromises()
const verb = action === 'delete' ? '删除' : action === 'stop' ? '停用' : '启用'
expect(document.querySelector('.el-message-box__title')?.textContent).toBe(`${verb}车队报价 · FQ-20261020-001`)
expect(document.querySelector('.el-message-box')?.classList.contains('admin-density-confirm')).toBe(true)
expect(api.setFleetQuoteStatus).not.toHaveBeenCalled(); expect(api.deleteFleetQuote).not.toHaveBeenCalled()
if (action === 'delete') expect(document.querySelector('.el-message-box')?.textContent).toContain('历史报价版本仍保留')
document.querySelector<HTMLButtonElement>('.el-message-box__btns .el-button--primary')!.click(); await flushPromises()
if (action === 'delete') expect(api.deleteFleetQuote).toHaveBeenCalledWith(quote)
else expect(api.setFleetQuoteStatus).toHaveBeenCalledWith(quote, action === 'stop' ? 'INACTIVE' : 'ACTIVE')
})
})
@@ -14,8 +14,8 @@ async function mount() {
FleetQuotePartySelect: input('FleetQuotePartySelect'), ElButton: { template: '<button><slot/></button>' }, ElAlert: { props: ['title'], template: '<p>{{ title }}<slot/></p>' }, ApiErrorMeta: true,
} } }); wrappers.push(wrapper); await wrapper.setProps({ modelValue: true }); await flushPromises()
wrapper.findComponent({ name: 'FleetQuotePartySelect' }).vm.$emit('update:modelValue', '101')
wrapper.findAllComponents({ name: 'ElInput' })[1]!.vm.$emit('update:modelValue', 'QA-Origin')
wrapper.findAllComponents({ name: 'ElInput' })[2]!.vm.$emit('update:modelValue', 'QA-DEST')
wrapper.findAllComponents({ name: 'ElInput' }).find(input => input.attributes('placeholder') === '请输入发货城市')!.vm.$emit('update:modelValue', 'QA-Origin')
wrapper.findAllComponents({ name: 'ElInput' }).find(input => input.attributes('placeholder') === '请输入目的仓码,如 ONT8')!.vm.$emit('update:modelValue', 'QA-DEST')
wrapper.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 10)
wrapper.findAllComponents({ name: 'ElInputNumber' })[2]!.vm.$emit('update:modelValue', 0)
wrapper.findAllComponents({ name: 'ElDatePicker' })[1]!.vm.$emit('update:modelValue', '2100-01-01')
@@ -27,7 +27,7 @@ it('HTTP 200/A0409 unlocks the form so corrected content receives a new key', as
const wrapper = await mount(); vi.mocked(api.createFleetQuote).mockRejectedValueOnce(new ApiResponseError('报价组合重复', 'A0409', 200))
await clickSave(wrapper); const original = vi.mocked(api.createFleetQuote).mock.calls[0]!
expect(wrapper.findComponent({ name: 'ElForm' }).props('disabled')).toBe(false); expect(wrapper.text()).not.toContain('按原请求重试')
wrapper.findAllComponents({ name: 'ElInput' })[1]!.vm.$emit('update:modelValue', 'QA-Another-Origin')
wrapper.findAllComponents({ name: 'ElInput' }).find(input => input.attributes('placeholder') === '请输入发货城市')!.vm.$emit('update:modelValue', 'QA-Another-Origin')
vi.mocked(api.createFleetQuote).mockResolvedValue({ quoteNo: 'FQ-NEW', details: original[1], recordVersion: '0', partyName: '测试客户', status: 'ACTIVE', updatedAt: '' })
await clickSave(wrapper)
const corrected = vi.mocked(api.createFleetQuote).mock.calls[1]!
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { reactive, ref, watch } from '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'
@@ -13,6 +13,19 @@ const empty = (): QuoteForm => ({ kind: props.kind, partyId: '', originCity: '',
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
@@ -35,23 +48,44 @@ async function save() {
finally { saving.value = false }
}
</script>
<template><AppDialog v-model="open" :title="readonly ? '报价详情' : quote ? '编辑报价' : '新增报价'" width="780px" class="admin-density-dialog" :close-on-click-modal="false">
<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">保存结果尚未确认,重试将发送原请求和原内容。</p>
<el-form label-position="top" class="density-form quote-form" :disabled="readonly || saving || uncertain">
<el-form-item label="用途"><el-input :model-value="form.kind === 'PURCHASE' ? '车队采购报价' : '外接销售报价'" disabled /></el-form-item>
<el-form-item :label="form.kind === 'PURCHASE' ? '海外车队供应商' : '委托客户'" required><FleetQuotePartySelect v-model="form.partyId" :kind="form.kind" :party-name="quote?.partyName" :disabled="readonly || saving || uncertain" /></el-form-item>
<el-form-item label="发货城市" required><el-input v-model="form.originCity" maxlength="128" placeholder="请输入发货城市" /></el-form-item>
<el-form-item label="目的仓码" required><el-input v-model="form.destinationCode" maxlength="64" placeholder="请输入目的仓码,如 ONT8" /></el-form-item>
<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>
<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>
<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>
<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>
<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>
<el-form-item label="生效日" required><el-date-picker v-model="form.validFrom" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="失效日(含当天)" required><el-date-picker v-model="form.validTo" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="备注" class="full"><el-input :model-value="form.remark || ''" @update:model-value="form.remark = $event || null" type="textarea" maxlength="1000" placeholder="选填,补充报价说明" /></el-form-item>
</el-form><p>留空表示未报价;报价变更不自动覆盖已确认承运价格,也不生成应收或应付。</p>
<template #footer><el-button @click="open = false">关闭</el-button><el-button v-if="!readonly" type="primary" :loading="saving" @click="save">{{ uncertain ? '按原请求重试' : '保存报价' }}</el-button></template>
<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">报价金额</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>
<p class="density-dialog-note">留空表示未报价;报价变更不自动覆盖已确认承运价格,也不生成应收或应付。</p>
<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>.quote-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0 var(--admin-space-4); }.full { grid-column:1/-1; }</style>
<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>
@@ -25,13 +25,31 @@ function template() { saveFleetCsv(new Blob(['\uFEFF' + fleetQuoteCsvHeader.join
async function save() { if (saving.value || (!pending.value && (!rows.value.length || !partyId.value))) return; fileSequence++; saving.value = true; error.value = null; try { pending.value ??= { key: crypto.randomUUID(), rows: rows.value.map(r => ({ ...r })) }; await importFleetQuotes(pending.value.key, pending.value.rows); pending.value = null; rows.value = []; source.value = ''; open.value = false; emit('saved') } catch (e) { error.value = getApiErrorDetails(e, '整批导入失败,请核对报价库与目录后重试'); if (isFleetCommandRejected(e)) pending.value = null } finally { saving.value = false } }
onBeforeUnmount(() => { fileSequence++ })
</script>
<template><AppDialog v-model="open" title="批量导入车队报价" width="1040px" class="admin-density-dialog" :close-on-click-modal="false">
<p>本批{{ activeKind === 'PURCHASE' ? '采购' : '销售' }}报价使用同一往来单位;每批最多200条,服务端整批校验,任一失败全部不写入。</p>
<FleetQuotePartySelect v-model="partyId" :kind="activeKind" :disabled="saving || !!pending" />
<el-button @click="template">下载CSV模板</el-button><input type="file" accept=".csv" aria-label="选择报价CSV" :disabled="saving || !!pending" @change="pick" />
<template><AppDialog v-model="open" title="批量导入车队报价" width="1040px" class="admin-density-dialog" fullscreenable :confirm-loading="saving" :show-confirm="false" :close-on-click-modal="false">
<p class="density-dialog-note">本批{{ activeKind === 'PURCHASE' ? '采购' : '销售' }}报价使用同一往来单位;每批最多200条,服务端整批校验,任一失败全部不写入。</p>
<el-alert v-if="error" type="error" :title="error.message" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
<p v-if="pending">已保留原批次和原内容。结果未确认时请按原请求重试,避免重复导入。</p>
<p aria-live="polite">共 {{ rows.length }} 条,预览前10条;目录有效性与重复组合由服务端核对。</p>
<table class="density-table"><thead><tr><th>发货城市</th><th>目的仓码</th><th>距离(mile)</th><th>币种</th><th>整车自提</th><th>整车派送</th><th>散板派送</th><th>有效期</th></tr></thead><tbody><tr v-for="(row, index) in rows.slice(0,10)" :key="index"><td>{{ row.originCity }}</td><td>{{ row.destinationCode }}</td><td>{{ row.distanceMiles }}</td><td>{{ row.currency }}</td><td>{{ row.selfPickupPrice ?? '—' }}</td><td>{{ row.fullDeliveryPrice ?? '—' }}</td><td>{{ row.looseDeliveryPrice ?? '—' }}</td><td>{{ row.validFrom }} — {{ row.validTo }}</td></tr></tbody></table>
<template #footer><el-button @click="open = false">关闭</el-button><el-button type="primary" :disabled="!rows.length || !partyId" :loading="saving" @click="save">{{ pending ? '按原请求重试' : '确认导入' }}</el-button></template>
<p v-if="pending" role="status" class="density-dialog-note">已保留原批次和原内容。结果未确认时请按原请求重试,避免重复导入。</p>
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="work" aria-label="导入报价文件与归属">
<h3 class="density-dialog-section__title">导入资料</h3>
<el-form label-position="left" label-width="130px" class="density-form" :disabled="saving || !!pending">
<table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="报价用途"><span>{{activeKind === 'PURCHASE' ? '车队采购报价' : '外接销售报价'}}</span></el-form-item></td></tr>
<tr><td><el-form-item :label="activeKind === 'PURCHASE' ? '海外车队供应商' : '委托客户'"><FleetQuotePartySelect v-model="partyId" :kind="activeKind" :disabled="saving || !!pending" /></el-form-item></td></tr>
<tr><td><el-form-item label="CSV文件"><input type="file" accept=".csv" aria-label="选择报价CSV" :disabled="saving || !!pending" @change="pick" /></el-form-item></td></tr>
</tbody></table>
</el-form>
<el-button :disabled="saving" @click="template">下载CSV模板</el-button>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="导入报价预览">
<h3 class="density-dialog-section__title">报价预览</h3>
<p aria-live="polite" class="density-dialog-note">共 {{ rows.length }} 条,预览前10条;目录有效性与重复组合由服务端核对。</p>
<div class="fleet-import-scroll"><table class="density-table fleet-import-table"><thead><tr><th scope="col">发货城市</th><th scope="col">目的仓码</th><th scope="col">距离(mile)</th><th scope="col">币种</th><th scope="col">整车自提</th><th scope="col">整车派送</th><th scope="col">散板派送</th><th scope="col">有效期</th></tr></thead><tbody><tr v-for="(row, index) in rows.slice(0,10)" :key="index"><td>{{ row.originCity }}</td><td>{{ row.destinationCode }}</td><td>{{ row.distanceMiles }}</td><td>{{ row.currency }}</td><td>{{ row.selfPickupPrice ?? '—' }}</td><td>{{ row.fullDeliveryPrice ?? '—' }}</td><td>{{ row.looseDeliveryPrice ?? '—' }}</td><td>{{ row.validFrom }} — {{ row.validTo }}</td></tr><tr v-if="!rows.length"><td colspan="8">请选择往来单位并上传有效CSV文件</td></tr></tbody></table></div>
</section>
</div>
<template #footer="{close}"><div class="density-actions density-actions--footer"><el-button :disabled="saving" @click="close">关闭</el-button><el-button type="primary" :disabled="!rows.length || !partyId" :loading="saving" @click="save">{{ pending ? '按原请求重试' : '确认导入' }}</el-button></div></template>
</AppDialog></template>
<style scoped>
.fleet-import-scroll { overflow-x: auto; }
.fleet-import-table { min-width: 900px; }
</style>
@@ -40,7 +40,7 @@ function search() { if (page.value === 1) void load(); else page.value = 1 }
function openCreate() { editingMonth.value = ''; Object.assign(form, { month: '', usdToCnyAverage: 0, remark: '', recordVersion: 0 }); editorOpen.value = true }
async function openEdit(row: MonthlyExchangeRate) { try { const value = await getExchangeRate(row.month); editingMonth.value = value.month; Object.assign(form, { month: value.month, usdToCnyAverage: Number(value.usdToCnyAverage), remark: value.remark || '', recordVersion: value.recordVersion }); editorOpen.value = true } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率详情加载失败')) } }
async function save() { if (!form.month || form.usdToCnyAverage <= 0) { ElMessage.warning('请选择月份并填写大于 0 的平均汇率'); return } saving.value = true; try { if (editingMonth.value) await updateExchangeRate(editingMonth.value, { recordVersion: form.recordVersion, usdToCnyAverage: form.usdToCnyAverage, remark: form.remark || null }); else await createExchangeRate({ month: form.month, usdToCnyAverage: form.usdToCnyAverage, remark: form.remark || null }); ElMessage.success(editingMonth.value ? '月度汇率已更新' : '月度汇率已创建'); editorOpen.value = false; await load() } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '月度汇率保存失败')) } finally { saving.value = false } }
async function toggle(row: MonthlyExchangeRate) { const next: MasterDataStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'; try { await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'} ${row.month} 汇率吗?`, '汇率状态', { customClass: 'admin-density-confirm', type: 'warning' }); await changeExchangeRateStatus(row.month, next, row.recordVersion, null); ElMessage.success('汇率状态已更新'); await load() } catch (cause) { if (cause !== 'cancel' && cause !== 'close') ElMessage.error(masterDataErrorMessage(cause, '汇率状态更新失败')) } }
async function toggle(row: MonthlyExchangeRate) { const next: MasterDataStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'; try { await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'} ${row.month} 汇率吗?`, `${next === 'ACTIVE' ? '启用' : '停用'}月度汇率 · ${row.month}`, { customClass: 'admin-density-confirm', type: 'warning' }); await changeExchangeRateStatus(row.month, next, row.recordVersion, null); ElMessage.success('汇率状态已更新'); await load() } catch (cause) { if (cause !== 'cancel' && cause !== 'close') ElMessage.error(masterDataErrorMessage(cause, '汇率状态更新失败')) } }
async function exportCsv() { exporting.value = true; try { const { page: _page, size: _size, ...query } = params(); await submitExport('MONTHLY_EXCHANGE_RATE_LIST', '月度汇率', query) } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率导出失败')) } finally { exporting.value = false } }
watch([page, pageSize], load); onMounted(load)
</script>
@@ -60,6 +60,15 @@ watch([page, pageSize], load); onMounted(load)
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.exchangeRateEdit)" @click="openEdit(row as MonthlyExchangeRate)">编辑</el-button><el-button class="density-action" v-if="access.can(permission.exchangeRateStatus)" :data-tone="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggle(row as MonthlyExchangeRate)">{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '停用' : '启用' }}</el-button></div></template>
<template #conversion="{ row }">1 USD = {{ (row as MonthlyExchangeRate).usdToCnyAverage }} CNY</template><template #status="{ row }"><el-tag :type="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'success' : 'info'">{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
</DataTable></section>
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editingMonth ? '编辑月度汇率' : '更新月度汇率'" width="min(560px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form class="density-form" label-position="top"><el-form-item label="汇率月份"><el-date-picker v-model="form.month" type="month" value-format="YYYY-MM" :disabled="!!editingMonth" /></el-form-item><el-form-item label="美元兑人民币平均汇率"><el-input-number v-model="form.usdToCnyAverage" :min="0.00000001" :precision="8" placeholder="请输入美元兑人民币月均汇率" /><p>汇率方向:1 USD = X CNY</p></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="请输入备注(选填)" /></el-form-item></el-form></AppDialog>
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editingMonth ? `编辑月度汇率 · ${editingMonth}` : '更新月度汇率'" width="min(560px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
<el-form class="density-form" label-position="left" label-width="160px" :disabled="saving">
<table class="density-table density-table--editable" aria-label="月度汇率资料"><tbody>
<tr><td><el-form-item label="汇率月份"><span v-if="editingMonth">{{ form.month }}</span><el-date-picker v-else v-model="form.month" type="month" value-format="YYYY-MM" /></el-form-item></td></tr>
<tr><td><el-form-item label="美元兑人民币平均汇率"><el-input-number v-model="form.usdToCnyAverage" :min="0.00000001" :precision="8" placeholder="请输入美元兑人民币月均汇率" /></el-form-item></td></tr>
<tr><td><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="请输入备注(选填)" /></el-form-item></td></tr>
</tbody></table>
<p class="density-dialog-note">汇率方向:1 USD = X CNY</p>
</el-form>
</AppDialog>
</ListPageLayout></AdminPage></template>
<style scoped>.table-panel{min-width:0;overflow:hidden}</style>
@@ -32,7 +32,7 @@ function actions(quoteNo: unknown): RowActionItem[] { const row = rows.value.fin
async function action(key: string, quoteNo: unknown) {
const row = rows.value.find(item => item.quoteNo === quoteNo); if (!row) return
if (key === 'view' || key === 'edit') { open(row, key === 'view'); return }
try { await ElMessageBox.confirm(key === 'delete' ? `删除报价 ${row.quoteNo}?历史报价版本仍保留,已确认承运金额不变。` : `确认${row.status === 'ACTIVE' ? '停用' : '启用'}报价 ${row.quoteNo}?`, '确认操作', { type: 'warning' }) } catch { return }
try { await ElMessageBox.confirm(key === 'delete' ? `删除报价 ${row.quoteNo}?历史报价版本仍保留,已确认承运金额不变。` : `确认${row.status === 'ACTIVE' ? '停用' : '启用'}报价 ${row.quoteNo}?`, `${key === 'delete' ? '删除' : row.status === 'ACTIVE' ? '停用' : '启用'}车队报价 · ${row.quoteNo}`, { customClass: 'admin-density-confirm', type: 'warning' }) } catch { return }
busy.value = true
try { if (key === 'delete') await deleteFleetQuote(row); else await setFleetQuoteStatus(row, row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'); ElMessage.success('报价已更新'); await load() } catch (e) { error.value = getApiErrorDetails(e, '报价操作失败') } finally { busy.value = false }
}
@@ -21,7 +21,7 @@ const submitExport = useExportTask()
const access = useAccessPreviewStore()
const rows = ref<OverseasWarehouseQuote[]>([]), total = ref(0), page = ref(1), pageSize = ref(20), loading = ref(false), saving = ref(false), exporting = ref(false)
const error = ref<ApiErrorDetails | null>(null), options = ref<PartnerWarehouseOption[]>([]), detail = ref<OverseasWarehouseQuoteDetail | null>(null)
const detailOpen = ref(false), editorOpen = ref(false), editingId = ref('')
const detailOpen = ref(false), editorOpen = ref(false), editingId = ref(''), editingWarehouse = ref('')
const filters = ref<QueryModel>({ keyword: '', warehouseId: '', providerName: '', currency: '', status: '' })
const fields = ref<QueryField[]>([])
function syncFields() { fields.value = [
@@ -46,11 +46,11 @@ async function load(){ loading.value=true;error.value=null;try{const r=await lis
async function loadOptions(){ try{options.value=await listQuoteWarehouseOptions();syncFields()}catch(e){ElMessage.error(masterDataErrorMessage(e,'合作仓候选加载失败'))} }
function search(){ if(page.value===1) void load(); else page.value=1 }
async function openDetail(row:OverseasWarehouseQuote){ try{detail.value=await getQuote(row.id);detailOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价详情加载失败'))} }
function fill(d:OverseasWarehouseQuoteDetail){const s=d.summary;Object.assign(form,{name:s.name,warehouseId:s.warehouseId,providerName:s.providerName,currency:s.currency,effectiveStart:s.effectiveStart,effectiveEnd:s.effectiveEnd,versionCode:s.versionCode,remark:s.remark||'',recordVersion:s.recordVersion,warehouseFees:d.warehouseFees.map(v=>({...v})),transitPrices:d.transitPrices.map(v=>({...v}))})}
function fill(d:OverseasWarehouseQuoteDetail){const s=d.summary;editingWarehouse.value=[s.warehouseName,s.warehouseCode].filter(Boolean).join(' · ')||'—';Object.assign(form,{name:s.name,warehouseId:s.warehouseId,providerName:s.providerName,currency:s.currency,effectiveStart:s.effectiveStart,effectiveEnd:s.effectiveEnd,versionCode:s.versionCode,remark:s.remark||'',recordVersion:s.recordVersion,warehouseFees:d.warehouseFees.map(v=>({...v})),transitPrices:d.transitPrices.map(v=>({...v}))})}
async function openCreate(){editingId.value='';resetForm();if(!options.value.length)await loadOptions();editorOpen.value=true}
async function openEdit(row:OverseasWarehouseQuote){try{const d=await getQuote(row.id);editingId.value=row.id;fill(d);editorOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价详情加载失败'))}}
async function save(){if(!form.name||!form.warehouseId||!form.providerName||!form.effectiveStart||!form.effectiveEnd||!form.warehouseFees.length){ElMessage.warning('请完整填写报价必填信息');return} if(form.effectiveEnd<form.effectiveStart){ElMessage.warning('失效日期不能早于生效日期');return} saving.value=true;try{const common={name:form.name,providerName:form.providerName,currency:form.currency as 'USD'|'CNY',effectiveStart:form.effectiveStart,effectiveEnd:form.effectiveEnd,warehouseFees:form.warehouseFees,transitPrices:form.transitPrices,remark:form.remark||null};if(editingId.value)await updateQuote(editingId.value,{...common,recordVersion:form.recordVersion});else await createQuote({...common,warehouseId:form.warehouseId,versionCode:form.versionCode});ElMessage.success(editingId.value?'报价已更新':'报价已创建');editorOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价保存失败'))}finally{saving.value=false}}
async function toggle(row:OverseasWarehouseQuote){const next:MasterDataStatus=row.status==='ACTIVE'?'INACTIVE':'ACTIVE';try{await ElMessageBox.confirm(`确认${next==='ACTIVE'?'启用':'停用'}“${row.name}”吗?`,'报价状态',{customClass:'admin-density-confirm',type:'warning'});await changeQuoteStatus(row.id,next,row.recordVersion);ElMessage.success('报价状态已更新');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(masterDataErrorMessage(e,'报价状态更新失败'))}}
async function toggle(row:OverseasWarehouseQuote){const next:MasterDataStatus=row.status==='ACTIVE'?'INACTIVE':'ACTIVE';try{await ElMessageBox.confirm(`确认${next==='ACTIVE'?'启用':'停用'}“${row.name}”吗?`,`${next==='ACTIVE'?'启用':'停用'}海外仓报价 · ${row.name.trim() || '—'}`,{customClass:'admin-density-confirm',type:'warning'});await changeQuoteStatus(row.id,next,row.recordVersion);ElMessage.success('报价状态已更新');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(masterDataErrorMessage(e,'报价状态更新失败'))}}
async function exportCsv(){exporting.value=true;try{const {page:_,size:__,...q}=params();await submitExport('OVERSEAS_QUOTE_LIST', '海外报价', q)}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价导出失败'))}finally{exporting.value=false}}
watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions()])})
</script>
@@ -67,7 +67,58 @@ watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions(
<template #actions="{row}"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="neutral" class="density-action" @click="openDetail(row as OverseasWarehouseQuote)">详情</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.quoteEdit)" @click="openEdit(row as OverseasWarehouseQuote)">编辑</el-button><el-button class="density-action" v-if="access.can(permission.quoteStatus)" :data-tone="(row as OverseasWarehouseQuote).status==='ACTIVE'?'danger':'success'" @click="toggle(row as OverseasWarehouseQuote)">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'停用':'启用'}}</el-button></div></template>
<template #status="{row}"><el-tag :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'success':'info'">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'启用':'停用'}}</el-tag></template>
</DataTable></section>
<AppDialog class="admin-density-dialog" v-model="detailOpen" title="海外仓报价详情" width="min(980px, calc(100vw - 28px))" :show-confirm="false"><dl v-if="detail" class="detail-grid density-fields"><div v-for="item in [['报价名称',detail.summary.name],['海外仓',detail.summary.warehouseName],['服务商',detail.summary.providerName],['币种',detail.summary.currency],['生效日期',detail.summary.effectiveStart],['失效日期',detail.summary.effectiveEnd],['版本',detail.summary.versionCode],['状态',detail.summary.status]]" :key="item[0]"><dt>{{item[0]}}</dt><dd>{{item[1]}}</dd></div></dl><h3>仓储作业费用</h3><el-table :data="detail?.warehouseFees||[]" border><el-table-column prop="item" label="费用项目"/><el-table-column prop="billingUnit" label="计费单位"/><el-table-column prop="unitPrice" label="单价"/><el-table-column prop="minimumCharge" label="最低收费"/><el-table-column prop="remark" label="备注"/></el-table><h3>中转派送价格</h3><el-table :data="detail?.transitPrices||[]" border><el-table-column prop="fbaWarehouseCode" label="目的FBA仓"/><el-table-column prop="deliveryMethod" label="车型/提货方式"/><el-table-column prop="estimatedMiles" label="预估迈数"/><el-table-column prop="price" label="报价"/><el-table-column prop="minimumCharge" label="最低收费"/></el-table></AppDialog>
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editingId?'编辑海外仓报价':'新增海外仓报价'" width="min(1180px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存报价" @confirm="save"><el-form class="density-form" label-position="top"><div class="form-grid"><el-form-item label="报价名称"><el-input v-model="form.name" placeholder="请输入报价名称" /></el-form-item><el-form-item label="海外仓"><el-select v-model="form.warehouseId" :disabled="!!editingId"><el-option v-for="o in options" :key="o.warehouseId" :label="`${o.warehouseName} · ${o.warehouseCode}`" :value="o.warehouseId"/></el-select></el-form-item><el-form-item label="服务商"><el-input v-model="form.providerName" placeholder="请输入服务商名称" /></el-form-item><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><el-form-item label="生效日期"><el-date-picker v-model="form.effectiveStart" type="date" value-format="YYYY-MM-DD"/></el-form-item><el-form-item label="失效日期"><el-date-picker v-model="form.effectiveEnd" type="date" value-format="YYYY-MM-DD"/></el-form-item><el-form-item label="报价版本"><el-input v-model="form.versionCode" :disabled="!!editingId" placeholder="请输入版本号" /></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" placeholder="请输入备注(选填)" /></el-form-item></div><h3>仓储作业费用 <el-button @click="form.warehouseFees.push({item:'',billingUnit:'',unitPrice:0,minimumCharge:null,remark:null})">+ 添加费用</el-button></h3><div v-for="(f,i) in form.warehouseFees" :key="i" class="line-grid"><el-input v-model="f.item" placeholder="请输入费用项目"/><el-input v-model="f.billingUnit" placeholder="请输入计费单位"/><el-input-number v-model="f.unitPrice" :min="0" placeholder="请输入单价" /><el-input-number v-model="f.minimumCharge" :min="0" placeholder="请输入最低收费" /><el-button data-tone="danger" class="density-action" @click="form.warehouseFees.splice(i,1)">删除</el-button></div><h3>中转派送价格 <el-button @click="form.transitPrices.push({fbaWarehouseCode:'',deliveryMethod:'',estimatedMiles:null,price:0,minimumCharge:null,remark:null})">+ 添加派送价格</el-button></h3><div v-for="(t,i) in form.transitPrices" :key="i" class="line-grid"><el-input v-model="t.fbaWarehouseCode" placeholder="请输入目的 FBA 仓"/><el-input v-model="t.deliveryMethod" placeholder="请输入车型或提货方式"/><el-input-number v-model="t.price" :min="0" placeholder="请输入报价" /><el-input-number v-model="t.minimumCharge" :min="0" placeholder="请输入最低收费" /><el-button data-tone="danger" class="density-action" @click="form.transitPrices.splice(i,1)">删除</el-button></div></el-form></AppDialog>
<AppDialog class="admin-density-dialog" v-model="detailOpen" :title="detail?.summary.name?.trim() ? `海外仓报价详情 · ${detail.summary.name.trim()}` : '海外仓报价详情'" width="min(980px, calc(100vw - 28px))" fullscreenable :show-confirm="false">
<div v-if="detail" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="报价摘要">
<h3 class="density-dialog-section__title">报价信息</h3>
<dl class="detail-grid density-fields"><div v-for="item in [['报价名称',detail.summary.name],['海外仓',detail.summary.warehouseName],['服务商',detail.summary.providerName],['币种',detail.summary.currency],['生效日期',detail.summary.effectiveStart],['失效日期',detail.summary.effectiveEnd],['版本',detail.summary.versionCode],['状态',detail.summary.status === 'ACTIVE' ? '启用' : '停用']]" :key="item[0] || ''"><dt>{{item[0]}}</dt><dd>{{item[1] || '—'}}</dd></div></dl>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="仓储作业费用">
<h3 class="density-dialog-section__title">仓储作业费用</h3>
<el-table :data="detail.warehouseFees" border empty-text="暂无仓储作业费用"><el-table-column prop="item" label="费用项目" min-width="150"/><el-table-column prop="billingUnit" label="计费单位" width="100"/><el-table-column prop="unitPrice" label="单价" min-width="100"/><el-table-column label="最低收费" min-width="100"><template #default="{row}">{{row.minimumCharge ?? '—'}}</template></el-table-column><el-table-column label="备注" min-width="150"><template #default="{row}">{{row.remark || '—'}}</template></el-table-column></el-table>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="中转派送价格">
<h3 class="density-dialog-section__title">中转派送价格</h3>
<el-table :data="detail.transitPrices" border empty-text="暂无中转派送价格"><el-table-column prop="fbaWarehouseCode" label="目的FBA仓" min-width="120"/><el-table-column prop="deliveryMethod" label="车型/提货方式" min-width="160"/><el-table-column label="预估迈数" min-width="100"><template #default="{row}">{{row.estimatedMiles ?? '—'}}</template></el-table-column><el-table-column prop="price" label="报价" min-width="100"/><el-table-column label="最低收费" min-width="100"><template #default="{row}">{{row.minimumCharge ?? '—'}}</template></el-table-column></el-table>
</section>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editingId ? `编辑海外仓报价 · ${form.name.trim() || '—'}` : '新增海外仓报价'" width="min(1180px, calc(100vw - 28px))" fullscreenable :confirm-loading="saving" confirm-text="保存报价" @confirm="save">
<el-form class="density-form density-dialog-sections" label-position="left" label-width="90px" :disabled="saving">
<section class="density-dialog-section" data-tone="work" aria-label="报价基础信息">
<h3 class="density-dialog-section__title">基础信息</h3>
<table class="density-table density-table--editable quote-basics-table"><tbody>
<tr><td><el-form-item label="报价名称"><el-input v-model="form.name" placeholder="请输入报价名称" /></el-form-item></td><td><el-form-item label="海外仓"><span v-if="editingId">{{ editingWarehouse }}</span><el-select v-else v-model="form.warehouseId"><el-option v-for="o in options" :key="o.warehouseId" :label="`${o.warehouseName} · ${o.warehouseCode}`" :value="o.warehouseId"/></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="服务商"><el-input v-model="form.providerName" 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>
<tr><td><el-form-item label="生效日期"><el-date-picker v-model="form.effectiveStart" type="date" value-format="YYYY-MM-DD"/></el-form-item></td><td><el-form-item label="失效日期"><el-date-picker v-model="form.effectiveEnd" type="date" value-format="YYYY-MM-DD"/></el-form-item></td></tr>
<tr><td><el-form-item label="报价版本"><span v-if="editingId">{{form.versionCode || '—'}}</span><el-input v-else v-model="form.versionCode" placeholder="请输入版本号" /></el-form-item></td><td><el-form-item label="备注"><el-input v-model="form.remark" 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 quote-section-heading">仓储作业费用 <el-button @click="form.warehouseFees.push({item:'',billingUnit:'',unitPrice:0,minimumCharge:null,remark:null})">+ 添加费用</el-button></h3>
<div class="quote-lines-scroll"><table class="density-table density-table--editable quote-lines-table"><thead><tr><th scope="col">操作</th><th scope="col">费用项目</th><th scope="col">计费单位</th><th scope="col">单价</th><th scope="col">最低收费</th></tr></thead><tbody>
<tr v-for="(f,i) in form.warehouseFees" :key="i"><td><el-button data-tone="danger" class="density-action" :aria-label="`删除第${i+1}项费用`" @click="form.warehouseFees.splice(i,1)">删除</el-button></td><td><el-input v-model="f.item" :aria-label="`第${i+1}项费用项目`" placeholder="请输入费用项目"/></td><td><el-input v-model="f.billingUnit" :aria-label="`第${i+1}项计费单位`" placeholder="请输入计费单位"/></td><td><el-input-number v-model="f.unitPrice" :aria-label="`第${i+1}项费用单价`" :min="0" placeholder="请输入单价" /></td><td><el-input-number v-model="f.minimumCharge" :aria-label="`第${i+1}项费用最低收费`" :min="0" placeholder="请输入最低收费" /></td></tr>
<tr v-if="!form.warehouseFees.length"><td colspan="5">暂无仓储作业费用</td></tr>
</tbody></table></div>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="编辑中转派送价格">
<h3 class="density-dialog-section__title quote-section-heading">中转派送价格 <el-button @click="form.transitPrices.push({fbaWarehouseCode:'',deliveryMethod:'',estimatedMiles:null,price:0,minimumCharge:null,remark:null})">+ 添加派送价格</el-button></h3>
<div class="quote-lines-scroll"><table class="density-table density-table--editable quote-lines-table"><thead><tr><th scope="col">操作</th><th scope="col">目的FBA仓</th><th scope="col">车型/提货方式</th><th scope="col">报价</th><th scope="col">最低收费</th></tr></thead><tbody>
<tr v-for="(t,i) in form.transitPrices" :key="i"><td><el-button data-tone="danger" class="density-action" :aria-label="`删除第${i+1}项派送价格`" @click="form.transitPrices.splice(i,1)">删除</el-button></td><td><el-input v-model="t.fbaWarehouseCode" :aria-label="`第${i+1}项目的FBA仓`" placeholder="请输入目的 FBA 仓"/></td><td><el-input v-model="t.deliveryMethod" :aria-label="`第${i+1}项车型或提货方式`" placeholder="请输入车型或提货方式"/></td><td><el-input-number v-model="t.price" :aria-label="`第${i+1}项派送报价`" :min="0" placeholder="请输入报价" /></td><td><el-input-number v-model="t.minimumCharge" :aria-label="`第${i+1}项派送最低收费`" :min="0" placeholder="请输入最低收费" /></td></tr>
<tr v-if="!form.transitPrices.length"><td colspan="5">暂无中转派送价格</td></tr>
</tbody></table></div>
</section>
</el-form>
</AppDialog>
</ListPageLayout></AdminPage></template>
<style scoped>.detail-grid.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }.table-panel{min-width:0;overflow:hidden}.form-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--admin-space-5) var(--admin-space-6)}.line-grid{display:grid;grid-template-columns:2fr 1.2fr 1fr 1fr auto;gap:var(--admin-space-3);margin:var(--admin-space-3) 0}h3{margin:var(--admin-space-6) 0 var(--admin-space-4);font-size:var(--admin-font-body)}</style>
<style scoped>
.detail-grid.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.quote-basics-table { table-layout: fixed; }
.quote-basics-table td { width: 50%; }
.quote-section-heading { justify-content: space-between; }
.quote-lines-scroll { overflow-x: auto; }
.quote-lines-table { table-layout: fixed; min-width: 760px; }
.quote-lines-table th:first-child { width: 70px; }
.quote-lines-table th:nth-child(2) { width: 25%; }
@media (max-width: 600px) { .quote-basics-table tr { display: grid; } .quote-basics-table td { width: auto; } }
</style>
@@ -0,0 +1,196 @@
import ElementPlus, { ElDatePicker, ElDialog, ElInputNumber, ElMessageBox, ElSelect } from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
import ExchangeRatesView from './ExchangeRatesView.vue'
import OverseasQuotesView from './OverseasQuotesView.vue'
import * as api from '../api/overseasMasterData'
import { overseasMasterDataPermissions as permission } from '../permissions'
import type { MonthlyExchangeRate, OverseasWarehouseQuoteDetail } from '../types'
vi.mock('@/modules/downloads', () => ({ useExportTask: () => vi.fn() }))
vi.mock('../api/overseasMasterData', () => ({
listQuotes: vi.fn(), listQuoteWarehouseOptions: vi.fn(), getQuote: vi.fn(), createQuote: vi.fn(), updateQuote: vi.fn(), changeQuoteStatus: vi.fn(),
listExchangeRates: vi.fn(), getExchangeRateSummary: vi.fn(), getExchangeRate: vi.fn(), createExchangeRate: vi.fn(), updateExchangeRate: vi.fn(), changeExchangeRateStatus: vi.fn(),
}))
const privateId = '9223372036854775807'
let detail: OverseasWarehouseQuoteDetail
let rate: MonthlyExchangeRate
const tableStub = { props: ['data'], template: '<div><slot name="toolbar-actions"/><div class="real-row"><slot v-if="data?.length" name="actions" :row="data[0]"/></div></div>' }
function mountView(component: typeof ExchangeRatesView | typeof OverseasQuotesView) {
return mount(component, { attachTo: document.body, global: { plugins: [ElementPlus], stubs: { DataTable: tableStub, QueryToolbar: true } } })
}
function button(label: string, selector = 'button') {
const found = Array.from(document.querySelectorAll<HTMLButtonElement>(selector)).find(node => node.textContent?.trim() === label)
expect(found, label).toBeDefined()
return found!
}
const activeDialog = (wrapper: ReturnType<typeof mountView>) => wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('modelValue'))!
async function openEditor(component: typeof ExchangeRatesView | typeof OverseasQuotesView, label = '编辑') {
const wrapper = mountView(component); await flushPromises()
button(label).click(); await flushPromises()
return wrapper
}
enableAutoUnmount(afterEach)
afterEach(() => ElMessageBox.close())
beforeEach(() => {
vi.resetAllMocks()
document.body.innerHTML = ''
setActivePinia(createPinia())
const auth = useAuthStore(); auth.token = 'test-session'; auth.permissions = Object.values(permission)
useAccessPreviewStore().initializeAfterLogin()
detail = { summary: { id: privateId, name: '报价测试', warehouseId: '9223372036854775806', warehouseName: '合作仓', warehouseCode: 'US-LAX', countryCode: 'US', providerName: '服务商', currency: 'USD', effectiveStart: '2026-10-01', effectiveEnd: '2026-12-31', versionCode: 'V2', status: 'ACTIVE', updatedAt: '', updatedBy: '测试员', recordVersion: 9, remark: '原备注' }, warehouseFees: [{ item: '卸货', billingUnit: '托', unitPrice: 0, minimumCharge: null, remark: '保留费用备注' }], transitPrices: [{ fbaWarehouseCode: 'LAX9', deliveryMethod: '卡车', estimatedMiles: 12.5, price: 20, minimumCharge: 0, remark: '保留派送备注' }], createdBy: '测试员', createdAt: '' }
rate = { month: '2026-10', baseCurrency: 'USD', quoteCurrency: 'CNY', usdToCnyAverage: 7.12345678, status: 'ACTIVE', recordVersion: 4, updatedBy: '测试员', updatedAt: '', remark: '汇率备注' }
vi.mocked(api.listQuotes).mockImplementation(async () => ({ records: [detail.summary], total: 1, pages: 1, page: 1, size: 20 }))
vi.mocked(api.listQuoteWarehouseOptions).mockResolvedValue([])
vi.mocked(api.getQuote).mockImplementation(async () => detail)
vi.mocked(api.createQuote).mockImplementation(async () => detail)
vi.mocked(api.updateQuote).mockImplementation(async () => detail)
vi.mocked(api.changeQuoteStatus).mockImplementation(async () => detail)
vi.mocked(api.listExchangeRates).mockImplementation(async () => ({ records: [rate], total: 1, pages: 1, page: 1, size: 20 }))
vi.mocked(api.getExchangeRateSummary).mockResolvedValue({ latestMonth: null, latestUsdToCnyAverage: null, updatedAt: null, updatedBy: null })
vi.mocked(api.getExchangeRate).mockImplementation(async () => rate)
vi.mocked(api.createExchangeRate).mockImplementation(async () => rate)
vi.mocked(api.updateExchangeRate).mockImplementation(async () => rate)
vi.mocked(api.changeExchangeRateStatus).mockImplementation(async () => rate)
})
describe('exchange rate and overseas quote dialogs', () => {
it('edits the original monthly version with a readonly month and eight decimal places', async () => {
const wrapper = await openEditor(ExchangeRatesView)
const dialog = activeDialog(wrapper)
expect(dialog.props('title')).toBe('编辑月度汇率 · 2026-10')
expect(dialog.findComponent(ElDatePicker).exists()).toBe(false)
expect(document.querySelector('[aria-label="月度汇率资料"]')?.textContent).toContain('2026-10')
expect(dialog.findComponent(ElInputNumber).props('precision')).toBe(8)
const remark = document.querySelector<HTMLTextAreaElement>('.el-dialog textarea')!
remark.value = '修改后的备注'; remark.dispatchEvent(new Event('input', { bubbles: true })); await flushPromises()
button('确定', '.el-dialog__footer button').click(); await flushPromises()
expect(api.updateExchangeRate).toHaveBeenCalledWith('2026-10', { recordVersion: 4, usdToCnyAverage: 7.12345678, remark: '修改后的备注' })
expect(api.createExchangeRate).not.toHaveBeenCalled()
})
it('keeps month selection on create and validates a positive average before writing', async () => {
const wrapper = await openEditor(ExchangeRatesView, '更新月度汇率')
const dialog = activeDialog(wrapper)
expect(dialog.findComponent(ElDatePicker).exists()).toBe(true)
button('确定', '.el-dialog__footer button').click(); await flushPromises()
expect(api.createExchangeRate).not.toHaveBeenCalled()
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-11')
dialog.findComponent(ElInputNumber).vm.$emit('update:modelValue', 7.00000001)
await flushPromises()
button('确定', '.el-dialog__footer button').click(); await flushPromises()
expect(api.createExchangeRate).toHaveBeenCalledWith({ month: '2026-11', usdToCnyAverage: 7.00000001, remark: null })
})
it('preserves quote edits and hidden detail values across fullscreen and sends only update fields', async () => {
const wrapper = await openEditor(OverseasQuotesView)
const dialog = activeDialog(wrapper)
expect(dialog.props()).toMatchObject({ title: '编辑海外仓报价 · 报价测试', fullscreenable: true })
const base = document.querySelector('[aria-label="报价基础信息"]')!
expect(base.textContent).toContain('合作仓 · US-LAX')
expect(base.textContent).toContain('V2')
expect(base.textContent).not.toContain(privateId)
expect(dialog.findAllComponents(ElSelect)).toHaveLength(1)
const price = document.querySelector<HTMLInputElement>('[aria-label="第1项费用单价"]')!
price.value = '1.25'; price.dispatchEvent(new Event('input', { bubbles: true })); price.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click(); await flushPromises()
expect(dialog.findComponent(ElDialog).props('fullscreen')).toBe(true)
expect(document.querySelector<HTMLInputElement>('[aria-label="第1项费用单价"]')!.value).toBe('1.25')
button('保存报价', '.el-dialog__footer button').click(); await flushPromises()
expect(api.updateQuote).toHaveBeenCalledWith(privateId, expect.objectContaining({ recordVersion: 9, warehouseFees: [{ ...detail.warehouseFees[0], unitPrice: 1.25 }], transitPrices: detail.transitPrices }))
const payload = vi.mocked(api.updateQuote).mock.calls[0]![1]
expect(payload).not.toHaveProperty('warehouseId'); expect(payload).not.toHaveProperty('versionCode')
expect(detail.warehouseFees[0]!.unitPrice).toBe(0)
})
it('retains draft on save failure and locks close and field edits during submission', async () => {
let rejectSave!: (cause: Error) => void
vi.mocked(api.updateQuote).mockImplementation(() => new Promise((_resolve, reject) => { rejectSave = reject }))
const wrapper = await openEditor(OverseasQuotesView)
const dialog = activeDialog(wrapper)
button('保存报价', '.el-dialog__footer button').click(); await flushPromises()
expect(dialog.findComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
expect(document.querySelector<HTMLButtonElement>('.el-dialog__footer button')!.disabled).toBe(true)
expect(document.querySelector<HTMLInputElement>('.el-dialog input')!.disabled).toBe(true)
rejectSave(new Error('保存失败')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(document.querySelector<HTMLInputElement>('[aria-label="第1项费用项目"]')!.value).toBe('卸货')
expect(dialog.findComponent(ElDialog).props('showClose')).toBe(true)
})
it('keeps create warehouse and version selectable and original add/delete defaults', async () => {
const wrapper = await openEditor(OverseasQuotesView, '新增报价')
const dialog = activeDialog(wrapper)
expect(dialog.findAllComponents(ElSelect)).toHaveLength(2)
expect(document.querySelector<HTMLInputElement>('[placeholder="请输入版本号"]')!.value).toBe('V1.0')
expect(document.querySelectorAll('[aria-label="编辑仓储作业费用"] tbody tr')).toHaveLength(1)
button('+ 添加费用').click(); button('+ 添加派送价格').click(); await flushPromises()
expect(document.querySelectorAll('[aria-label="编辑仓储作业费用"] tbody tr')).toHaveLength(2)
expect(document.querySelector<HTMLInputElement>('[aria-label="第1项派送报价"]')!.value).toBe('0')
document.querySelector<HTMLButtonElement>('[aria-label="删除第2项费用"]')!.click(); await flushPromises()
expect(document.querySelectorAll('[aria-label="编辑仓储作业费用"] tbody tr')).toHaveLength(1)
button('保存报价', '.el-dialog__footer button').click(); await flushPromises()
expect(api.createQuote).not.toHaveBeenCalled()
})
it('shows full detail facts, translated status, zero and missing values without private IDs', async () => {
detail.summary.effectiveEnd = null
const wrapper = await openEditor(OverseasQuotesView, '详情')
const dialog = activeDialog(wrapper)
expect(dialog.props()).toMatchObject({ title: '海外仓报价详情 · 报价测试', fullscreenable: true, showConfirm: false, width: 'min(980px, calc(100vw - 28px))' })
const summary = document.querySelector('[aria-label="报价摘要"]')!
expect(summary.textContent).toContain('启用'); expect(summary.textContent).toContain('—'); expect(summary.textContent).not.toContain('ACTIVE'); expect(summary.textContent).not.toContain(privateId)
const fees = document.querySelector('[aria-label="仓储作业费用"] .el-table__body')!
expect(fees.textContent).toContain('保留费用备注')
expect(fees.querySelectorAll('td')[2]!.textContent).toBe('0')
expect(fees.querySelectorAll('td')[3]!.textContent).toBe('—')
const transit = document.querySelector('[aria-label="中转派送价格"] .el-table__body')!
expect(transit.querySelectorAll('td')[2]!.textContent).toBe('12.5')
expect(transit.querySelectorAll('td')[4]!.textContent).toBe('0')
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click(); await flushPromises()
expect(dialog.findComponent(ElDialog).props('fullscreen')).toBe(true)
expect(api.updateQuote).not.toHaveBeenCalled()
})
it('shows compact empty detail sections and complete long names', async () => {
detail.summary.name = '完整报价名称'.repeat(30); detail.summary.status = 'INACTIVE'
detail.warehouseFees = []; detail.transitPrices = []
await openEditor(OverseasQuotesView, '详情')
expect(document.querySelector('[aria-label="报价摘要"]')?.textContent).toContain(detail.summary.name)
expect(document.querySelector('[aria-label="报价摘要"]')?.textContent).toContain('停用')
expect(document.querySelector('.el-dialog')?.textContent).toContain('暂无仓储作业费用')
expect(document.querySelector('.el-dialog')?.textContent).toContain('暂无中转派送价格')
})
it.each(['ACTIVE', 'INACTIVE'] as const)('identifies the %s rate status change and waits for confirmation', async status => {
rate.status = status
const verb = status === 'ACTIVE' ? '停用' : '启用'
mountView(ExchangeRatesView); await flushPromises(); button(verb, '.real-row button').click(); await flushPromises()
expect(document.querySelector('.el-message-box__title')?.textContent).toBe(`${verb}月度汇率 · 2026-10`)
expect(api.changeExchangeRateStatus).not.toHaveBeenCalled()
document.querySelector<HTMLButtonElement>('.el-message-box__btns .el-button--primary')!.click(); await flushPromises()
expect(api.changeExchangeRateStatus).toHaveBeenCalledWith('2026-10', status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE', 4, null)
})
it.each(['ACTIVE', 'INACTIVE'] as const)('identifies the %s quote status change and waits for confirmation', async status => {
detail.summary.status = status
const verb = status === 'ACTIVE' ? '停用' : '启用'
mountView(OverseasQuotesView); await flushPromises(); button(verb, '.real-row button').click(); await flushPromises()
expect(document.querySelector('.el-message-box__title')?.textContent).toBe(`${verb}海外仓报价 · 报价测试`)
expect(api.changeQuoteStatus).not.toHaveBeenCalled()
document.querySelector<HTMLButtonElement>('.el-message-box__btns .el-button--primary')!.click(); await flushPromises()
expect(api.changeQuoteStatus).toHaveBeenCalledWith(privateId, status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE', 9)
})
it('keeps quote details available for list-only access without write entries', async () => {
useAuthStore().permissions = [permission.quoteList]; useAccessPreviewStore().initializeAfterLogin()
const wrapper = mountView(OverseasQuotesView); await flushPromises()
expect(wrapper.findAll('button').map(node => node.text())).toEqual(['详情'])
button('详情').click(); await flushPromises()
expect(activeDialog(wrapper).props('showConfirm')).toBe(false)
})
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { appointmentReturnTarget, TripAppointmentPanel } from '@/modules/appointment-calendar'
import { inquiryReturnTarget } from '../inquiryNavigation'
@@ -206,8 +207,7 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
<div v-if="activeTab === 'overview' || isPickup" v-show="activeTab === 'overview'">
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel v-if="!pickupLocked" :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
<TripPickupPanel v-if="isPickup" :detail="detail" @saved="accept" @locked="pickupLocked = $event" />
<FormSection v-else class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><TripDispatchSelector v-model="dispatchNo" :trip-no="detail.trip.tripNo" :disabled="!!busy" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
<p>先分配车辆和司机,选择待运排车单后自动匹配卸货站。每单调度审核通过后拣货,确认实际装载并通过理货审核后整车发车。</p>
<FormSection v-else class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><TripDispatchSelector v-model="dispatchNo" :trip-no="detail.trip.tripNo" :disabled="!!busy" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button><InlineHelp title="操作说明"><p>先分配车辆和司机,选择待运排车单后自动匹配卸货站。每单调度审核通过后拣货,确认实际装载并通过理货审核后整车发车。</p></InlineHelp></div>
<DataTable :data="detail.dispatches" :columns="bindingsColumns" row-key="dispatchNo" empty-text="尚未关联排车单"><template #actions="{ row }"><div class="density-actions"><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" class="density-action" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } })">详情</el-button><el-button v-if="editable" class="density-action" data-tone="danger" :disabled="!!busy" @click="unbind(row.dispatchNo)">解除关联</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" :disabled="!!busy || row.review.status === 'APPROVED'" @click="review('APPROVED', row.dispatchNo)">审核通过</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" data-tone="danger" :disabled="!!busy" @click="review('REJECTED', row.dispatchNo)">打回</el-button></div></template><template #review="{ row }"><span>{{ tripReviewLabels[row.review.status as keyof typeof tripReviewLabels] }}</span><p v-if="row.review.reason">{{ row.review.reason }}</p><span v-if="row.review.reviewedAt">{{ time(row.review.reviewedAt) }}</span></template><template #boundAt="{ row }">{{ time(row.boundAt) }}</template></DataTable>
</FormSection>
</div>