This commit is contained in:
2026-08-12 12:35:32 +08:00
parent 11e085e30f
commit 4fb8b85c13
22 changed files with 161 additions and 47 deletions
@@ -0,0 +1,39 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const pages = [
['src/modules/container-loading/pages/ContainersView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无货柜"', 'statusText(row.planStatus)', 'platform:firstleg:container:view'],
['src/modules/loading-lists/components/LoadingListsView.vue', ':loading="loading"', 'LoadErrorState', ':empty-text="`暂无符合筛选条件的${title}`"', 'loadingListStatusLabel(row.status)', 'canView'],
['src/modules/ocean-bookings/pages/OceanBookingsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的订舱"', 'oceanBookingStatusLabel(row.status)', 'platform:firstleg:ocean-booking:view'],
['src/modules/port-schedules/pages/PortSchedulesView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合条件的船期"', "row.expired ? '已过期' : '有效'", 'platform:firstleg:port-schedule:view'],
['src/modules/trucking/pages/TruckAppointmentsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的拖车预约"', 'statusLabel(row.status)', 'platform:firstleg:truck-appointment:view'],
['src/modules/customs/pages/CustomsCasesView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无报关协同记录"', 'customsStatusLabel(row.status)', 'platform:firstleg:customs:view'],
['src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合条件的出仓记录"', 'outboundDispatchStatusLabel(row.dispatchStatus)', 'platform:firstleg:outbound-dispatch:view'],
] as const
function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8')
}
describe('domestic outbound list state contracts', () => {
it.each(pages)('%s exposes loading, error, empty, translated status and view-permission states', (path, loading, error, empty, status, viewPermission) => {
const content = source(path)
expect(content).toContain(loading)
expect(content).toContain(error)
expect(content).toContain(empty)
expect(content).toContain(status)
expect(content).toContain(viewPermission)
})
it('keeps list component state when route details return to the cached workspace list', () => {
const layout = source('src/app/layouts/AppLayout.vue')
const oceanDetail = source('src/modules/ocean-bookings/pages/OceanBookingDetailView.vue')
const truckingDetail = source('src/modules/trucking/pages/TruckAppointmentDetailView.vue')
expect(layout).toContain('<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">')
expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute))"')
expect(oceanDetail).toContain("router.push('/ocean-bookings')")
expect(truckingDetail).toContain("router.push({ name: 'trucking' })")
})
})
+2 -1
View File
@@ -172,7 +172,8 @@ describe('LoginView authentication lifecycle', () => {
await wrapper.get('form').trigger('submit') await wrapper.get('form').trigger('submit')
await flushPromises() await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toBe('network unavailable') expect(wrapper.get('[role="alert"]').text()).toContain('登录失败,请检查账号和密码后重试')
expect(wrapper.get('[role="alert"]').text()).not.toContain('network unavailable')
expect(initialize).not.toHaveBeenCalled() expect(initialize).not.toHaveBeenCalled()
expect(replace).not.toHaveBeenCalled() expect(replace).not.toHaveBeenCalled()
}) })
@@ -52,6 +52,20 @@ describe('CustomerBillingRulesPanel', () => {
expect(document.body.textContent).toContain('标准中箱') expect(document.body.textContent).toContain('标准中箱')
}) })
it('hides permission codes in the no-access state', async () => {
const auth = useAuthStore()
auth.permissions = []
useAccessPreviewStore().initializeAfterLogin()
mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(document.body.textContent).toContain('暂无尺寸计算规则查看权限')
expect(document.body.textContent).toContain('请联系管理员申请尺寸计算规则查看权限')
expect(document.body.textContent).not.toContain('firstleg:dimension-config:view')
expect(getCustomerDimensionConfig).not.toHaveBeenCalled()
})
it('submits all four rows in one update request', async () => { it('submits all four rows in one update request', async () => {
mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } }) mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
@@ -183,7 +183,7 @@ onMounted(loadConfig)
<div v-if="!canView || loadError" class="panel-title rule-heading"> <div v-if="!canView || loadError" class="panel-title rule-heading">
<div><h2>按箱规尺寸计算</h2><p>{{ customerName }}{{ customerCode ? `(${customerCode})` : '' }}的尺寸计算规则</p></div> <div><h2>按箱规尺寸计算</h2><p>{{ customerName }}{{ customerCode ? `(${customerCode})` : '' }}的尺寸计算规则</p></div>
</div> </div>
<div v-if="!canView" class="rule-state"><Ruler :size="30" /><strong>暂无尺寸计算规则查看权限</strong><p>需要权限 firstleg:dimension-config:view。</p></div> <div v-if="!canView" class="rule-state"><Ruler :size="30" /><strong>暂无尺寸计算规则查看权限</strong><p>请联系管理员申请尺寸计算规则查看权限。</p></div>
<div v-else-if="loadError" class="rule-error" role="alert"><div><strong>箱规尺寸规则加载失败</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div><el-button type="primary" plain :loading="loading" @click="loadConfig">重新加载</el-button></div> <div v-else-if="loadError" class="rule-error" role="alert"><div><strong>箱规尺寸规则加载失败</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div><el-button type="primary" plain :loading="loading" @click="loadConfig">重新加载</el-button></div>
<section v-else-if="config" class="rule-card"> <section v-else-if="config" class="rule-card">
<header class="rule-card-heading"><h2>按箱规尺寸计算</h2><el-button v-if="canEdit" type="primary" :disabled="loading" @click="openEditor"><Pencil :size="16" />编辑规则</el-button></header> <header class="rule-card-heading"><h2>按箱规尺寸计算</h2><el-button v-if="canEdit" type="primary" :disabled="loading" @click="openEditor"><Pencil :size="16" />编辑规则</el-button></header>
@@ -89,7 +89,7 @@ describe('FeeItemsView public master data and access', () => {
expect(wrapper.find('.list-page-layout__state').exists()).toBe(true) expect(wrapper.find('.list-page-layout__state').exists()).toBe(true)
expect(wrapper.find('.table-panel').exists()).toBe(false) expect(wrapper.find('.table-panel').exists()).toBe(false)
expect(wrapper.text()).toContain('费用项目加载失败') expect(wrapper.text()).toContain('费用项目加载失败')
expect(wrapper.text()).toContain('Gateway unavailable') expect(wrapper.text()).not.toContain('Gateway unavailable')
}) })
it('renders an explicit no-access state without loading customers or fee items', async () => { it('renders an explicit no-access state without loading customers or fee items', async () => {
@@ -143,7 +143,8 @@ describe('InternalUsersView platform internal user management', () => {
vi.mocked(listPlatformManagedUsers).mockRejectedValueOnce(new Error('Gateway unavailable')) vi.mocked(listPlatformManagedUsers).mockRejectedValueOnce(new Error('Gateway unavailable'))
const wrapper = await mountView() const wrapper = await mountView()
expect(wrapper.text()).toContain('Gateway unavailable') expect(wrapper.text()).toContain('内部用户列表加载失败,请稍后重试')
expect(wrapper.text()).not.toContain('Gateway unavailable')
expect(wrapper.text()).toContain('重新加载') expect(wrapper.text()).toContain('重新加载')
expect(wrapper.text()).not.toContain('zhangwei') expect(wrapper.text()).not.toContain('zhangwei')
}) })
@@ -97,7 +97,9 @@ describe('PermissionsView IAM mode', () => {
const wrapper = mountServerView() const wrapper = mountServerView()
await flushPromises() await flushPromises()
expect(wrapper.findAll('[role="alert"]').map((item) => item.text()).join(' ')).toContain('IAM unavailable') const alertText = wrapper.findAll('[role="alert"]').map((item) => item.text()).join(' ')
expect(alertText).toContain('权限资源加载失败,请稍后重试')
expect(alertText).not.toContain('IAM unavailable')
}) })
it('shows only add-child directly for a built-in menu even with all permissions', async () => { it('shows only add-child directly for a built-in menu even with all permissions', async () => {
+3 -1
View File
@@ -113,6 +113,8 @@ describe('RolesView', () => {
const wrapper = mountView(pinia) const wrapper = mountView(pinia)
await flushPromises() await flushPromises()
expect(wrapper.findAll('[role="alert"]').map((item) => item.text()).join(' ')).toContain('IAM unavailable') const alertText = wrapper.findAll('[role="alert"]').map((item) => item.text()).join(' ')
expect(alertText).toContain('平台角色列表加载失败,请稍后重试')
expect(alertText).not.toContain('IAM unavailable')
}) })
}) })
@@ -9,6 +9,7 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import QueryToolbar from '@/shared/components/QueryToolbar.vue' import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue' import DataTable from '@/shared/components/DataTable.vue'
import { listPlatformWarehouses } from '@/modules/warehouses' import { listPlatformWarehouses } from '@/modules/warehouses'
import { useAuthStore } from '@/core/auth/auth'
import { downloadInboundCartonLabels, getPlatformInbound, listPlatformInbounds } from '../core/api' import { downloadInboundCartonLabels, getPlatformInbound, listPlatformInbounds } from '../core/api'
import type { InboundOrderDetail, InboundOrderSummary } from '../core/types' import type { InboundOrderDetail, InboundOrderSummary } from '../core/types'
@@ -73,6 +74,7 @@ describe('InboundOrdersView', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
setActivePinia(createPinia()) setActivePinia(createPinia())
useAuthStore().permissions = ['platform:firstleg:inbound:view']
vi.mocked(listPlatformInbounds).mockResolvedValue({ records: [summary], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(listPlatformInbounds).mockResolvedValue({ records: [summary], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getPlatformInbound).mockResolvedValue(detail) vi.mocked(getPlatformInbound).mockResolvedValue(detail)
vi.mocked(downloadInboundCartonLabels).mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' })) vi.mocked(downloadInboundCartonLabels).mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }))
@@ -207,6 +209,16 @@ describe('InboundOrdersView', () => {
vi.unstubAllGlobals() vi.unstubAllGlobals()
}) })
it('does not expose row operations without the confirmed inbound view permission', async () => {
useAuthStore().permissions = []
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('.row-actions').exists()).toBe(false)
expect(wrapper.text()).toContain('无查看权限')
expect(wrapper.text()).not.toContain('打印箱唛')
})
it('uses the app header as the page title and exposes compact accessible status tabs', async () => { it('uses the app header as the page title and exposes compact accessible status tabs', async () => {
const wrapper = mountPage() const wrapper = mountPage()
await flushPromises() await flushPromises()
@@ -317,6 +329,9 @@ describe('InboundOrdersView', () => {
expect(columnSource).toContain("{ key: 'feeReviewStatus', label: '费用审核状态'") expect(columnSource).toContain("{ key: 'feeReviewStatus', label: '费用审核状态'")
expect(source).toContain('<template #processStatus="{ row }">') expect(source).toContain('<template #processStatus="{ row }">')
expect(source).toContain('<template #feeReviewStatus="{ row }">') expect(source).toContain('<template #feeReviewStatus="{ row }">')
expect(source).toContain("access.can('platform:firstleg:inbound:view')")
expect(source).toContain('preference-key="inbound-orders-table"')
expect(columnSource.match(/defaultVisible: false/g)?.length).toBeGreaterThanOrEqual(25)
expect(toolbarSource).toMatch(/@media \(max-width: 720px\)[\s\S]*width: 100%/) expect(toolbarSource).toMatch(/@media \(max-width: 720px\)[\s\S]*width: 100%/)
}) })
}) })
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { ChevronDown, ChevronUp, ClipboardCheck, Eye, Printer, RefreshCw, SlidersHorizontal } from '@lucide/vue' import { ChevronDown, ChevronUp, ClipboardCheck, Eye, Printer, RefreshCw, SlidersHorizontal } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue' import AppDrawer from '@/shared/components/AppDrawer.vue'
@@ -40,6 +41,7 @@ type FilterModel = QueryModel & {
} }
const router = useRouter() const router = useRouter()
const access = useAccessPreviewStore()
const tab = ref<InboundListTab | ''>('') const tab = ref<InboundListTab | ''>('')
const page = ref(1) const page = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
@@ -133,35 +135,35 @@ const columns: DataTableColumn[] = [
{ key: 'customerName', label: '客户', minWidth: 142, slotName: 'customerName' }, { key: 'customerName', label: '客户', minWidth: 142, slotName: 'customerName' },
{ key: 'warehouseName', label: '仓库', minWidth: 140, slotName: 'warehouseName' }, { key: 'warehouseName', label: '仓库', minWidth: 140, slotName: 'warehouseName' },
{ key: 'deliveryWarehouseName', label: '交货仓', minWidth: 140, slotName: 'deliveryWarehouseName' }, { key: 'deliveryWarehouseName', label: '交货仓', minWidth: 140, slotName: 'deliveryWarehouseName' },
{ key: 'loadingStatus', label: '配载状态', minWidth: 110, slotName: 'loadingStatus' }, { key: 'loadingStatus', label: '配载状态', minWidth: 110, slotName: 'loadingStatus', defaultVisible: false },
{ key: 'holdFlag', label: '是否扣货', width: 96, slotName: 'holdFlag' }, { key: 'holdFlag', label: '是否扣货', width: 96, slotName: 'holdFlag', defaultVisible: false },
{ key: 'currentHoldReason', label: '扣货理由', minWidth: 150, slotName: 'currentHoldReason' }, { key: 'currentHoldReason', label: '扣货理由', minWidth: 150, slotName: 'currentHoldReason', defaultVisible: false },
{ key: 'containerNo', label: '集装箱号', minWidth: 132, slotName: 'containerNo' }, { key: 'containerNo', label: '集装箱号', minWidth: 132, slotName: 'containerNo', defaultVisible: false },
{ key: 'cabinetNo', label: '柜号', minWidth: 118, slotName: 'cabinetNo' }, { key: 'cabinetNo', label: '柜号', minWidth: 118, slotName: 'cabinetNo', defaultVisible: false },
{ key: 'companyName', label: '公司', minWidth: 160, slotName: 'companyName' }, { key: 'companyName', label: '公司', minWidth: 160, slotName: 'companyName', defaultVisible: false },
{ key: 'businessType', label: '业务类型', minWidth: 112, slotName: 'businessType' }, { key: 'businessType', label: '业务类型', minWidth: 112, slotName: 'businessType', defaultVisible: false },
{ key: 'channelName', label: '渠道', minWidth: 138, slotName: 'channelName' }, { key: 'channelName', label: '渠道', minWidth: 138, slotName: 'channelName', defaultVisible: false },
{ key: 'taxMode', label: '买单/退税', minWidth: 108, slotName: 'taxMode' }, { key: 'taxMode', label: '买单/退税', minWidth: 108, slotName: 'taxMode', defaultVisible: false },
{ key: 'containerMode', label: '是否整柜', minWidth: 108, slotName: 'containerMode' }, { key: 'containerMode', label: '是否整柜', minWidth: 108, slotName: 'containerMode', defaultVisible: false },
{ key: 'customsInspection', label: '商检验', width: 92, slotName: 'customsInspection' }, { key: 'customsInspection', label: '商检验', width: 92, slotName: 'customsInspection', defaultVisible: false },
{ key: 'sailingAt', label: '开船时间', minWidth: 164, slotName: 'sailingAt' }, { key: 'sailingAt', label: '开船时间', minWidth: 164, slotName: 'sailingAt', defaultVisible: false },
{ key: 'portArrivalAt', label: '到港时间', minWidth: 164, slotName: 'portArrivalAt' }, { key: 'portArrivalAt', label: '到港时间', minWidth: 164, slotName: 'portArrivalAt', defaultVisible: false },
{ key: 'totalPackageCount', label: '总箱数', width: 90, slotName: 'totalPackageCount' }, { key: 'totalPackageCount', label: '总箱数', width: 90, slotName: 'totalPackageCount', defaultVisible: false },
{ key: 'actualGrossWeightKg', label: '总毛重(T.K.GW)', minWidth: 136, slotName: 'actualGrossWeightKg' }, { key: 'actualGrossWeightKg', label: '总毛重(T.K.GW)', minWidth: 136, slotName: 'actualGrossWeightKg', defaultVisible: false },
{ key: 'chargeableWeightKg', label: '计费毛重', minWidth: 112, slotName: 'chargeableWeightKg' }, { key: 'chargeableWeightKg', label: '计费毛重', minWidth: 112, slotName: 'chargeableWeightKg', defaultVisible: false },
{ key: 'actualVolumeCbm', label: '总体积(m³)', minWidth: 118, slotName: 'actualVolumeCbm' }, { key: 'actualVolumeCbm', label: '总体积(m³)', minWidth: 118, slotName: 'actualVolumeCbm', defaultVisible: false },
{ key: 'chargeableVolumeCbm', label: '计费体积(m³)', minWidth: 128, slotName: 'chargeableVolumeCbm' }, { key: 'chargeableVolumeCbm', label: '计费体积(m³)', minWidth: 128, slotName: 'chargeableVolumeCbm', defaultVisible: false },
{ key: 'palletCount', label: '总托盘数', width: 100, slotName: 'palletCount' }, { key: 'palletCount', label: '总托盘数', width: 100, slotName: 'palletCount', defaultVisible: false },
{ key: 'locationCodes', label: '库位位置', minWidth: 124, slotName: 'locationCodes' }, { key: 'locationCodes', label: '库位位置', minWidth: 124, slotName: 'locationCodes', defaultVisible: false },
{ key: 'volumeWeightKg', label: '总材积重', minWidth: 110, slotName: 'volumeWeightKg' }, { key: 'volumeWeightKg', label: '总材积重', minWidth: 110, slotName: 'volumeWeightKg', defaultVisible: false },
{ key: 'actualNetWeightKg', label: '总净重(T.K.NW)', minWidth: 136, slotName: 'actualNetWeightKg' }, { key: 'actualNetWeightKg', label: '总净重(T.K.NW)', minWidth: 136, slotName: 'actualNetWeightKg', defaultVisible: false },
{ key: 'destinationType', label: '目的仓类型', minWidth: 112, slotName: 'destinationType' }, { key: 'destinationType', label: '目的仓类型', minWidth: 112, slotName: 'destinationType', defaultVisible: false },
{ key: 'destinationName', label: '目的仓', minWidth: 132, slotName: 'destinationName' }, { key: 'destinationName', label: '目的仓', minWidth: 132, slotName: 'destinationName', defaultVisible: false },
{ key: 'measureCompletedAt', label: '测量完成时间', minWidth: 164, slotName: 'measureCompletedAt' }, { key: 'measureCompletedAt', label: '测量完成时间', minWidth: 164, slotName: 'measureCompletedAt', defaultVisible: false },
{ key: 'inboundedAt', label: '入库完成时间', minWidth: 164, slotName: 'inboundedAt' }, { key: 'inboundedAt', label: '入库完成时间', minWidth: 164, slotName: 'inboundedAt', defaultVisible: false },
{ key: 'expressCompanyName', label: '快递公司', minWidth: 120, slotName: 'expressCompanyName' }, { key: 'expressCompanyName', label: '快递公司', minWidth: 120, slotName: 'expressCompanyName', defaultVisible: false },
{ key: 'expressTrackingNo', label: '快递单号', minWidth: 150, slotName: 'expressTrackingNo' }, { key: 'expressTrackingNo', label: '快递单号', minWidth: 150, slotName: 'expressTrackingNo', defaultVisible: false },
{ key: 'createdAt', label: '创建日期', minWidth: 164, slotName: 'createdAt' }, { key: 'createdAt', label: '创建日期', minWidth: 164, slotName: 'createdAt', defaultVisible: false },
] ]
const selectedCustomerName = computed(() => { const selectedCustomerName = computed(() => {
@@ -452,7 +454,7 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
</header> </header>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="inbound-orders-table" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表"> <DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="inbound-orders-table" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
<template #pagination-left>当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</template> <template #pagination-left>当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</template>
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openWorkspace(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button link :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button link @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div></template> <template #actions="{ row }"><div v-if="access.can('platform:firstleg:inbound:view')" class="row-actions"><el-button link type="primary" @click="openWorkspace(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button link :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button link @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div><span v-else class="muted-action">无查看权限</span></template>
<template #inbound="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template> <template #inbound="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
<template #processStatus="{ row }"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag></template> <template #processStatus="{ row }"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag></template>
<template #orderStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).orderStatusSnapshot, inboundOrderStatusLabels) }}</template> <template #orderStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).orderStatusSnapshot, inboundOrderStatusLabels) }}</template>
@@ -5,6 +5,7 @@ import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper }
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth' import { useAuthStore } from '@/core/auth/auth'
import { ApiResponseError } from '@/shared/api/http'
import { import {
confirmWeightResult, confirmWeightResult,
correctWeightResult, correctWeightResult,
@@ -482,7 +483,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
}) })
it('keeps the edited draft available when confirmation fails', async () => { it('keeps the edited draft available when confirmation fails', async () => {
vi.mocked(confirmWeightResult).mockRejectedValueOnce(new Error('version conflict')) vi.mocked(confirmWeightResult).mockRejectedValueOnce(new ApiResponseError('数据版本已变化,请刷新后重试', 'A0409', 409))
const wrapper = mountPanel() const wrapper = mountPanel()
await flushPromises() await flushPromises()
await openWeightDialog(wrapper) await openWeightDialog(wrapper)
@@ -493,7 +494,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
await flushPromises() await flushPromises()
expect((input.element as HTMLInputElement).value).toBe('286.125') expect((input.element as HTMLInputElement).value).toBe('286.125')
expect(wrapper.text()).toContain('version conflict') expect(wrapper.text()).toContain('数据版本已变化,请刷新后重试')
expect(confirmWeightResult).toHaveBeenCalledTimes(1) expect(confirmWeightResult).toHaveBeenCalledTimes(1)
}) })
}) })
@@ -77,6 +77,8 @@ describe('LoadingListsView', () => {
expect(detailSource).not.toContain('[action] || action') expect(detailSource).not.toContain('[action] || action')
}) })
it('uses safe labels when customer or operator names are missing', () => { it('uses safe labels when customer or operator names are missing', () => {
expect(source).toContain("item.customerName || '客户信息缺失'")
expect(source).not.toContain('item.customerName || item.accountId')
expect(detailSource).toContain("item.customerName || '客户信息缺失'") expect(detailSource).toContain("item.customerName || '客户信息缺失'")
expect(detailSource).toContain("history.operatorName || '操作人信息缺失'") expect(detailSource).toContain("history.operatorName || '操作人信息缺失'")
expect(detailSource).not.toContain('item.customerName || item.accountId') expect(detailSource).not.toContain('item.customerName || item.accountId')
@@ -305,7 +305,7 @@ onMounted(() => {
<el-button v-if="canView" link type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button> <el-button v-if="canView" link type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button>
<el-button v-if="isPreload && canEdit && ['EDITING', 'LOADING'].includes(row.status)" link type="primary" @click="openEditFromRow(row)"><Pencil :size="15" />编辑</el-button> <el-button v-if="isPreload && canEdit && ['EDITING', 'LOADING'].includes(row.status)" link type="primary" @click="openEditFromRow(row)"><Pencil :size="15" />编辑</el-button>
</template> </template>
<template #customers="{ row }">{{ (row as PreloadSummary).customers.map((item) => item.customerName || item.accountId).join('、') || '—' }}</template> <template #customers="{ row }">{{ (row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、') || '—' }}</template>
<template #vesselVoyage="{ row }">{{ (row as PreloadSummary).vesselName || '—' }} / {{ (row as PreloadSummary).voyageNo || '—' }}</template> <template #vesselVoyage="{ row }">{{ (row as PreloadSummary).vesselName || '—' }} / {{ (row as PreloadSummary).voyageNo || '—' }}</template>
<template #status="{ row }"><StatusBadge :status="loadingListStatusLabel(row.status)" /></template> <template #status="{ row }"><StatusBadge :status="loadingListStatusLabel(row.status)" /></template>
<template #grossWeight="{ row }">{{ formatNumber(row.totalGrossWeightKg) }}</template> <template #grossWeight="{ row }">{{ formatNumber(row.totalGrossWeightKg) }}</template>
@@ -197,7 +197,7 @@ describe('PartnersView real API integration', () => {
const wrapper = await mountView() const wrapper = await mountView()
expect(wrapper.text()).toContain('往来单位列表暂时无法加载') expect(wrapper.text()).toContain('往来单位列表暂时无法加载')
expect(wrapper.text()).toContain('Gateway unavailable') expect(wrapper.text()).not.toContain('Gateway unavailable')
expect(wrapper.text()).toContain('重新加载') expect(wrapper.text()).toContain('重新加载')
expect(wrapper.text()).not.toContain('深圳示例贸易有限公司') expect(wrapper.text()).not.toContain('深圳示例贸易有限公司')
expect(wrapper.find('.list-page-layout__state').exists()).toBe(true) expect(wrapper.find('.list-page-layout__state').exists()).toBe(true)
@@ -100,7 +100,7 @@ describe('ExpressCompaniesView query and table migration', () => {
expect(wrapper.find('.list-page-layout__state').exists()).toBe(true) expect(wrapper.find('.list-page-layout__state').exists()).toBe(true)
expect(wrapper.find('.table-panel').exists()).toBe(false) expect(wrapper.find('.table-panel').exists()).toBe(false)
expect(wrapper.text()).toContain('快递公司加载失败') expect(wrapper.text()).toContain('快递公司加载失败')
expect(wrapper.text()).toContain('Gateway unavailable') expect(wrapper.text()).not.toContain('Gateway unavailable')
}) })
it('does not request with edited filters until search is submitted', async () => { it('does not request with edited filters until search is submitted', async () => {
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import TruckAppointmentsView from './TruckAppointmentsView.vue' import TruckAppointmentsView from './TruckAppointmentsView.vue'
import { listTruckAppointments } from '../api/platformTrucking' import { listTruckAppointments } from '../api/platformTrucking'
import type { TruckAppointmentResource } from '../types' import type { TruckAppointmentResource } from '../types'
import { useAuthStore } from '@/core/auth/auth'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
@@ -24,6 +25,10 @@ describe('TruckAppointmentsView', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(listTruckAppointments).mockReset().mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(listTruckAppointments).mockReset().mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 })
setActivePinia(createPinia()) setActivePinia(createPinia())
useAuthStore().permissions = [
'platform:firstleg:truck-appointment:view',
'platform:firstleg:truck-appointment:edit',
]
}) })
it('loads the real list and renders relationship snapshots', async () => { it('loads the real list and renders relationship snapshots', async () => {
@@ -58,6 +63,20 @@ describe('TruckAppointmentsView', () => {
expect(source).toContain("appointmentActionLabel('编辑', row as TruckAppointmentResource)") expect(source).toContain("appointmentActionLabel('编辑', row as TruckAppointmentResource)")
}) })
it('does not expose a technical carrier id when the business name is unavailable', async () => {
vi.mocked(listTruckAppointments).mockResolvedValue({
records: [{ ...row, carrierName: '', carrierId: 'carrier-77' }],
page: 1,
size: 20,
total: 1,
pages: 1,
})
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('已关联承运商')
expect(wrapper.text()).not.toContain('carrier-77')
})
it('uses the table toolbar for create and keeps a single empty state', () => { it('uses the table toolbar for create and keeps a single empty state', () => {
expect(source).toContain('<template #toolbar-actions>') expect(source).toContain('<template #toolbar-actions>')
expect(source).not.toContain('<template #toolbar-start>') expect(source).not.toContain('<template #toolbar-start>')
@@ -67,8 +86,16 @@ describe('TruckAppointmentsView', () => {
}) })
it('shows the two frequent row actions directly without a more menu', () => { it('shows the two frequent row actions directly without a more menu', () => {
expect(source).toContain("v-if=\"access.can('platform:firstleg:truck-appointment:view')\"")
expect(source).toContain('<Eye :size="15" />详情') expect(source).toContain('<Eye :size="15" />详情')
expect(source).toContain('<Pencil :size="15" />编辑') expect(source).toContain('<Pencil :size="15" />编辑')
expect(source).not.toContain('MoreHorizontal') expect(source).not.toContain('MoreHorizontal')
}) })
it('hides the detail action when the account lacks the backend view permission', async () => {
useAuthStore().permissions = ['platform:firstleg:truck-appointment:list']
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(false)
})
}) })
@@ -61,11 +61,11 @@ function openDetail(row: TruckAppointmentResource, edit = false) { router.push({
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading"> <section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="trucking-appointments" aria-label="拖车预约列表" empty-text="暂无符合筛选条件的拖车预约"> <DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="trucking-appointments" aria-label="拖车预约列表" empty-text="暂无符合筛选条件的拖车预约">
<template #toolbar-actions><el-button v-if="access.can('platform:firstleg:truck-appointment:create')" type="primary" :disabled="loading" @click="router.push({ name: 'trucking-new' })"><Plus :size="16" />新增拖车</el-button></template> <template #toolbar-actions><el-button v-if="access.can('platform:firstleg:truck-appointment:create')" type="primary" :disabled="loading" @click="router.push({ name: 'trucking-new' })"><Plus :size="16" />新增拖车</el-button></template>
<template #company="{ row }">{{ row.carrierName || row.carrierId || '—' }}</template> <template #company="{ row }">{{ row.carrierName || (row.carrierId ? '已关联承运商' : '—') }}</template>
<template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.driverPhone || '—' }}</small></template> <template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.driverPhone || '—' }}</small></template>
<template #store="{ row }">{{ row.storeName || '—' }}</template> <template #store="{ row }">{{ row.storeName || '—' }}</template>
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template> <template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
<template #actions="{ row }"><div class="table-actions"><el-button link type="primary" :aria-label="appointmentActionLabel('查看', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit')" link type="primary" :aria-label="appointmentActionLabel('编辑', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource, true)"><Pencil :size="15" />编辑</el-button></div></template> <template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:firstleg:truck-appointment:view')" link type="primary" :aria-label="appointmentActionLabel('查看', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit')" link type="primary" :aria-label="appointmentActionLabel('编辑', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource, true)"><Pencil :size="15" />编辑</el-button></div></template>
</DataTable> </DataTable>
</section> </section>
</ListPageLayout> </ListPageLayout>
@@ -3,6 +3,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth' import { useAuthStore } from '@/core/auth/auth'
import { ApiResponseError } from '@/shared/api/http'
import UnifiedOrderWorkflowPanel from './UnifiedOrderWorkflowPanel.vue' import UnifiedOrderWorkflowPanel from './UnifiedOrderWorkflowPanel.vue'
import { import {
getEvidence, getEvidence,
@@ -92,7 +93,7 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
vi.mocked(getPreload) vi.mocked(getPreload)
.mockResolvedValueOnce(preload('REF-OLD', 3)) .mockResolvedValueOnce(preload('REF-OLD', 3))
.mockResolvedValueOnce(preload('REF-SERVER', 4)) .mockResolvedValueOnce(preload('REF-SERVER', 4))
vi.mocked(smartSplit).mockRejectedValue(new Error('数据已被其他人修改,请刷新后重试')) vi.mocked(smartSplit).mockRejectedValue(new ApiResponseError('数据已被其他人修改,请刷新后重试', 'A0409', 409))
const wrapper = mountPanel(['firstleg:inbound:preload-view', 'firstleg:inbound:split']) const wrapper = mountPanel(['firstleg:inbound:preload-view', 'firstleg:inbound:split'])
await flushPromises() await flushPromises()
const save = wrapper.findAll('button').find((button) => button.text().includes('保存分柜清单')) const save = wrapper.findAll('button').find((button) => button.text().includes('保存分柜清单'))
@@ -299,7 +299,7 @@ describe('PalletsView', () => {
const wrapper = await mountView() const wrapper = await mountView()
expect(wrapper.text()).toContain('托盘列表暂时无法加载') expect(wrapper.text()).toContain('托盘列表暂时无法加载')
expect(wrapper.text()).toContain('Gateway unavailable') expect(wrapper.text()).not.toContain('Gateway unavailable')
expect(wrapper.text()).toContain('重新加载') expect(wrapper.text()).toContain('重新加载')
expect(wrapper.text()).not.toContain('TP-001') expect(wrapper.text()).not.toContain('TP-001')
}) })
@@ -243,7 +243,7 @@ describe('WarehousesView', () => {
const wrapper = await mountView() const wrapper = await mountView()
expect(wrapper.text()).toContain('仓库数据加载失败') expect(wrapper.text()).toContain('仓库数据加载失败')
expect(wrapper.text()).toContain('Gateway unavailable') expect(wrapper.text()).not.toContain('Gateway unavailable')
expect(wrapper.text()).toContain('重新查询') expect(wrapper.text()).toContain('重新查询')
expect(wrapper.text()).not.toContain('演示物流园') expect(wrapper.text()).not.toContain('演示物流园')
expect(wrapper.find('.table-panel .data-table').exists()).toBe(false) expect(wrapper.find('.table-panel .data-table').exists()).toBe(false)
+6
View File
@@ -80,6 +80,12 @@ describe('API error diagnostics', () => {
}) })
}) })
it('does not expose local exception text in user-facing diagnostics', () => {
expect(getApiErrorDetails(new Error('Error: socket failed\\n at internal/request.ts:42'))).toEqual({
message: '接口请求失败', code: undefined, requestId: undefined, traceId: undefined,
})
})
it('notifies session expiry once when concurrent 401 requests share a failed refresh', async () => { it('notifies session expiry once when concurrent 401 requests share a failed refresh', async () => {
const refresh = vi.fn(async () => { throw new Error('refresh token expired') }) const refresh = vi.fn(async () => { throw new Error('refresh token expired') })
const expired = vi.fn() const expired = vi.fn()
+2 -1
View File
@@ -134,7 +134,8 @@ export function getApiErrorDetails(error: unknown, fallback = '接口请求失
traceId: response?.traceId || undefined, traceId: response?.traceId || undefined,
} }
} }
return { message: error instanceof Error ? error.message : fallback } // Local/runtime errors are not user-facing API messages and may contain stack details.
return { message: fallback }
} }
export function getApiErrorMessage(error: unknown, fallback = '接口请求失败'): string { export function getApiErrorMessage(error: unknown, fallback = '接口请求失败'): string {