From 44ee2be24654979f7f980eefc8994585fda771f6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 18:50:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/auth/pages/LoginView.test.ts | 47 ++++++++++ src/modules/auth/pages/LoginView.vue | 6 +- .../api/platformOceanBookings.test.ts | 10 +- .../api/platformOceanBookings.ts | 9 ++ .../pages/OceanBookingDetailView.test.ts | 29 +++++- .../pages/OceanBookingDetailView.vue | 91 ++++++++++++++----- src/modules/ocean-bookings/types.ts | 2 +- src/shared/api/http.test.ts | 19 ++++ src/shared/api/http.ts | 12 ++- 9 files changed, 196 insertions(+), 29 deletions(-) diff --git a/src/modules/auth/pages/LoginView.test.ts b/src/modules/auth/pages/LoginView.test.ts index 9e55dc8d..755736f1 100644 --- a/src/modules/auth/pages/LoginView.test.ts +++ b/src/modules/auth/pages/LoginView.test.ts @@ -6,6 +6,7 @@ import { createMemoryHistory, createRouter } from 'vue-router' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' +import { ApiResponseError } from '@/shared/api/http' import LoginView from './LoginView.vue' const loginSource = readFileSync(resolve(process.cwd(), 'src/modules/auth/pages/LoginView.vue'), 'utf8') @@ -177,4 +178,50 @@ describe('LoginView authentication lifecycle', () => { expect(initialize).not.toHaveBeenCalled() expect(replace).not.toHaveBeenCalled() }) + + it('shows the backend credential error and diagnostic identifiers', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const auth = useAuthStore() + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/login', component: LoginView }, + { path: '/', component: { template: '

home

' } }, + ], + }) + await router.push('/login') + await router.isReady() + vi.spyOn(auth, 'login').mockRejectedValue(new ApiResponseError( + '用户名或密码错误', + 'A0401', + undefined, + null, + 'request-login', + 'trace-login', + )) + const wrapper = mount(LoginView, { + global: { + plugins: [pinia, router], + stubs: { + 'el-form': formStub, + 'el-form-item': { template: '' }, + 'el-input': { props: ['modelValue'], emits: ['update:modelValue'], template: '' }, + 'el-button': { emits: ['click'], template: '' }, + }, + }, + }) + + const inputs = wrapper.findAll('input') + await inputs[0]!.setValue('platform_admin') + await inputs[1]!.setValue('incorrect-password') + await wrapper.get('form').trigger('submit') + await flushPromises() + + const alert = wrapper.get('[role="alert"]').text() + expect(alert).toContain('用户名或密码错误') + expect(alert).toContain('request-login') + expect(alert).toContain('trace-login') + expect(alert).not.toContain('登录状态已失效') + }) }) diff --git a/src/modules/auth/pages/LoginView.vue b/src/modules/auth/pages/LoginView.vue index 7f62784e..a520e614 100644 --- a/src/modules/auth/pages/LoginView.vue +++ b/src/modules/auth/pages/LoginView.vue @@ -41,7 +41,11 @@ async function submit() { : requestedPath await router.replace(targetPath) } catch (error) { - loginError.value = getApiErrorDetails(error, '登录失败,请检查账号和密码后重试') + loginError.value = getApiErrorDetails( + error, + '登录失败,请检查账号和密码后重试', + { preserveAuthenticationMessage: true }, + ) ElMessage.error(loginError.value.message) } finally { loading.value = false diff --git a/src/modules/ocean-bookings/api/platformOceanBookings.test.ts b/src/modules/ocean-bookings/api/platformOceanBookings.test.ts index 0191d9b8..6e31c513 100644 --- a/src/modules/ocean-bookings/api/platformOceanBookings.test.ts +++ b/src/modules/ocean-bookings/api/platformOceanBookings.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { createOceanBooking, listOceanBookings, performOceanBookingAction, releaseOceanBooking, requestOceanBookingChange } from './platformOceanBookings' +import { createOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, performOceanBookingAction, releaseOceanBooking, requestOceanBookingChange } from './platformOceanBookings' import { http } from '@/shared/api/http' const detailResponse = (version = 0) => ({ @@ -22,7 +22,7 @@ describe('ocean bookings api', () => { it('uses version zero for create and extracts the id from the returned Detail', async () => { const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse() }) - const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: '456', customerName: '客户', channelCode: 'SEA', channelName: '海运', warehouseId: '8', warehouseName: '义乌仓', parties: [], containerRequirements: [] } }) + const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: '456', customerName: '客户', channelCode: 'SEA', warehouseId: '8', warehouseName: '义乌仓', parties: [], containerRequirements: [] } }) expect(post.mock.calls[0]?.[1]).toMatchObject({ expectedVersion: 0 }) expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) expect(created.id).toBe('123') @@ -43,4 +43,10 @@ describe('ocean bookings api', () => { expect(post.mock.calls[2]?.[0]).toContain('/123/release') expect(post.mock.calls[2]?.[1]).toMatchObject({ billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00' }) }) + + it('loads warehouse-scoped logistics channels from the booking contract', async () => { + vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ channelCode: 'SEA_FCL', channelName: '海运整箱' }] }) + await expect(listWarehouseLogisticsChannels('8')).resolves.toEqual([{ channelCode: 'SEA_FCL', channelName: '海运整箱' }]) + expect(http.get).toHaveBeenCalledWith('/platform/v1/ocean-bookings/channels', { params: { originWarehouseId: '8' } }) + }) }) diff --git a/src/modules/ocean-bookings/api/platformOceanBookings.ts b/src/modules/ocean-bookings/api/platformOceanBookings.ts index 484a5d5c..193919e0 100644 --- a/src/modules/ocean-bookings/api/platformOceanBookings.ts +++ b/src/modules/ocean-bookings/api/platformOceanBookings.ts @@ -2,6 +2,11 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import type { OceanBookingActionRequest, OceanBookingChangeRequest, OceanBookingDetail, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types' const basePath = '/platform/v1/ocean-bookings' + +export interface OceanLogisticsChannel { + channelCode: string + channelName: string +} function pageNumber(value: number | string, fallback: number) { const n = Number(value); return Number.isFinite(n) ? Math.trunc(n) : fallback } function booking(raw: OceanBookingResource): OceanBookingResource { const item = raw as OceanBookingResource & { version?: number | string | null } @@ -49,4 +54,8 @@ export async function performOceanBookingAction(id: string, action: Exclude, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}/release`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) } export async function requestOceanBookingChange(id: string, request: OceanBookingChangeRequest) { const response = await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}/change-request`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) } export async function printOceanBooking(id: string, type: 'SHIPPING_INSTRUCTION' | 'BILL_CONFIRMATION') { return http.get(`${basePath}/${encodeURIComponent(id)}/print`, { params: { type }, responseType: 'blob' }) } +export async function listWarehouseLogisticsChannels(warehouseId: string): Promise { + const response = await http.get, ApiEnvelope>(`${basePath}/channels`, { params: { originWarehouseId: warehouseId } }) + return (unwrapApiResponse(response) || []).map((item) => ({ channelCode: String(item.channelCode), channelName: String(item.channelName) })) +} export type { OceanBookingResource, OceanBookingStatus } diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts index d594eb9d..970b393f 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts @@ -45,8 +45,16 @@ describe('OceanBookingDetailView interaction contract', () => { }) it('captures channel, warehouse, capacity snapshots and uses the release DTO', () => { - expect(source).toContain('v-model="form.data.channelCode"') - expect(source).toContain('v-model="form.data.channelName"') + expect(source).toContain("listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', status: 'ACTIVE' })") + expect(source).toContain("warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC'") + expect(source).toContain('label="物流渠道" prop="channelCode"') + expect(source).toContain(' { expect(source).not.toContain('maxWeightKg: 28000') }) + it('keeps every container requirement removable without collapsing its fields', () => { + expect(source).toContain('@click="removeContainer(index)"') + expect(source).toContain(':aria-label="`删除箱型 ${index + 1}`"') + expect(source).toContain("form.data.containerRequirements.length === 1 ? '至少保留一条箱型' : '删除该箱型'") + expect(source).toContain(':disabled="form.data.containerRequirements.length === 1"') + expect(source).toContain('function removeContainer(index: number) { if (form.data.containerRequirements.length > 1)') + expect(source).toContain('') + expect(source).toContain('.container-table-wrap{overflow-x:auto') + expect(source).toContain('.container-table td :deep(.el-input),.container-table td :deep(.el-input-number){width:100%;min-width:0}') + expect(source).toContain(':controls="false" placeholder="请输入数量"') + expect(source).not.toContain('class="container-row"') + }) + it('keeps view, create and edit on the formal detail route with a return action', () => { expect(source).toContain("router.push('/ocean-bookings')") expect(source).toContain("router.replace(`/ocean-bookings/${created.id}`)") @@ -74,7 +95,11 @@ describe('OceanBookingDetailView interaction contract', () => { expect(source).toContain('aria-label="航线船期"') expect(source).toContain('aria-label="条款与柜量"') expect(source).toContain('grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr))') + expect(source).toContain('.booking-group__body--fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}') + expect(source).toContain('class="booking-field--wide" label="交货地点"') expect(source).toContain('@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}') + expect(source).toContain('@media(max-width:520px){.booking-group__body--fields{grid-template-columns:1fr}') + expect(source).toContain('min-width:580px') }) it('keeps the three real booking parties in their own three-column section', () => { diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue index e8055071..2d62dfb3 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue @@ -22,6 +22,8 @@ import { releaseOceanBooking, requestOceanBookingChange, updateOceanBooking, + listWarehouseLogisticsChannels, + type OceanLogisticsChannel, type OceanBookingAction, } from '../api/platformOceanBookings' import type { @@ -74,24 +76,32 @@ const partners = ref([]) const warehouses = ref([]) const optionsLoading = ref(false) const optionsError = ref('') +const channels = ref([]) +const channelsLoading = ref(false) +const channelsError = ref('') +const selectedChannelName = ref('') +let channelRequestSerial = 0 const selectableCustomers = computed(() => customers.value.filter((customer) => ( customer.status === 'ACTIVE' || String(customer.id) === String(form.data.customerId) ))) const shippingCompanies = computed(() => partners.value.filter((partner) => partner.types.includes('CARRIER'))) const bookingAgents = computed(() => partners.value.filter((partner) => partner.types.some((type) => ['SUPPLIER', 'CUSTOMS_BROKER'].includes(type)))) +const channelUnavailable = computed(() => Boolean(form.data.warehouseId) && !channelsLoading.value && !channelsError.value && channels.value.length === 0) function blankParties(): OceanBookingParty[] { return partyTypes.map(({ value }) => ({ partyType: value, partyName: '', contactName: '', contactPhone: '', address: '' })) } function blankData(): OceanBookingDraftData { return { - customerId: '', customerName: '', channelCode: '', channelName: '', warehouseId: '', warehouseName: '', parties: blankParties(), + customerId: '', customerName: '', channelCode: '', warehouseId: '', warehouseName: '', parties: blankParties(), containerRequirements: [createBlankContainerRequirement()], } } const form = reactive({ expectedVersion: 0, data: blankData() }) const rules: FormRules = { customerId: [{ required: true, message: '请选择客户', trigger: 'change' }], + warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }], + channelCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }], } const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT') const canSave = computed(() => isNew.value @@ -115,11 +125,11 @@ async function loadOptions() { const [customerResult, partnerResult, warehouseResult] = await Promise.all([ listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }), listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' }), - listPlatformWarehouses({ page: 1, size: 200, status: 'ACTIVE' }), + listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', status: 'ACTIVE' }), ]) customers.value = customerResult.records.filter((customer) => customer.status === 'ACTIVE') partners.value = partnerResult.records - warehouses.value = warehouseResult.records + warehouses.value = warehouseResult.records.filter((warehouse) => warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC') } catch (cause) { optionsError.value = getApiErrorDetails(cause, '客户及合作单位选项加载失败').message } finally { @@ -135,6 +145,29 @@ function selectWarehouse(value: unknown) { const warehouseId = String(value || '') form.data.warehouseId = warehouseId form.data.warehouseName = warehouses.value.find((warehouse) => String(warehouse.id) === warehouseId)?.warehouseName || '' + form.data.channelCode = '' + selectedChannelName.value = '' + channels.value = [] + channelsError.value = '' + if (warehouseId) void loadChannels(warehouseId) +} +async function loadChannels(warehouseId: string) { + const serial = ++channelRequestSerial + channelsLoading.value = true + channelsError.value = '' + try { + const result = await listWarehouseLogisticsChannels(warehouseId) + if (serial === channelRequestSerial && warehouseId === form.data.warehouseId) channels.value = result + } catch (cause) { + if (serial === channelRequestSerial) channelsError.value = getApiErrorDetails(cause, '物流渠道加载失败').message + } finally { + if (serial === channelRequestSerial) channelsLoading.value = false + } +} +function selectChannel(channelCode: string) { + const channel = channels.value.find((item) => item.channelCode === channelCode) + form.data.channelCode = channelCode + selectedChannelName.value = channel?.channelName || '' } function selectPartner(kind: 'shippingCompany' | 'bookingAgent', partnerId: string) { const partner = partners.value.find((item) => String(item.id) === partnerId) @@ -150,7 +183,7 @@ function applyDetail(value: OceanBookingDetail) { detail.value = value form.expectedVersion = value.version form.data = { - customerId: value.customerId || '', customerName: value.customerName || '', channelCode: value.channelCode || '', channelName: value.channelName || '', warehouseId: value.warehouseId || '', warehouseName: value.warehouseName || '', billOfLadingNo: value.billOfLadingNo, + customerId: value.customerId || '', customerName: value.customerName || '', channelCode: value.channelCode || '', warehouseId: value.warehouseId || '', warehouseName: value.warehouseName || '', billOfLadingNo: value.billOfLadingNo, shippingCompanyId: value.shippingCompanyId, shippingCompanyName: value.shippingCompanyName, bookingAgentId: value.bookingAgentId, bookingAgentName: value.bookingAgentName, vesselName: value.vesselName, voyageNo: value.voyageNo, @@ -162,6 +195,9 @@ function applyDetail(value: OceanBookingDetail) { parties: value.parties.length ? value.parties.map(({ partyType, partyName, contactName, contactPhone, address }) => ({ partyType, partyName, contactName, contactPhone, address })) : blankParties(), containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ containerType, containerKind, capacityCbm, maxWeightKg, quantity }) => ({ containerType, containerKind, capacityCbm: capacityCbm ?? null, maxWeightKg: maxWeightKg ?? null, quantity: quantity ?? null })) : [createBlankContainerRequirement()], } + selectedChannelName.value = value.channelName || '' + channels.value = [] + if (form.data.warehouseId) void loadChannels(form.data.warehouseId) } async function load() { if (!id.value || isNew.value) return @@ -182,13 +218,14 @@ async function save() { saving.value = true actionError.value = null try { + const payload = { ...form.data } if (isNew.value) { - const created = await createOceanBooking({ expectedVersion: 0, data: form.data }) + const created = await createOceanBooking({ expectedVersion: 0, data: payload }) ElMessage.success('订舱已创建') await router.replace(`/ocean-bookings/${created.id}`) applyDetail(created) } else { - applyDetail(await updateOceanBooking(id.value, { expectedVersion: form.expectedVersion, data: form.data })) + applyDetail(await updateOceanBooking(id.value, { expectedVersion: form.expectedVersion, data: payload })) ElMessage.success('订舱草稿已保存') } } catch (cause) { actionError.value = getApiErrorDetails(cause) } @@ -307,27 +344,26 @@ onMounted(() => { void load(); void loadOptions() }) 重新加载选项
-

基本信息

+

基本信息

- - - + + {{ channel.channelName }}{{ channel.channelCode }}
{{ channelsError }}重新加载
该仓库暂无有效物流渠道,请更换仓库或联系管理员。
-

航线船期

+

航线船期

- +
@@ -336,15 +372,27 @@ onMounted(() => { void load(); void loadOptions() })

条款与柜量

-
- - - - - - 删除 +
+
+ + + + + + + + + + + +
操作箱型箱种容量(m³)限重(kg)数量
+ + + + +
- 新增箱型 + 新增箱型 @@ -396,5 +444,6 @@ onMounted(() => { void load(); void loadOptions() }) diff --git a/src/modules/ocean-bookings/types.ts b/src/modules/ocean-bookings/types.ts index 9de849fb..122185c5 100644 --- a/src/modules/ocean-bookings/types.ts +++ b/src/modules/ocean-bookings/types.ts @@ -5,7 +5,7 @@ export interface OceanBookingContainer { id?: string; containerType: string; qua export interface OceanBookingContainerAllocation extends OceanBookingContainer { allocatedQuantity?: number | null } export interface OceanBookingDraftData { customerId: string; customerName: string; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[] - channelCode: string; channelName: string; warehouseId: string; warehouseName: string + channelCode: string; warehouseId: string; warehouseName: string billOfLadingNo?: string | null shippingCompanyId?: string | null; shippingCompanyName?: string | null; bookingAgentId?: string | null; bookingAgentName?: string | null departurePortCode?: string | null; departurePortName?: string | null; destinationPortCode?: string | null; destinationPortName?: string | null diff --git a/src/shared/api/http.test.ts b/src/shared/api/http.test.ts index 80ec5b03..c20b2b88 100644 --- a/src/shared/api/http.test.ts +++ b/src/shared/api/http.test.ts @@ -66,6 +66,25 @@ describe('API error diagnostics', () => { } }) + it('preserves a backend credential error for an explicit login request', () => { + const error = new ApiResponseError( + '用户名或密码错误', + 'A0401', + undefined, + null, + 'request-login', + 'trace-login', + ) + + expect(getApiErrorDetails(error, '登录失败', { preserveAuthenticationMessage: true })).toEqual({ + message: '用户名或密码错误', + code: 'A0401', + requestId: 'request-login', + traceId: 'trace-login', + }) + expect(getApiErrorDetails(error).message).toBe('登录状态已失效,请重新登录') + }) + it('extracts identifiers from an HTTP error envelope', () => { const error = new axios.AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, { status: 503, diff --git a/src/shared/api/http.ts b/src/shared/api/http.ts index fadad2a1..1a089a71 100644 --- a/src/shared/api/http.ts +++ b/src/shared/api/http.ts @@ -35,6 +35,10 @@ export interface ApiErrorDetails { traceId?: string } +export interface ApiErrorDetailsOptions { + preserveAuthenticationMessage?: boolean +} + export const http = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 15_000, @@ -113,9 +117,13 @@ export function unwrapApiResponse(response: ApiEnvelope): T { return response.data } -export function getApiErrorDetails(error: unknown, fallback = '接口请求失败'): ApiErrorDetails { +export function getApiErrorDetails( + error: unknown, + fallback = '接口请求失败', + options: ApiErrorDetailsOptions = {}, +): ApiErrorDetails { if (error instanceof ApiResponseError) { - const message = error.code === 'A0401' + const message = error.code === 'A0401' && !options.preserveAuthenticationMessage ? '登录状态已失效,请重新登录' : error.code === 'A0403' ? '当前账号无权访问该数据,请联系管理员确认权限'