Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
34 changed files with 1352 additions and 159 deletions
Showing only changes of commit 1595fbcae2 - Show all commits
+12
View File
@@ -51,6 +51,18 @@ describe('admin router access metadata', () => {
expect(route?.meta.permission).toBe('platform:express:list')
})
it('registers the global smart-loading route before the single-container deep link', () => {
const route = router.getRoutes().find((item) => item.name === 'global-smart-loading-workbench')
const moduleChildren = router.options.routes.find((item) => item.path === '/')?.children || []
const globalIndex = moduleChildren.findIndex((item) => item.name === 'global-smart-loading-workbench')
const detailIndex = moduleChildren.findIndex((item) => item.name === 'smart-loading-workbench')
expect(route?.path).toBe('/container-loading/smart-loading')
expect(route?.meta.permission).toBe('platform:firstleg:smart-loading:list')
expect(globalIndex).toBeGreaterThanOrEqual(0)
expect(detailIndex).toBeGreaterThan(globalIndex)
})
it('protects counterparty records with a stable master-data permission', () => {
const route = router.getRoutes().find((item) => item.name === 'counterparties')
expect(route?.path).toBe('/master-data/counterparties')
@@ -42,6 +42,7 @@ describe('adminNavigation', () => {
])
expect(adminNavigation.find((item) => item.key === 'container-management')?.children?.map((item) => item.title)).toEqual([
'货柜列表',
'智能装柜',
'预装清单',
'装柜清单',
])
@@ -85,6 +86,7 @@ describe('adminNavigation', () => {
expect(paths).toContain('/downloads')
expect(paths).toContain('/master-data/fee-items')
expect(paths).toContain('/container-loading')
expect(paths).toContain('/container-loading/smart-loading')
expect(paths).toContain('/loading-plans')
expect(paths).toContain('/loading-lists')
expect(paths).not.toContain('/prototype/container-management/container-list')
@@ -150,6 +152,7 @@ describe('adminNavigation', () => {
it('shows each formal container workflow entry only with its matching permission', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:firstleg:container:list',
'platform:firstleg:smart-loading:list',
'platform:firstleg:preload:list',
'platform:firstleg:loading-list:list',
]))
@@ -157,6 +160,7 @@ describe('adminNavigation', () => {
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
'/dashboard',
'/container-loading',
'/container-loading/smart-loading',
'/loading-plans',
'/loading-lists',
])
+1
View File
@@ -97,6 +97,7 @@ export const adminNavigation: AdminNavigationNode[] = [
icon: WarehouseIcon,
children: [
{ key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading', permission: 'platform:firstleg:container:list' },
{ key: 'container-smart-loading', title: '智能装柜', icon: ClipboardList, path: '/container-loading/smart-loading', permission: 'platform:firstleg:smart-loading:list' },
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
{ key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' },
],
+12
View File
@@ -42,6 +42,18 @@ describe('LoginView authentication lifecycle', () => {
expect(loginSource).not.toContain('demo-tip')
})
it('uses the admin design tokens and keeps the brand visible at 320px', () => {
expect(loginSource).toContain('<h1>柏悦物流管理后台</h1>')
expect(loginSource).toContain('class="login-mobile-brand"')
expect(loginSource).toContain('aria-label="平台员工登录"')
expect(loginSource).toContain('autocomplete="username" maxlength="100" autofocus')
expect(loginSource).toContain('aria-live="polite"')
expect(loginSource).toContain('background: var(--cc-surface-muted)')
expect(loginSource).toContain('border-radius: var(--cc-radius-lg)')
expect(loginSource).toContain('@media (max-width: 360px)')
expect(loginSource).not.toContain('让跨境物流管理')
})
it('initializes preview access after login and before redirecting', async () => {
const pinia = createPinia()
setActivePinia(pinia)
+36 -5
View File
@@ -54,8 +54,8 @@ async function submit() {
<section class="login-intro">
<div class="login-brand"><img class="login-brand-logo" :src="baiyueLogoWhite" alt="柏悦物流" /></div>
<div>
<h1>让跨境物流管理<br />清晰、可靠、可追踪</h1>
<p class="intro-copy">统一管理企业入驻、内部权限和头程订单,让每一次操作都有边界、有记录。</p>
<h1>柏悦物流管理后台</h1>
<p class="intro-copy">平台内部运营与管理系统</p>
</div>
<div class="security-note">
<ShieldCheck :size="22" />
@@ -65,12 +65,13 @@ async function submit() {
<section class="login-panel">
<div class="login-card">
<div class="login-mobile-brand"><img :src="baiyueLogoWhite" alt="柏悦物流" /></div>
<div class="login-card-icon"><LockKeyhole :size="24" /></div>
<h2>登录管理后台</h2>
<p>使用平台内部员工账号登录</p>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" @submit.prevent="submit">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" aria-label="平台员工登录" @submit.prevent="submit">
<el-form-item label="账号" prop="account" required>
<el-input v-model.trim="form.account" placeholder="请输入账号" :prefix-icon="UserRound" autocomplete="username" maxlength="100" />
<el-input v-model.trim="form.account" placeholder="请输入账号" :prefix-icon="UserRound" autocomplete="username" maxlength="100" autofocus />
</el-form-item>
<el-form-item label="密码" prop="password" required>
<el-input v-model="form.password" type="password" show-password placeholder="请输入密码" :prefix-icon="LockKeyhole" autocomplete="current-password" maxlength="128" />
@@ -79,8 +80,38 @@ async function submit() {
登录 <ArrowRight :size="17" />
</el-button>
</el-form>
<div v-if="loginError" class="login-error" role="alert"><p>{{ loginError.message }}</p><ApiErrorMeta :details="loginError" /></div>
<div v-if="loginError" class="login-error" role="alert" aria-live="polite"><p>{{ loginError.message }}</p><ApiErrorMeta :details="loginError" /></div>
</div>
</section>
</div>
</template>
<style scoped>
.login-page { min-width: 0; min-height: 100svh; background: var(--cc-surface); color: var(--cc-text); }
.login-intro { min-width: 0; background: var(--cc-navy); }
.login-intro h1 { max-width: 560px; margin: 16px 0 14px; font-size: 40px; line-height: 1.2; overflow-wrap: anywhere; }
.intro-copy { margin: 0; color: #cbd5e1; font-size: 15px; line-height: 1.7; overflow-wrap: anywhere; }
.login-panel { min-width: 0; background: var(--cc-surface-muted); }
.login-card { min-width: 0; padding: 32px; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-lg); background: var(--cc-surface); box-shadow: var(--cc-shadow-md); }
.login-card-icon { border-radius: var(--cc-radius-lg); background: var(--cc-primary-soft); }
.login-card h2, .login-card > p { overflow-wrap: anywhere; }
.login-mobile-brand { display: none; }
.login-submit { min-height: 44px; }
.login-error { min-width: 0; padding: 12px 14px; border: 1px solid #fecdca; border-radius: var(--cc-radius-md); background: var(--cc-danger-soft); color: var(--cc-danger); overflow-wrap: anywhere; }
.login-error :deep(*) { max-width: 100%; overflow-wrap: anywhere; }
.login-card :deep(.el-input__wrapper:focus-within) { box-shadow: 0 0 0 1px var(--cc-primary) inset, 0 0 0 3px var(--cc-primary-soft); }
@media (max-width: 960px) {
.login-panel { align-items: start; padding: 24px; }
.login-card { width: min(440px, 100%); margin: auto; padding: 28px; }
.login-mobile-brand { display: flex; min-height: 52px; align-items: center; margin: -28px -28px 28px; padding: 12px 20px; border-radius: var(--cc-radius-lg) var(--cc-radius-lg) 0 0; background: var(--cc-navy); }
.login-mobile-brand img { display: block; width: 154px; height: 48px; object-fit: contain; object-position: left center; }
}
@media (max-width: 360px) {
.login-panel { padding: 12px; }
.login-card { padding: 20px; }
.login-mobile-brand { margin: -20px -20px 24px; padding-inline: 16px; }
.login-card h2 { font-size: 24px; }
}
</style>
@@ -28,4 +28,33 @@ describe('ContainersView', () => {
expect(wrapper.text()).toContain('宁波 → 长滩')
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
})
it('uses stable view labels when cabinetNo is missing', async () => {
const base = {
id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING' as const, version: 0,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000,
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
vi.mocked(listContainers).mockResolvedValueOnce({
records: [
{ ...base, id: '9002', cabinetNo: undefined, containerNo: 'MSCU-2', bookingNo: 'BK-2' },
{ ...base, id: '9003', cabinetNo: '', containerNo: null, bookingNo: 'BK-3' },
{ ...base, id: '9004', cabinetNo: null, containerNo: null, bookingNo: '' },
],
page: 1, size: 20, total: 3, pages: 1,
} as never)
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: ContainersView }] })
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
const labels = wrapper.findAll('.el-table__body button')
.map((button) => button.attributes('aria-label'))
.filter((label): label is string => Boolean(label))
expect(labels).toEqual(expect.arrayContaining(['查看 MSCU-2', '查看 BK-3', '查看货柜详情']))
expect(labels.every((label) => !/undefined|null/.test(label))).toBe(true)
})
})
@@ -91,6 +91,11 @@ const columns: DataTableColumn[] = [
function optional(value: string | null) { return value?.trim() || null }
function statusText(status: ContainerPlanStatus) { return ({ EDITING: '编辑中', LOADING: '装柜中', COMPLETED: '已完成' })[status] }
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
function containerViewLabel(row: ContainerBookingResource) {
const reference = [row.cabinetNo, row.containerNo, row.bookingNo]
.find((value) => typeof value === 'string' && value.trim())
return reference ? `查看 ${reference}` : '查看货柜详情'
}
function replaceForm(source: ContainerBookingResource | null) {
Object.assign(form, emptyForm(), source || {})
}
@@ -189,7 +194,7 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadRows()]) })
<section v-if="!warehouseError && !loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增货柜</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" :aria-label="`查看 ${row.cabinetNo}`" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canEdit && row.planStatus !== 'COMPLETED'" link type="primary" @click="openEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="canSmartLoading && row.planStatus === 'EDITING'" link type="primary" @click="goWorkbench(row)"><Sparkles :size="15" />排柜</el-button></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canEdit && row.planStatus !== 'COMPLETED'" link type="primary" @click="openEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="canSmartLoading && row.planStatus === 'EDITING'" link type="primary" @click="goWorkbench(row)"><Sparkles :size="15" />排柜</el-button></div></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>{{ row.bookingNo }}</small></template>
<template #containerNo="{ row }">{{ row.containerNo || '待录入' }}</template>
<template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template>
@@ -0,0 +1,98 @@
import ElementPlus from 'element-plus'
import { AxiosError } from 'axios'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
import {
getSmartLoadingPlanByBooking,
listContainers,
listSmartLoadingCandidates,
recommendSmartLoading,
} from '../api/platformContainerLoading'
vi.mock('@/core/access/accessPreview', () => ({
useAccessPreviewStore: () => ({ can: () => true }),
}))
vi.mock('../api/platformContainerLoading', () => ({
listContainers: vi.fn(),
getSmartLoadingPlanByBooking: vi.fn(),
listSmartLoadingCandidates: vi.fn(),
createSmartLoadingPlan: vi.fn(),
assignSmartLoadingItem: vi.fn(),
recommendSmartLoading: vi.fn(),
adjustSmartLoadingItem: vi.fn(),
removeSmartLoadingItem: vi.fn(),
pushSmartLoadingPreload: vi.fn(),
}))
const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 28000,
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
const plan = {
id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2,
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1 }],
}
describe('GlobalSmartLoadingView', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 })
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan)
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', inboundDetailId: '6', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsName: '鞋类', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }])
})
it('loads real containers, candidates and the selected plan into the three-column workbench', async () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(listContainers).toHaveBeenCalledWith({ page: 1, size: 100 })
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.warehouseId)
expect(wrapper.text()).toContain('候选订单与货物')
expect(wrapper.text()).toContain('ORD-2')
expect(wrapper.text()).toContain('CB-1 装柜方案')
expect(wrapper.text()).toContain('LP-99')
expect(wrapper.text()).toContain('ORD-1')
})
it('keeps unsupported prototype filters out of the real workbench', async () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).not.toContain('税率')
expect(wrapper.text()).not.toContain('是否退税')
expect(wrapper.text()).not.toContain('美森正班')
})
it('refreshes the latest plan after A0409 without losing the selected container', async () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, version: 3 })
vi.mocked(recommendSmartLoading).mockRejectedValue(new AxiosError(
'conflict',
'ERR_BAD_REQUEST',
undefined,
undefined,
{ data: { code: 'A0409', message: '数据已被修改', requestId: 'request-1', traceId: 'trace-1' }, status: 200, statusText: 'OK', headers: {}, config: {} as never },
))
const button = wrapper.findAll('button').find((item) => item.text().includes('智能推荐'))
expect(button).toBeDefined()
await button!.trigger('click')
await flushPromises()
expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2)
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('版本 3')
expect(wrapper.text()).toContain('已刷新最新版本')
expect(wrapper.text()).toContain('request-1')
})
})
@@ -0,0 +1,476 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustSmartLoadingItem,
assignSmartLoadingItem,
createSmartLoadingPlan,
getSmartLoadingPlanByBooking,
listContainers,
listSmartLoadingCandidates,
pushSmartLoadingPreload,
recommendSmartLoading,
removeSmartLoadingItem,
} from '../api/platformContainerLoading'
import type {
ContainerBookingResource,
ContainerPlanStatus,
SmartLoadingCandidate,
SmartLoadingItem,
SmartLoadingPlan,
} from '../types'
const access = useAccessPreviewStore()
const containers = ref<ContainerBookingResource[]>([])
const selectedContainerId = ref('')
const plan = ref<SmartLoadingPlan | null>(null)
const candidates = ref<SmartLoadingCandidate[]>([])
const loading = ref(false)
const selectionLoading = ref(false)
const mutating = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const selectionError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const noDraft = ref(false)
const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const query = ref<QueryModel>({ orderKeyword: '', containerKeyword: '', warehouseId: '', status: '' })
const appliedQuery = ref<QueryModel>({ ...query.value })
let selectionRequest = 0
const canCreate = computed(() => access.can('platform:firstleg:smart-loading:create'))
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
const selectedContainer = computed(() => containers.value.find((item) => item.id === selectedContainerId.value) || null)
const editable = computed(() => plan.value?.status === 'EDITING')
const assignedDetailIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundDetailId)))
const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
const volumeLimit = computed(() => Number(selectedContainer.value?.capacityCbm || 0) * 1.1)
const weightLimit = computed(() => Number(selectedContainer.value?.maxWeightKg || 0) * 1.1)
const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0)
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
const warehouseOptions = computed(() => {
const values = new Map<string, string>()
for (const item of containers.value) values.set(item.warehouseId, item.warehouseName || `仓库 ${item.warehouseId}`)
return [...values].map(([value, label]) => ({ value, label }))
})
const queryFields = computed<QueryField[]>(() => [
{ key: 'orderKeyword', type: 'input', label: '订单或货物', placeholder: '订单号、入库单、客户、货物', width: 250 },
{ key: 'containerKeyword', type: 'input', label: '柜次', placeholder: '柜次号、集装箱号、目的仓', width: 230 },
{ key: 'warehouseId', type: 'select', label: '装柜仓库', options: warehouseOptions.value, width: 190 },
{ key: 'status', type: 'select', label: '柜次状态', width: 160, options: [
{ label: '可编辑', value: 'EDITING' },
{ label: '已推送预装', value: 'LOADING' },
{ label: '已完成', value: 'COMPLETED' },
] },
])
const filteredContainers = computed(() => {
const keyword = String(appliedQuery.value.containerKeyword || '').trim().toLowerCase()
const warehouseId = String(appliedQuery.value.warehouseId || '')
const status = String(appliedQuery.value.status || '')
return containers.value.filter((item) => {
if (warehouseId && item.warehouseId !== warehouseId) return false
if (status && item.planStatus !== status) return false
if (!keyword) return true
return [item.cabinetNo, item.containerNo, item.bookingNo, item.destinationWarehouseCode, item.destinationWarehouseName]
.some((value) => String(value || '').toLowerCase().includes(keyword))
})
})
const filteredCandidates = computed(() => {
const keyword = String(appliedQuery.value.orderKeyword || '').trim().toLowerCase()
if (!keyword) return candidates.value
return candidates.value.filter((item) => [item.orderNo, item.inboundCode, item.customerName, item.goodsName]
.some((value) => String(value || '').toLowerCase().includes(keyword)))
})
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单/入库单', minWidth: 170, slotName: 'order' },
{ key: 'goodsName', prop: 'goodsName', label: '货物', minWidth: 150 },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 100 },
{ key: 'unitVolumeCbm', label: '单箱体积', width: 105, slotName: 'unitVolume' },
{ key: 'unitWeightKg', label: '单箱重量', width: 105, slotName: 'unitWeight' },
{ key: 'destination', label: '目的仓', minWidth: 130, slotName: 'destination' },
]
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单/入库单', minWidth: 170, slotName: 'order' },
{ key: 'goods', label: '货物', minWidth: 160, slotName: 'goods' },
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
{ key: 'volumeCbm', label: '体积', width: 105, slotName: 'volume' },
{ key: 'grossWeightKg', label: '毛重', width: 110, slotName: 'weight' },
{ key: 'destination', label: '目的仓', minWidth: 130, slotName: 'destination' },
]
function formatNumber(value: number, digits = 3) {
return Number.isFinite(value) ? value.toFixed(digits) : '0.000'
}
function candidateRow(source: object) { return source as SmartLoadingCandidate }
function itemRow(source: object) { return source as SmartLoadingItem }
function statusLabel(status: ContainerPlanStatus) {
return ({ EDITING: '可编辑', LOADING: '已推送预装', COMPLETED: '已完成' } as const)[status]
}
function statusType(status: ContainerPlanStatus) {
return status === 'EDITING' ? 'primary' : status === 'LOADING' ? 'warning' : 'success'
}
function routeMatches(source: object) {
const candidate = candidateRow(source)
const target = selectedContainer.value?.destinationWarehouseCode
return !target || target === candidate.destinationWarehouseCode
}
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
for (const item of next.items || []) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
}
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
plan.value = next
noDraft.value = false
syncDrafts(next, preserved)
const container = selectedContainer.value
if (container) {
container.planId = next.id
container.planStatus = next.status
}
}
function clearSelectionData() {
plan.value = null
candidates.value = []
noDraft.value = false
selectionError.value = null
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
}
async function loadSelectedData(preserved: Record<string, number> = {}) {
const container = selectedContainer.value
const request = ++selectionRequest
clearSelectionData()
if (!container) return
selectionLoading.value = true
try {
const [candidateResult, planResult] = await Promise.all([
listSmartLoadingCandidates(container.warehouseId),
getSmartLoadingPlanByBooking(container.id).then((value) => ({ value, missing: false })).catch((error) => {
const details = getApiErrorDetails(error)
if (details.code === 'A0404') return { value: null, missing: true }
throw error
}),
])
if (request !== selectionRequest) return
candidates.value = candidateResult
if (planResult.value) acceptPlan(planResult.value, preserved)
else noDraft.value = planResult.missing
} catch (error) {
if (request === selectionRequest) selectionError.value = getApiErrorDetails(error, '柜次装柜数据加载失败')
} finally {
if (request === selectionRequest) selectionLoading.value = false
}
}
async function load() {
loading.value = true
loadError.value = null
try {
const firstPage = await listContainers({ page: 1, size: 100 })
const remainingPages = firstPage.pages > 1
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 })))
: []
containers.value = [firstPage, ...remainingPages].flatMap((page) => page.records)
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) selectedContainerId.value = filteredContainers.value[0]?.id || ''
await loadSelectedData()
} catch (error) {
loadError.value = getApiErrorDetails(error, '智能装柜工作台加载失败')
} finally {
loading.value = false
}
}
async function selectContainer(containerId: string) {
if (selectedContainerId.value === containerId) return
selectedContainerId.value = containerId
mutationError.value = null
await loadSelectedData()
}
async function applyFilters(model: QueryModel) {
appliedQuery.value = { ...model }
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) {
selectedContainerId.value = filteredContainers.value[0]?.id || ''
await loadSelectedData()
}
}
async function createPlan() {
const container = selectedContainer.value
if (!container) return
await mutate(async () => acceptPlan(await createSmartLoadingPlan(container.id, null)), '创建装柜计划失败')
}
function openAssign(source: object) {
const candidate = candidateRow(source)
selectedCandidate.value = candidate
assignForm.packageCount = Math.min(candidate.availablePackageCount, 1)
assignOpen.value = true
}
async function assign() {
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
await mutate(async () => {
acceptPlan(await assignSmartLoadingItem(plan.value!.id, {
inboundId: selectedCandidate.value!.inboundId,
inboundDetailId: selectedCandidate.value!.inboundDetailId,
packageCount: assignForm.packageCount,
expectedVersion: plan.value!.version,
}))
assignOpen.value = false
ElMessage.success('货物已加入当前柜次')
}, '分配货物失败', { preserveAssign: true })
}
async function recommend() {
if (!plan.value) return
await mutate(async () => {
acceptPlan(await recommendSmartLoading(plan.value!.id, plan.value!.version))
ElMessage.success('智能推荐已生成')
}, '智能推荐失败')
}
async function adjust(source: object) {
const item = itemRow(source)
if (!plan.value) return
const count = Number(quantityDrafts[item.id])
if (!Number.isInteger(count) || count < 1) { ElMessage.warning('分配箱数必须为正整数'); return }
await mutate(async () => {
acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version))
ElMessage.success('箱数已调整')
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
}
async function remove(source: object) {
const item = itemRow(source)
if (!plan.value) return
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除“${item.productNameZh || item.orderNo || item.id}”吗?`, '移除装柜明细', {
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
acceptPlan(await removeSmartLoadingItem(plan.value!.id, item.id, plan.value!.version, item.version))
ElMessage.success('装柜明细已移除')
}, '移除装柜明细失败')
}
async function pushPreload() {
if (!plan.value) return
const confirmed = await ElMessageBox.confirm('推送后当前方案进入装柜流程,不能继续调整。确认推送预装清单?', '推送预装清单', {
type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
acceptPlan(await pushSmartLoadingPreload(plan.value!.id, plan.value!.version))
ElMessage.success('已推送预装清单')
}, '推送预装清单失败')
}
async function mutate(
action: () => Promise<void>,
fallback: string,
recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean } = {},
) {
mutating.value = true
mutationError.value = null
try {
await action()
} catch (error) {
const details = getApiErrorDetails(error, fallback)
if (details.code === 'A0409') {
await loadSelectedData(recovery.preserveDrafts)
mutationError.value = { ...details, message: '方案已被其他人修改,已刷新最新版本并保留本次输入,请重新确认后提交' }
if (recovery.preserveAssign) assignOpen.value = true
ElMessage.warning(mutationError.value.message)
} else {
mutationError.value = details
ElMessage.error(details.message)
}
} finally {
mutating.value = false
}
}
onMounted(load)
</script>
<template>
<AdminPage>
<section class="global-loading-page">
<QueryToolbar
v-model="query"
ariaLabel="智能装柜筛选"
:fields="queryFields"
preference-key="global-smart-loading-query"
:search-on-reset="false"
@search="applyFilters"
@reset="applyFilters"
/>
<LoadErrorState v-if="loadError" title="智能装柜工作台加载失败" :message="loadError.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<template v-else>
<LoadErrorState v-if="mutationError" title="装柜操作失败" :message="mutationError.message" compact retry-text="刷新当前柜次" @retry="loadSelectedData()">
<template #details><ApiErrorMeta :details="mutationError" /></template>
</LoadErrorState>
<section class="summary-band" aria-label="当前装柜汇总">
<div><span>候选货物</span><strong>{{ filteredCandidates.length }}</strong></div>
<div><span>可见柜次</span><strong>{{ filteredContainers.length }}</strong></div>
<div><span>当前已装箱数</span><strong>{{ totalPackages }}</strong></div>
<div><span>当前总体积</span><strong>{{ formatNumber(totalVolume) }} m³</strong></div>
<div><span>当前总重量</span><strong>{{ formatNumber(totalWeight, 1) }} kg</strong></div>
</section>
<section class="dispatch-grid" aria-label="智能装柜调度台">
<section class="dispatch-pane candidate-pane">
<header class="pane-header"><div><strong>候选订单与货物</strong><small>{{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
<DataTable
:data="filteredCandidates"
:columns="candidateColumns"
:loading="loading || selectionLoading || mutating"
preference-key="global-smart-loading-candidates"
:empty-text="selectedContainer ? '当前仓库暂无可装货物' : '暂无可用柜次'"
>
<template #actions="{ row }">
<el-button
v-if="canAssign && editable"
link
type="primary"
:disabled="!routeMatches(row) || assignedDetailIds.has(row.inboundDetailId) || row.availablePackageCount < 1"
@click="openAssign(row)"
><Plus :size="15" />加入</el-button>
</template>
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template>
<template #unitWeight="{ row }">{{ formatNumber(Number(row.unitWeightKg || 0), 1) }} kg</template>
<template #destination="{ row }">
<el-tag :type="routeMatches(row) ? 'success' : 'danger'">{{ routeMatches(row) ? '匹配' : '不匹配' }}</el-tag>
<small>{{ row.destinationWarehouseName || row.destinationWarehouseCode || '未设置' }}</small>
</template>
</DataTable>
</section>
<aside class="dispatch-pane container-pane" aria-label="货柜列表">
<header class="pane-header"><div><strong>货柜</strong><small>可用、装柜中与已完成</small></div></header>
<div v-loading="loading" class="container-list">
<el-empty v-if="!filteredContainers.length" description="暂无符合条件的货柜" :image-size="72" />
<button
v-for="item in filteredContainers"
:key="item.id"
type="button"
class="container-option"
:class="{ selected: item.id === selectedContainerId }"
@click="selectContainer(item.id)"
>
<span class="container-option__top"><strong>{{ item.cabinetNo }}</strong><el-tag size="small" :type="statusType(item.planStatus)">{{ statusLabel(item.planStatus) }}</el-tag></span>
<span>{{ item.containerNo || '集装箱号待录入' }} · {{ item.containerType }}</span>
<span>{{ item.warehouseName || '仓库未设置' }}</span>
<span>目的仓 {{ item.destinationWarehouseName || item.destinationWarehouseCode || '不限' }}</span>
<span v-if="item.id === selectedContainerId && plan">体积 {{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</span>
<span v-else>容量 {{ formatNumber(Number(item.capacityCbm || 0)) }} m³ · {{ formatNumber(Number(item.maxWeightKg || 0), 0) }} kg</span>
</button>
</div>
</aside>
<section class="dispatch-pane plan-pane">
<header class="pane-header plan-header">
<div><strong>{{ selectedContainer?.cabinetNo || '当前柜次' }} 装柜方案</strong><small>{{ plan ? `${plan.planNo} · 版本 ${plan.version}` : '尚未创建方案' }}</small></div>
<div class="plan-actions">
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedData()"><RefreshCw :size="16" /></el-button></el-tooltip>
<el-button v-if="canAssign && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
<el-button v-if="canPush && editable" type="primary" :disabled="!plan?.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
</div>
</header>
<LoadErrorState v-if="selectionError" title="当前柜次加载失败" :message="selectionError.message" compact @retry="loadSelectedData()">
<template #details><ApiErrorMeta :details="selectionError" /></template>
</LoadErrorState>
<el-empty v-else-if="!selectedContainer" description="请选择一个货柜" />
<el-empty v-else-if="noDraft" description="当前货柜尚未创建装柜方案">
<el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建装柜方案</el-button>
</el-empty>
<template v-else-if="plan">
<div class="capacity-grid">
<div><div class="meter-title"><span>体积</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(volumePercent, 100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div>
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(weightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
</div>
<DataTable :data="plan.items" :columns="itemColumns" :loading="selectionLoading || mutating" preference-key="global-smart-loading-items" empty-text="当前柜次暂无已装货物">
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" link type="primary" :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable" link type="danger" @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="1" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
<template #destination="{ row }">{{ row.destinationWarehouseName || row.destinationWarehouseCode || '—' }}</template>
</DataTable>
</template>
</section>
</section>
<AppDialog v-model="assignOpen" title="加入当前柜次" :confirm-loading="mutating" confirm-text="确认加入" @confirm="assign">
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.goodsName || '未命名货物' }}</strong></p>
<p>{{ selectedCandidate.orderNo || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p>
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form>
</div>
</AppDialog>
</template>
</section>
</AdminPage>
</template>
<style scoped>
.global-loading-page { display: grid; gap: 16px; min-width: 0; }
.summary-band { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
.summary-band > div { display: grid; gap: 4px; padding: 12px 14px; background: #fff; }
.summary-band span,.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: 12px; }
.summary-band strong { font-size: 16px; }
.dispatch-grid { display: grid; grid-template-columns: minmax(390px, 1.1fr) 270px minmax(460px, 1.35fr); min-height: 620px; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
.dispatch-pane { display: flex; min-width: 0; flex-direction: column; }
.dispatch-pane + .dispatch-pane { border-left: 1px solid var(--cc-border); }
.pane-header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--cc-border); background: #fff; }
.pane-header > div:first-child { display: grid; min-width: 0; gap: 3px; }
.candidate-pane :deep(.data-table),.plan-pane :deep(.data-table) { min-height: 0; flex: 1; }
.candidate-pane :deep(.el-table),.plan-pane :deep(.el-table) { height: 100%; }
.container-pane { background: #fafafa; }
.container-list { display: grid; align-content: start; gap: 8px; padding: 10px; overflow-y: auto; }
.container-option { display: grid; width: 100%; min-height: 118px; gap: 5px; padding: 10px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; color: var(--cc-text); text-align: left; cursor: pointer; }
.container-option:hover,.container-option:focus-visible { border-color: var(--el-color-primary); outline: none; }
.container-option.selected { border-color: var(--el-color-primary); box-shadow: inset 3px 0 0 var(--el-color-primary); background: var(--el-color-primary-light-9); }
.container-option__top { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--cc-text) !important; }
.plan-header { flex-wrap: wrap; }
.plan-actions,.row-actions,.meter-title { display: flex; align-items: center; gap: 8px; }
.plan-actions,.row-actions { flex-wrap: wrap; }
.plan-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.capacity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; border-bottom: 1px solid var(--cc-border); }
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
.plan-pane :deep(.el-input-number) { width: 120px; }
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
@media (max-width: 1180px) {
.dispatch-grid { grid-template-columns: 1fr; overflow: visible; }
.dispatch-pane + .dispatch-pane { border-top: 1px solid var(--cc-border); border-left: 0; }
.container-list { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); max-height: 360px; }
}
@media (max-width: 720px) {
.summary-band,.capacity-grid { grid-template-columns: 1fr; }
.pane-header,.plan-actions,.meter-title { align-items: stretch; flex-direction: column; }
.plan-actions :deep(.el-button) { width: 100%; min-height: 40px; margin-left: 0; }
.container-list { grid-template-columns: 1fr; }
}
</style>
+6
View File
@@ -7,6 +7,12 @@ export const containerLoadingRoutes = [
component: () => import('./pages/ContainersView.vue'),
meta: { title: '货柜与排柜', description: '维护货柜资料并进入智能排柜工作台', permission: 'platform:firstleg:container:list' },
},
{
path: 'container-loading/smart-loading',
name: 'global-smart-loading-workbench',
component: () => import('./pages/GlobalSmartLoadingView.vue'),
meta: { title: '智能装柜', description: '统一调度候选货物、货柜与装柜方案', permission: 'platform:firstleg:smart-loading:list' },
},
{
path: 'container-loading/:containerId/smart-loading',
name: 'smart-loading-workbench',
@@ -101,7 +101,7 @@ function tableButtonByText(text: string) {
}
async function clickMoreAction(text: string) {
document.querySelector<HTMLButtonElement>('.el-table__body button[aria-label="更多客户操作"]')?.click()
document.querySelector<HTMLButtonElement>('.el-table__body button[aria-label^="更多客户操作:"]')?.click()
await flushPromises()
const item = [...document.querySelectorAll<HTMLElement>('.el-dropdown-menu__item')]
.find((candidate) => candidate.textContent?.trim() === text && !candidate.classList.contains('is-disabled'))
@@ -455,7 +455,8 @@ describe('CustomersView', () => {
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
await clickMoreAction('重置密码')
tableButtonByText('重置密码')?.click()
await flushPromises()
const password = document.querySelector<HTMLInputElement>('input[autocomplete="new-password"]')
const confirmation = document.querySelector<HTMLInputElement>('input[placeholder="请再次输入新密码"]')
@@ -479,11 +480,10 @@ describe('CustomersView', () => {
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(archivePlatformTenant).mockResolvedValue({ ...fullTenant, status: 'DISABLED' })
const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const dropdowns = wrapper.findAllComponents({ name: 'ElDropdown' })
expect(dropdowns.length).toBeGreaterThan(0)
dropdowns.at(-1)!.vm.$emit('command', 'delete')
expect(document.querySelector('.el-table__body button[aria-label^="更多客户操作:"]')).toBeNull()
tableButtonByText('删除')?.click()
await flushPromises()
const confirmation = document.querySelector<HTMLInputElement>('input[placeholder="TEN8"]')
@@ -519,9 +519,53 @@ describe('CustomersView', () => {
expect(tableButtonByText('详情')).toBeDefined()
expect(tableButtonByText('编辑')).toBeDefined()
expect(document.querySelector('.el-table__body button[aria-label="更多客户操作"]')).toBeTruthy()
expect(tableButtonByText('重置密码')).toBeUndefined()
await clickMoreAction('重置密码')
expect(document.querySelector('input[autocomplete="new-password"]')).toBeTruthy()
expect(document.querySelector('.el-table__body button[aria-label="更多客户操作:柏悦物流"]')).toBeTruthy()
expect(tableButtonByText('重置密码')).toBeDefined()
expect(tableButtonByText('跳转登录')).toBeUndefined()
await clickMoreAction('跳转登录')
expect(document.body.textContent).toContain('代登录原因')
})
it('uses stable unique customer identities for multiple more-action buttons', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete']
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(listPlatformTenants).mockResolvedValue({
records: [
{ ...fullTenant, id: '8', tenantName: '客户甲', tenantCode: 'TEN-A', status: 'ACTIVE' },
{ ...fullTenant, id: '9', tenantName: '', tenantCode: 'TEN-B', status: 'ACTIVE' },
{ ...fullTenant, id: '10', tenantName: '', tenantCode: '', status: 'ACTIVE' },
],
page: 1, size: 10, total: 3, pages: 1,
})
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const buttons = [...document.querySelectorAll<HTMLButtonElement>('.el-table__body button[aria-label^="更多客户操作:"]')]
expect(buttons.map((button) => button.getAttribute('aria-label'))).toEqual([
'更多客户操作:客户甲',
'更多客户操作:TEN-B',
'更多客户操作:10',
])
expect(buttons.map((button) => button.getAttribute('title'))).toEqual(buttons.map((button) => button.getAttribute('aria-label')))
})
it('falls back to the current customer when all row identifiers are missing', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete']
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(listPlatformTenants).mockResolvedValue({
records: [{ ...fullTenant, id: null, tenantName: '', tenantCode: '', status: 'ACTIVE' } as unknown as PlatformTenantResource],
page: 1, size: 10, total: 1, pages: 1,
})
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const button = document.querySelector<HTMLButtonElement>('.el-table__body button[aria-label^="更多客户操作:"]')
expect(button?.getAttribute('aria-label')).toBe('更多客户操作:当前客户')
expect(button?.getAttribute('title')).toBe('更多客户操作:当前客户')
expect(button?.getAttribute('aria-label')).not.toMatch(/undefined|null/)
})
})
+60 -10
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Building2, CheckCircle2, Download, Eye, FileText, FileUp, KeyRound, LogIn, MoreHorizontal, Pencil, Plus, Power, Save, ShieldCheck, ShieldX, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
@@ -633,8 +633,60 @@ function canChangeStatus(row: PlatformTenantResource) {
function canArchive() {
return access.can('platform:customer:delete')
}
function hasMoreActions(row: PlatformTenantResource) {
return canResetPassword(row) || canImpersonate(row) || canChangeStatus(row) || canArchive()
type CustomerRowAction = 'reset-password' | 'impersonate' | 'status' | 'delete'
const customerRowActionIcons: Record<CustomerRowAction, Component> = {
'reset-password': KeyRound,
impersonate: LogIn,
status: Power,
delete: Trash2,
}
function hasReviewAction(row: PlatformTenantResource) {
return pendingReviewByTenantId.value.has(String(row.id))
&& access.can('platform:customer:review')
&& (access.can('platform:customer:approve') || access.can('platform:customer:reject'))
}
function customerPrimaryActionCount(row: PlatformTenantResource) {
return Number(access.can('platform:customer:view'))
+ Number(access.can('platform:customer:view') && access.can('platform:customer:edit'))
+ Number(hasReviewAction(row))
}
function customerSecondaryActions(row: PlatformTenantResource): CustomerRowAction[] {
return [
...(canResetPassword(row) ? ['reset-password' as const] : []),
...(canImpersonate(row) ? ['impersonate' as const] : []),
...(canChangeStatus(row) ? ['status' as const] : []),
...(canArchive() ? ['delete' as const] : []),
]
}
function directCustomerActions(row: PlatformTenantResource) {
return customerSecondaryActions(row).slice(0, Math.max(0, 3 - customerPrimaryActionCount(row)))
}
function moreCustomerActions(row: PlatformTenantResource) {
return customerSecondaryActions(row).slice(Math.max(0, 3 - customerPrimaryActionCount(row)))
}
function customerMoreActionsLabel(row: PlatformTenantResource) {
const compatibleTenantId = (row as PlatformTenantResource & { tenantId?: unknown }).tenantId
const identifier = [row.tenantName, row.tenantCode, row.id, compatibleTenantId]
.map((value) => String(value ?? '').trim())
.find(Boolean)
return `更多客户操作:${identifier || '当前客户'}`
}
function customerActionLabel(row: PlatformTenantResource, action: CustomerRowAction) {
if (action === 'reset-password') return '重置密码'
if (action === 'impersonate') return '跳转登录'
if (action === 'status') return row.status === 'ACTIVE' ? '停用' : '启用'
return '删除'
}
function customerActionType(row: PlatformTenantResource, action: CustomerRowAction) {
if (action === 'delete' || (action === 'status' && row.status === 'ACTIVE')) return 'danger'
if (action === 'status') return 'success'
return 'primary'
}
function customerActionDisabled(action: CustomerRowAction) {
if (action === 'reset-password') return passwordSubmitting.value
if (action === 'impersonate') return impersonationSubmitting.value
if (action === 'delete') return archiveSubmitting.value
return false
}
function handleMoreAction(row: PlatformTenantResource, command: string) {
if (command === 'reset-password') openResetPassword(row)
@@ -917,15 +969,13 @@ onMounted(() => { void loadCustomers() })
<div class="table-actions">
<el-button v-if="access.can('platform:customer:view')" link type="primary" @click="showDetail(row as PlatformTenantResource)"><Eye :size="15" />详情</el-button>
<el-button v-if="access.can('platform:customer:view') && access.can('platform:customer:edit')" link type="primary" @click="openEdit(row as PlatformTenantResource)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="pendingReviewByTenantId.has(String((row as PlatformTenantResource).id)) && access.can('platform:customer:review') && (access.can('platform:customer:approve') || access.can('platform:customer:reject'))" link type="warning" :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, access.can('platform:customer:approve') ? 'approve' : 'reject')"><ShieldCheck :size="15" />审核</el-button>
<el-dropdown v-if="hasMoreActions(row as PlatformTenantResource)" trigger="click" :disabled="rowActionId === String((row as PlatformTenantResource).id)" popper-class="customer-action-menu" @command="handleMoreAction(row as PlatformTenantResource, String($event))">
<el-button link type="primary" aria-label="更多客户操作" title="更多操作"><MoreHorizontal :size="16" /></el-button>
<el-button v-if="hasReviewAction(row as PlatformTenantResource)" link type="warning" :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, access.can('platform:customer:approve') ? 'approve' : 'reject')"><ShieldCheck :size="15" />审核</el-button>
<el-button v-for="action in directCustomerActions(row as PlatformTenantResource)" :key="action" link :type="customerActionType(row as PlatformTenantResource, action)" :disabled="customerActionDisabled(action)" @click="handleMoreAction(row as PlatformTenantResource, action)"><component :is="customerRowActionIcons[action]" :size="15" />{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-button>
<el-dropdown v-if="moreCustomerActions(row as PlatformTenantResource).length" trigger="click" :disabled="rowActionId === String((row as PlatformTenantResource).id)" popper-class="customer-action-menu" @command="handleMoreAction(row as PlatformTenantResource, String($event))">
<el-button link type="primary" :aria-label="customerMoreActionsLabel(row as PlatformTenantResource)" :title="customerMoreActionsLabel(row as PlatformTenantResource)"><MoreHorizontal :size="16" /></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="canResetPassword(row as PlatformTenantResource)" command="reset-password" :disabled="passwordSubmitting"><KeyRound :size="15" />重置密码</el-dropdown-item>
<el-dropdown-item v-if="canImpersonate(row as PlatformTenantResource)" command="impersonate" :disabled="impersonationSubmitting"><LogIn :size="15" />跳转登录</el-dropdown-item>
<el-dropdown-item v-if="canChangeStatus(row as PlatformTenantResource)" command="status"><Power :size="15" />{{ (row as PlatformTenantResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-dropdown-item>
<el-dropdown-item v-if="canArchive()" command="delete" :disabled="archiveSubmitting" divided class="customer-action-menu__danger"><Trash2 :size="15" />删除</el-dropdown-item>
<el-dropdown-item v-for="action in moreCustomerActions(row as PlatformTenantResource)" :key="action" :command="action" :disabled="customerActionDisabled(action)" :divided="action === 'delete'" :class="{ 'customer-action-menu__danger': action === 'delete' }"><component :is="customerRowActionIcons[action]" :size="15" />{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -25,13 +25,36 @@ describe('PlatformFirstLegOrdersPanel review queue', () => {
expect(source).toContain('await nextTick()')
})
it('keeps the fixed action column compact by grouping secondary actions', () => {
it('shows up to three row actions directly and overflows only the fourth action onward', () => {
expect(source).toContain('class="table-actions order-row-actions"')
expect(source).toContain('更多订单操作')
expect(source).toContain('directSecondaryActionCount')
expect(source).toContain('Math.max(0, 3 - primaryActionCount)')
expect(source).toContain("isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'express')")
expect(source).toContain("isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'note')")
expect(source).toContain("isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'cancel')")
expect(source).toContain('v-if="hasOverflowActions(row as PlatformFirstLegOrderResource)"')
expect(source).toContain(':aria-label="moreOrderActionsLabel(row as PlatformFirstLegOrderResource)"')
expect(source).toContain(':title="moreOrderActionsLabel(row as PlatformFirstLegOrderResource)"')
expect(source).toContain('维护快递')
expect(source).toContain('内部备注')
expect(source).toContain('取消订单')
expect(source).toContain("minWidth: 224, fixed: 'left'")
expect(source).toContain("minWidth: 288, fixed: 'left'")
})
it('distinguishes overflow action buttons by order and keeps a stable missing-field fallback', () => {
expect(source).toContain('function moreOrderActionsLabel(order: PlatformFirstLegOrderResource)')
expect(source).toContain('[compatibleOrder.orderNo, compatibleOrder.id, compatibleOrder.orderId]')
expect(source).toContain("identifier || '当前订单'")
expect(source).not.toContain('aria-label="更多订单操作"')
})
it('uses structured list regions so toolbar and table keep one layout gap', () => {
expect(source).toContain('<ListPageLayout class="first-leg-order-list"')
expect(source).toContain('<template #toolbar>')
expect(source).toContain('<template v-if="tenantLoadError || loadError" #state>')
expect(source).toContain('<template #default>')
expect(source).toContain('<section v-if="!loadError" class="surface-panel table-panel">')
expect(source).toContain('.first-leg-order-list { gap: 16px; }')
})
it('prioritizes order status and removes noisy customer and time metadata', () => {
@@ -2,7 +2,7 @@
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { ArrowLeft, CircleX, Eye, FilePenLine, MoreHorizontal, PackageCheck } from '@lucide/vue'
import { ArrowLeft, CircleCheckBig, CircleX, Eye, FilePenLine, MoreHorizontal, PackageCheck } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
acceptPlatformFirstLegOrder,
@@ -97,7 +97,7 @@ const queryFields = computed<QueryField[]>(() => [
])
const orderColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', minWidth: 224, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', minWidth: 288, fixed: 'left', slotName: 'actions' },
{ key: 'status', label: '状态', width: 108, slotName: 'status' },
{ key: 'orderInfo', label: '订单信息', minWidth: 210, slotName: 'orderInfo' },
{ key: 'customer', label: '客户', minWidth: 190, showOverflowTooltip: true, slotName: 'customer' },
@@ -202,8 +202,41 @@ function canMaintainExpress(order: PlatformFirstLegOrderResource) {
return can('platform:order:fulfillment') && !['DRAFT', 'REJECTED', 'CANCELLED'].includes(order.status)
}
function hasSecondaryActions(order: PlatformFirstLegOrderResource) {
return canCancel(order) || canMaintainExpress(order) || can('platform:order:note')
type SecondaryAction = 'express' | 'note' | 'cancel'
function secondaryActions(order: PlatformFirstLegOrderResource): SecondaryAction[] {
const actions: SecondaryAction[] = []
if (canMaintainExpress(order)) actions.push('express')
if (can('platform:order:note')) actions.push('note')
if (canCancel(order)) actions.push('cancel')
return actions
}
function directSecondaryActionCount(order: PlatformFirstLegOrderResource) {
const primaryActionCount = actionLabel(order) ? 2 : 1
return Math.max(0, 3 - primaryActionCount)
}
function isDirectSecondaryAction(order: PlatformFirstLegOrderResource, action: SecondaryAction) {
const index = secondaryActions(order).indexOf(action)
return index >= 0 && index < directSecondaryActionCount(order)
}
function isOverflowSecondaryAction(order: PlatformFirstLegOrderResource, action: SecondaryAction) {
const index = secondaryActions(order).indexOf(action)
return index >= directSecondaryActionCount(order)
}
function hasOverflowActions(order: PlatformFirstLegOrderResource) {
return secondaryActions(order).length > directSecondaryActionCount(order)
}
function moreOrderActionsLabel(order: PlatformFirstLegOrderResource) {
const compatibleOrder = order as PlatformFirstLegOrderResource & { orderId?: string | number | null }
const identifier = [compatibleOrder.orderNo, compatibleOrder.id, compatibleOrder.orderId]
.map((value) => String(value ?? '').trim())
.find(Boolean)
return `更多订单操作:${identifier || '当前订单'}`
}
function runSecondaryAction(command: string | number | object, order: PlatformFirstLegOrderResource) {
@@ -511,7 +544,7 @@ onMounted(() => void loadTenants())
</template>
<template v-else>
<ListPageLayout ariaLabel="头程订单列表">
<ListPageLayout class="first-leg-order-list" ariaLabel="头程订单列表">
<template #toolbar>
<QueryToolbar
v-model="filters"
@@ -525,18 +558,18 @@ onMounted(() => void loadTenants())
/>
</template>
<el-alert v-if="tenantLoadError" type="warning" :closable="false" show-icon role="alert">
<template #title><div class="api-error-alert-title"><span>{{ tenantLoadError.message }}</span><ApiErrorMeta :details="tenantLoadError" /></div></template>
<el-button link type="warning" @click="loadTenants">重新加载企业列表</el-button>
</el-alert>
<template v-if="loadError" #state>
<el-alert type="error" :closable="false" show-icon role="alert">
<template v-if="tenantLoadError || loadError" #state>
<el-alert v-if="tenantLoadError" type="warning" :closable="false" show-icon role="alert">
<template #title><div class="api-error-alert-title"><span>{{ tenantLoadError.message }}</span><ApiErrorMeta :details="tenantLoadError" /></div></template>
<el-button link type="warning" @click="loadTenants">重新加载企业列表</el-button>
</el-alert>
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div></template>
<el-button link type="danger" @click="loadOrders">重新加载</el-button>
</el-alert>
</template>
<template #default>
<section v-if="!loadError" class="surface-panel table-panel">
<DataTable
v-model:page="page"
@@ -554,14 +587,17 @@ onMounted(() => void loadTenants())
<template #actions="{ row }">
<div class="table-actions order-row-actions">
<el-button link type="primary" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="openDetail(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button>
<el-button v-if="actionLabel(row as PlatformFirstLegOrderResource)" link type="success" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="runLifecycleAction(row as PlatformFirstLegOrderResource)">{{ actionLabel(row as PlatformFirstLegOrderResource) }}</el-button>
<el-dropdown v-if="hasSecondaryActions(row as PlatformFirstLegOrderResource)" trigger="click" popper-class="first-leg-action-menu" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @command="runSecondaryAction($event, row as PlatformFirstLegOrderResource)">
<el-button link type="primary" aria-label="更多订单操作" title="更多订单操作"><MoreHorizontal :size="17" /></el-button>
<el-button v-if="actionLabel(row as PlatformFirstLegOrderResource)" link type="success" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="runLifecycleAction(row as PlatformFirstLegOrderResource)"><CircleCheckBig :size="15" />{{ actionLabel(row as PlatformFirstLegOrderResource) }}</el-button>
<el-button v-if="isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'express')" link type="primary" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="openExpress(row as PlatformFirstLegOrderResource)"><PackageCheck :size="15" />维护快递</el-button>
<el-button v-if="isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'note')" link type="primary" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="openNote(row as PlatformFirstLegOrderResource)"><FilePenLine :size="15" />内部备注</el-button>
<el-button v-if="isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'cancel')" link type="danger" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="cancelOrder(row as PlatformFirstLegOrderResource)"><CircleX :size="15" />取消订单</el-button>
<el-dropdown v-if="hasOverflowActions(row as PlatformFirstLegOrderResource)" trigger="click" popper-class="first-leg-action-menu" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @command="runSecondaryAction($event, row as PlatformFirstLegOrderResource)">
<el-button link type="primary" :aria-label="moreOrderActionsLabel(row as PlatformFirstLegOrderResource)" :title="moreOrderActionsLabel(row as PlatformFirstLegOrderResource)"><MoreHorizontal :size="17" /></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="canMaintainExpress(row as PlatformFirstLegOrderResource)" command="express"><PackageCheck :size="15" />维护快递</el-dropdown-item>
<el-dropdown-item v-if="can('platform:order:note')" command="note"><FilePenLine :size="15" />内部备注</el-dropdown-item>
<el-dropdown-item v-if="canCancel(row as PlatformFirstLegOrderResource)" command="cancel" divided><CircleX :size="15" />取消订单</el-dropdown-item>
<el-dropdown-item v-if="isOverflowSecondaryAction(row as PlatformFirstLegOrderResource, 'express')" command="express"><PackageCheck :size="15" />维护快递</el-dropdown-item>
<el-dropdown-item v-if="isOverflowSecondaryAction(row as PlatformFirstLegOrderResource, 'note')" command="note"><FilePenLine :size="15" />内部备注</el-dropdown-item>
<el-dropdown-item v-if="isOverflowSecondaryAction(row as PlatformFirstLegOrderResource, 'cancel')" command="cancel" divided><CircleX :size="15" />取消订单</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -576,6 +612,7 @@ onMounted(() => void loadTenants())
<template #express="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).expressCompany || '未维护' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).expressTrackingNo || '—' }}</span></div></template>
</DataTable>
</section>
</template>
</ListPageLayout>
</template>
@@ -595,6 +632,7 @@ onMounted(() => void loadTenants())
<style scoped>
.detail-page { overflow: hidden; }
.first-leg-order-list { gap: 16px; }
.detail-page-header { display: flex; min-height: 88px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border); }
.detail-page-header h1 { margin: 0; font-size: 20px; line-height: 1.35; }
.detail-page-header p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
+126 -5
View File
@@ -18,7 +18,7 @@ const permission: PlatformPermissionResource = {
tenantId: 0,
appCode: 'PLATFORM',
permissionType: 'MENU',
parentId: 5100000,
parentId: 0,
permissionCode: 'platform:customer:list',
permissionName: '企业客户',
routePath: '/customers',
@@ -27,12 +27,20 @@ const permission: PlatformPermissionResource = {
status: 'ACTIVE',
}
function mountServerView() {
const allActionPermissions = [
'platform:permission:list',
'platform:permission:create',
'platform:permission:edit',
'platform:permission:status',
'platform:permission:delete',
]
function mountServerView(permissionCodes = ['platform:permission:list', 'platform:permission:create']) {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.isServerSession = true
auth.permissions = ['platform:permission:list', 'platform:permission:create']
auth.permissions = permissionCodes
return shallowMount(PermissionsView, {
global: {
plugins: [pinia],
@@ -43,12 +51,18 @@ function mountServerView() {
FormSection: { template: '<section><slot /></section>' },
AppDrawer: { template: '<section><slot /><slot name="footer" /></section>' },
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<slot name="details" /></div>' },
DataTable: { props: ['data'], template: '<section><slot name="toolbar-actions" /><span data-testid="row-count">{{ data.length }}</span></section>' },
DataTable: {
props: ['data'],
template: '<section><slot name="toolbar-actions" /><div v-for="row in data" :key="row.id" class="row-actions" :data-row-id="row.id"><slot name="actions" :row="row" /></div><span data-testid="row-count">{{ data.length }}</span></section>',
},
'el-alert': { props: ['title'], template: '<div role="alert">{{ title }}<slot name="title" /></div>' },
'el-input': true,
'el-option': true,
'el-select': true,
'el-button': true,
'el-button': { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
'el-dropdown': { template: '<div class="dropdown"><slot /><slot name="dropdown" /></div>' },
'el-dropdown-menu': { template: '<div><slot /></div>' },
'el-dropdown-item': { props: ['disabled'], template: '<button class="dropdown-item" :disabled="disabled"><slot /></button>' },
'el-table': true,
'el-table-column': true,
'el-input-number': true,
@@ -85,4 +99,111 @@ describe('PermissionsView IAM mode', () => {
expect(wrapper.findAll('[role="alert"]').map((item) => item.text()).join(' ')).toContain('IAM unavailable')
})
it('shows only add-child directly for a built-in menu even with all permissions', async () => {
vi.mocked(listPlatformPermissions).mockResolvedValue([{ ...permission, builtIn: true }])
const wrapper = mountServerView(allActionPermissions)
await flushPromises()
const actions = wrapper.get('[data-row-id="5100010"]')
expect(actions.text()).toContain('添加下级')
expect(actions.text()).toContain('系统内置')
expect(actions.text()).not.toContain('编辑')
expect(actions.text()).not.toContain('停用')
expect(actions.text()).not.toContain('删除')
expect(actions.find('.dropdown').exists()).toBe(false)
})
it('keeps three frequent actions direct and moves delete into more for a custom menu', async () => {
vi.mocked(listPlatformPermissions).mockResolvedValue([{ ...permission, builtIn: false }])
const wrapper = mountServerView(allActionPermissions)
await flushPromises()
const actions = wrapper.get('[data-row-id="5100010"]')
expect(actions.text()).toContain('添加下级')
expect(actions.text()).toContain('编辑')
expect(actions.text()).toContain('停用')
expect(actions.get('.dropdown').text()).toContain('删除')
expect(actions.get('.dropdown-item').classes()).toContain('danger-menu-item')
expect(actions.get('[aria-label="企业客户的更多操作"]').attributes('title')).toBe('企业客户的更多操作')
})
it('shows edit, status, and delete directly for a custom non-menu resource', async () => {
vi.mocked(listPlatformPermissions).mockResolvedValue([{
...permission,
permissionType: 'BUTTON',
permissionCode: 'platform:customer:edit',
permissionName: '编辑企业客户',
routePath: null,
builtIn: false,
}])
const wrapper = mountServerView(allActionPermissions)
await flushPromises()
const actions = wrapper.get('[data-row-id="5100010"]')
expect(actions.text()).not.toContain('添加下级')
expect(actions.text()).toContain('编辑')
expect(actions.text()).toContain('停用')
expect(actions.text()).toContain('删除')
expect(actions.find('.dropdown').exists()).toBe(false)
})
it('shows all three actually available menu actions directly when permissions are reduced', async () => {
vi.mocked(listPlatformPermissions).mockResolvedValue([{ ...permission, builtIn: false }])
const wrapper = mountServerView([
'platform:permission:list',
'platform:permission:create',
'platform:permission:edit',
'platform:permission:delete',
])
await flushPromises()
const actions = wrapper.get('[data-row-id="5100010"]')
expect(actions.text()).toContain('添加下级')
expect(actions.text()).toContain('编辑')
expect(actions.text()).toContain('删除')
expect(actions.text()).not.toContain('停用')
expect(actions.find('.dropdown').exists()).toBe(false)
})
it('falls back from a missing name and code to the row id for the more-action label', async () => {
vi.mocked(listPlatformPermissions).mockResolvedValue([{
...permission,
permissionName: '',
permissionCode: '',
builtIn: false,
}])
const wrapper = mountServerView(allActionPermissions)
await flushPromises()
const moreButton = wrapper.get('[data-row-id="5100010"] [aria-label="5100010的更多操作"]')
expect(moreButton.attributes('title')).toBe('5100010的更多操作')
expect(moreButton.attributes('aria-label')).not.toMatch(/undefined|null/)
})
it('gives more-action buttons distinct accessible names across rows', async () => {
vi.mocked(listPlatformPermissions).mockResolvedValue([
{ ...permission, builtIn: false },
{
...permission,
id: 5100020,
permissionCode: 'platform:role:list',
permissionName: '角色管理',
routePath: '/roles',
builtIn: false,
},
])
const wrapper = mountServerView(allActionPermissions)
await flushPromises()
const labels = wrapper.findAll('[aria-label$="的更多操作"]').map((button) => button.attributes('aria-label'))
expect(labels).toEqual(['企业客户的更多操作', '角色管理的更多操作'])
expect(new Set(labels).size).toBe(labels.length)
})
})
+49 -6
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Braces, FolderTree, Pencil, Plus, Power, ShieldCheck, Trash2 } from '@lucide/vue'
import { Braces, FolderTree, MoreHorizontal, Pencil, Plus, Power, ShieldCheck, Trash2 } from '@lucide/vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
@@ -231,6 +231,37 @@ async function removePermission(row: Permission) {
}
function typeClass(type: PermissionType) { return { '目录': 'directory', '菜单': 'menu', '按钮': 'action', '接口': 'api' }[type] }
function canAddChild(row: Permission) {
return canCreate.value && row.type === '菜单'
}
function canEditRow(row: Permission) {
return canEdit.value && !row.builtIn
}
function canChangeStatusRow(row: Permission) {
return canChangeStatus.value && !row.builtIn
}
function canDeleteRow(row: Permission) {
return canDelete.value && !row.builtIn
}
function permissionActionCount(row: Permission) {
return Number(canAddChild(row))
+ Number(canEditRow(row))
+ Number(canChangeStatusRow(row))
+ Number(canDeleteRow(row))
}
function showDeleteDirect(row: Permission) {
return canDeleteRow(row) && permissionActionCount(row) <= 3
}
function showDeleteMore(row: Permission) {
return canDeleteRow(row) && permissionActionCount(row) > 3
}
function moreActionsLabel(row: Permission) {
const resourceLabel = [row.name, row.code, row.id]
.find((value) => typeof value === 'string' && value.trim())
?.trim() || '当前权限'
return `${resourceLabel}的更多操作`
}
onMounted(loadPermissions)
</script>
@@ -246,8 +277,22 @@ onMounted(loadPermissions)
<section v-if="!loadError" class="surface-panel table-panel">
<DataTable :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" preference-key="permissions-table" :loading="loading" :total="treeRows.length" row-key="id" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
<template #toolbar-actions><div class="permission-tree-heading-actions"><div class="permission-tree-tools"><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></div></template>
<template #actions="{ row }"><div v-if="canCreate || canEdit || canChangeStatus || canDelete" class="table-actions"><el-button v-if="canCreate && (row as Permission).type === '菜单'" link type="primary" @click="openCreate((row as Permission).id)"><Plus :size="15" />添加下级</el-button><el-button v-if="canEdit" link type="primary" :disabled="(row as Permission).builtIn || rowActionId === (row as Permission).id" @click="openEdit(row as Permission)"><Pencil :size="15" />编辑</el-button><el-button v-if="canChangeStatus" link :type="(row as Permission).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Permission).id" :disabled="(row as Permission).builtIn" @click="toggleStatus(row as Permission)"><Power :size="15" />{{ (row as Permission).status === '启用' ? '停用' : '启用' }}</el-button><el-button v-if="canDelete" link type="danger" :disabled="(row as Permission).builtIn || rowActionId === (row as Permission).id" @click="removePermission(row as Permission)"><Trash2 :size="15" />删除</el-button><span v-if="(row as Permission).builtIn" class="secondary-text">系统内置</span></div><span v-else class="secondary-text">仅查看</span></template>
<template #toolbar-start><div class="permission-tree-tools"><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div></template>
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></template>
<template #actions="{ row }">
<div v-if="permissionActionCount(row as Permission) > 0 || (row as Permission).builtIn" class="table-actions">
<el-button v-if="canAddChild(row as Permission)" link type="primary" @click="openCreate((row as Permission).id)"><Plus :size="15" />添加下级</el-button>
<el-button v-if="canEditRow(row as Permission)" link type="primary" :disabled="rowActionId === (row as Permission).id" @click="openEdit(row as Permission)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="canChangeStatusRow(row as Permission)" link :type="(row as Permission).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Permission).id" @click="toggleStatus(row as Permission)"><Power :size="15" />{{ (row as Permission).status === '启用' ? '停用' : '启用' }}</el-button>
<el-button v-if="showDeleteDirect(row as Permission)" link type="danger" :disabled="rowActionId === (row as Permission).id" @click="removePermission(row as Permission)"><Trash2 :size="15" />删除</el-button>
<el-dropdown v-if="showDeleteMore(row as Permission)" trigger="click" @command="(command: string) => command === 'delete' && removePermission(row as Permission)">
<el-button link :aria-label="moreActionsLabel(row as Permission)" :title="moreActionsLabel(row as Permission)"><MoreHorizontal :size="16" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item command="delete" :disabled="rowActionId === (row as Permission).id" class="danger-menu-item"><Trash2 :size="15" />删除</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
<span v-if="(row as Permission).builtIn" class="secondary-text">系统内置</span>
</div>
<span v-else class="secondary-text">仅查看</span>
</template>
<template #name="{ row }"><div class="permission-name"><span :class="`is-${typeClass((row as Permission).type)}`"><FolderTree v-if="(row as Permission).type === '目录' || (row as Permission).type === '菜单'" :size="16" /><ShieldCheck v-else-if="(row as Permission).type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><div><strong>{{ (row as Permission).name }}</strong><small v-if="(row as PermissionTreeNode).children?.length">{{ permissionSummary(row as PermissionTreeNode) }}</small></div></div></template>
<template #code="{ row }"><code class="code-value">{{ (row as Permission).code }}</code></template>
<template #endpoint="{ row }"><div class="endpoint-cell"><span v-if="(row as Permission).method !== '-'">{{ (row as Permission).method }}</span><code>{{ (row as Permission).path || '-' }}</code></div></template>
@@ -277,16 +322,14 @@ onMounted(loadPermissions)
<style scoped>
.permission-tree-tools { display: flex; align-items: center; gap: 16px; }
.permission-tree-heading-actions { display: flex; align-items: center; gap: 12px; }
.tree-view-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.tree-view-actions .el-button + .el-button { margin-left: 0; }
.permission-name > div { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
.permission-name small { color: var(--cc-muted); font-size: 11px; line-height: 1.35; }
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
@media (max-width: 720px) {
.permission-tree-heading-actions { width: 100%; align-items: flex-start; flex-direction: column; gap: 8px; }
.permission-tree-tools { width: 100%; align-items: flex-start; flex-direction: column; gap: 8px; }
.permission-tree-heading-actions > .el-button { min-height: 40px; }
}
@media (max-width: 420px) {
+2 -2
View File
@@ -5,13 +5,13 @@ export const iamRoutes = [
path: 'system/users',
name: 'users',
component: () => import('@/modules/iam/pages/InternalUsersView.vue'),
meta: { title: '用户管理', description: '统一管理平台和企业租户用户、状态及角色', permission: 'platform:user:list' },
meta: { title: '用户管理', description: '统一管理平台内部用户、状态及角色', permission: 'platform:user:list' },
},
{
path: 'system/roles',
name: 'roles',
component: () => import('@/modules/iam/pages/RolesView.vue'),
meta: { title: '角色管理', description: '维护平台角色并配置菜单及操作权限', permission: 'platform:role:list' },
meta: { title: '角色权限', description: '维护平台角色并配置菜单及操作权限', permission: 'platform:role:list' },
},
{
path: 'system/permissions',
@@ -210,7 +210,8 @@ describe('InboundOrdersView', () => {
expect(toolbarRows[1].classList.contains('inbound-stats-row')).toBe(true)
expect(toolbarRows).toHaveLength(2)
expect(wrapper.findComponent(DataTable).find('.data-table__pagination-left').text()).toContain('当前显示 1 条 · 共 1 条')
expect(wrapper.findComponent(DataTable).find('.data-table__toolbar-start').text()).not.toContain('入库单列表')
const dataTableToolbarStart = wrapper.findComponent(DataTable).find('.data-table__toolbar-start')
expect(dataTableToolbarStart.exists() ? dataTableToolbarStart.text() : '').not.toContain('入库单列表')
expect(wrapper.text()).not.toContain('刷新')
expect(wrapper.text()).not.toContain('全部入库单')
expect(tabButtons[0].attributes('aria-selected')).toBe('true')
@@ -43,11 +43,11 @@ const detail = {
describe('platform loading lists API', () => {
beforeEach(() => vi.restoreAllMocks())
it('queries preload plans with required warehouse and fixed LOADING status', async () => {
it('queries preload plans across all warehouses with fixed LOADING status', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: { records: [preloadSummary], page: '1', size: '20', total: '1', pages: '1' } })
const result = await listPreloadPlans({ page: 1, size: 20, warehouseId: '300', keyword: 'PL' })
const result = await listPreloadPlans({ page: 1, size: 20, keyword: 'PL' })
expect(get).toHaveBeenCalledWith('/platform/v1/first-leg/loading-plans', { params: { page: 1, size: 20, warehouseId: '300', keyword: 'PL', status: 'LOADING' } })
expect(get).toHaveBeenCalledWith('/platform/v1/first-leg/loading-plans', { params: { page: 1, size: 20, keyword: 'PL', status: 'LOADING' } })
expect(result.total).toBe(1)
expect(result.records[0]?.planId).toBe('1900000000000000001')
expect(result.records[0]?.customers[0]?.accountId).toBe('1900000000000000002')
@@ -76,10 +76,11 @@ describe('platform loading lists API', () => {
.mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [confirmed], page: '1', size: '20', total: '1', pages: '1' } })
.mockResolvedValueOnce({ code: '200', message: 'ok', data: { ...detail, summary: confirmed } })
const page = await listConfirmedLoadingLists({ page: 1, size: 20, warehouseId: '300' })
const page = await listConfirmedLoadingLists({ page: 1, size: 20 })
const loaded = await getConfirmedLoadingList('100', '300')
expect(get.mock.calls[0]?.[0]).toBe('/platform/v1/first-leg/loading-lists')
expect(get.mock.calls[0]?.[1]).toEqual({ params: { page: 1, size: 20 } })
expect(get.mock.calls[1]?.[0]).toBe('/platform/v1/first-leg/loading-lists/100')
expect(page.records[0]?.createdBy).toBe('9')
expect(loaded.summary.status).toBe('COMPLETED')
@@ -110,7 +110,7 @@ export interface LoadingListPage<T extends LoadingListSummary = LoadingListSumma
export interface LoadingListQuery {
page: number
size: number
warehouseId: string
warehouseId?: string
keyword?: string
createdFrom?: string
createdTo?: string
@@ -13,10 +13,11 @@ describe('LoadingListsView', () => {
expect(source.indexOf("key: 'actions'", source.indexOf("key: 'actions'") + 1)).toBeLessThan(source.indexOf("key: 'loadingListNo'"))
})
it('requires a warehouse before sending a list request', () => {
expect(source).toContain("if (!warehouseId) return null")
expect(source).toContain("ElMessage.warning('请先选择仓库')")
expect(source).toContain("'请先选择仓库后查询'")
it('queries all warehouses when the warehouse filter is empty', () => {
expect(source).toContain("placeholder: loadingWarehouses.value ? '仓库加载中' : '全部仓库'")
expect(source).toContain('warehouseId: warehouseId || undefined')
expect(source).not.toContain("ElMessage.warning('请先选择仓库')")
expect(source).not.toContain("'请先选择仓库后查询'")
})
it('uses the shared query, table, drawer, dialog and error components', () => {
@@ -58,13 +58,11 @@ const viewPermission = computed(() => isPreload.value ? 'platform:firstleg:prelo
const canList = computed(() => access.can(listPermission.value))
const canView = computed(() => access.can(viewPermission.value))
const canEdit = computed(() => isPreload.value && access.can('platform:firstleg:preload:edit'))
const hasWarehouse = computed(() => Boolean(String(query.warehouseId || '').trim()))
const fields = computed<QueryField[]>(() => [
{
key: 'warehouseId', type: 'select', label: '仓库', placeholder: loadingWarehouses.value ? '仓库加载中' : '请选择仓库(必选)', width: 220,
key: 'warehouseId', type: 'select', label: '仓库', placeholder: loadingWarehouses.value ? '仓库加载中' : '全部仓库', width: 220,
options: warehouses.value.map((warehouse) => ({ label: warehouse.name, value: warehouse.id })),
props: { loading: loadingWarehouses.value, 'aria-label': '仓库(必选)' },
props: { loading: loadingWarehouses.value, clearable: true, 'aria-label': '仓库' },
},
{ key: 'keyword', type: 'input', label: '关键词', placeholder: isPreload.value ? '预装单号/集装箱号/柜号' : '装柜清单号/集装箱号/柜号', width: 250 },
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 260 },
@@ -117,14 +115,13 @@ function formatNumber(value?: number | null, digits = 3) {
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: digits })
}
function toQuery(): LoadingListQuery | null {
function toQuery(): LoadingListQuery {
const warehouseId = String(query.warehouseId || '').trim()
if (!warehouseId) return null
const range = Array.isArray(query.createdRange) ? query.createdRange.map(String) : []
return {
page: page.value,
size: pageSize.value,
warehouseId,
warehouseId: warehouseId || undefined,
keyword: String(query.keyword || '').trim() || undefined,
createdFrom: range[0] ? `${range[0]}T00:00:00` : undefined,
createdTo: range[1] ? `${range[1]}T23:59:59` : undefined,
@@ -146,12 +143,6 @@ async function loadWarehouses() {
async function load() {
if (!canList.value) return
const request = toQuery()
if (!request) {
rows.value = []
total.value = 0
error.value = null
return
}
loading.value = true
error.value = null
try {
@@ -166,21 +157,13 @@ async function load() {
}
function search() {
if (!hasWarehouse.value) {
rows.value = []
total.value = 0
ElMessage.warning('请先选择仓库')
return
}
if (page.value !== 1) page.value = 1
else void load()
}
function reset() {
page.value = 1
rows.value = []
total.value = 0
error.value = null
void load()
}
async function loadDetail() {
@@ -240,8 +223,11 @@ async function saveTransport() {
}
}
watch([page, pageSize], () => { if (hasWarehouse.value) void load() })
onMounted(() => void loadWarehouses())
watch([page, pageSize], () => void load())
onMounted(() => {
void loadWarehouses()
void load()
})
</script>
<template>
@@ -277,7 +263,7 @@ onMounted(() => void loadWarehouses())
:total="total"
:preference-key="`loading-lists-${mode}`"
row-key="planId"
:empty-text="hasWarehouse ? `暂无符合筛选条件的${title}` : '请先选择仓库后查询'"
:empty-text="`暂无符合筛选条件的${title}`"
:aria-label="title"
>
<template #actions="{ row }"><el-button v-if="canView" link type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
+1 -1
View File
@@ -17,7 +17,7 @@ export const loadingListRoutes = [
component: () => import('./pages/ConfirmedLoadingListsView.vue'),
meta: {
title: '装柜清单',
description: '按仓库查看已完成且永久只读的装柜清单',
description: '查看全部仓库已完成且永久只读的装柜清单',
permission: 'platform:firstleg:loading-list:list',
},
},
@@ -1,3 +1,5 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
@@ -12,6 +14,8 @@ import { useAuthStore } from '@/core/auth/auth'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import PartnersView from './PartnersView.vue'
const source = readFileSync(join(process.cwd(), 'src/modules/master-data/counterparties/pages/PartnersView.vue'), 'utf8')
vi.mock('@/modules/master-data/counterparties/api/platformPartners', () => ({
listPlatformPartners: vi.fn(),
createPlatformPartner: vi.fn(),
@@ -222,4 +226,11 @@ describe('PartnersView real API integration', () => {
expect(vm.detail?.partnerCode).toBe('CUS-002')
})
it('keeps all three row actions visible without a premature more menu', () => {
expect(source).toContain('<Eye :size="15" />详情')
expect(source).toContain('<Pencil :size="15" />编辑')
expect(source).toContain('<Power :size="15" />')
expect(source).not.toContain('MoreHorizontal')
})
})
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('keeps list actions in the table toolbar without a duplicate page title', () => { expect(source).toContain('<template #toolbar-actions>'); expect(source).not.toContain('<template #toolbar-start>'); expect(source).not.toContain('PageToolbar'); expect(source).not.toContain('<template #actions><el-button') }) })
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('keeps list actions in the table toolbar without a duplicate page title', () => { expect(source).toContain('<template #toolbar-actions>'); expect(source).not.toContain('<template #toolbar-start>'); expect(source).not.toContain('PageToolbar'); expect(source).not.toContain('<template #actions><el-button') }); it('shows the two frequent row actions directly without a more menu', () => { expect(source).toContain('<Eye :size="15" />查看'); expect(source).toContain('<Pencil :size="15" />编辑'); expect(source).not.toContain('MoreHorizontal') }) })
@@ -36,6 +36,27 @@ describe('TruckAppointmentsView', () => {
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true)
})
it('uses stable accessible labels when appointment identifiers are missing', async () => {
vi.mocked(listTruckAppointments).mockResolvedValue({
records: [
{ ...row, id: '9002', appointmentNo: '' },
{ ...row, id: undefined, appointmentNo: null } as unknown as TruckAppointmentResource,
],
page: 1,
size: 20,
total: 2,
pages: 1,
})
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.find('button[aria-label="查看 9002"]').exists()).toBe(true)
expect(wrapper.find('button[aria-label="查看拖车预约详情"]').exists()).toBe(true)
const labels = wrapper.findAll('button[aria-label]').map((button) => button.attributes('aria-label'))
expect(labels.every((label) => Boolean(label) && !/undefined|null/.test(label ?? ''))).toBe(true)
expect(source).toContain("appointmentActionLabel('编辑', row as TruckAppointmentResource)")
})
it('uses the table toolbar for create and keeps a single empty state', () => {
expect(source).toContain('<template #toolbar-actions>')
expect(source).not.toContain('<template #toolbar-start>')
@@ -43,4 +64,10 @@ describe('TruckAppointmentsView', () => {
expect(source).not.toContain('empty-hint')
expect(source.match(/empty-text=/g)).toHaveLength(1)
})
it('shows the two frequent row actions directly without a more menu', () => {
expect(source).toContain('<Eye :size="15" />详情')
expect(source).toContain('<Pencil :size="15" />编辑')
expect(source).not.toContain('MoreHorizontal')
})
})
@@ -39,6 +39,12 @@ const columns: DataTableColumn[] = [
{ key: 'attachments', label: '附件', width: 90, formatter: (row: TruckAppointmentResource) => String(row.attachments?.length || 0) },
]
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function appointmentActionLabel(action: '查看' | '编辑', row: TruckAppointmentResource) {
const identifier = [row.appointmentNo, row.id]
.map((value) => String(value ?? '').trim())
.find(Boolean)
return identifier ? `${action} ${identifier}` : `${action}拖车预约详情`
}
function statusLabel(value: unknown) {
return typeof value === 'string' && value in truckAppointmentStatusLabels
? truckAppointmentStatusLabels[value as TruckAppointmentStatus]
@@ -62,7 +68,7 @@ function openDetail(row: TruckAppointmentResource, edit = false) { router.push({
<template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.driverPhone || '—' }}</small></template>
<template #store="{ row }">{{ row.storeName || '—' }}</template>
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
<template #actions="{ row }"><div class="table-actions"><el-button link type="primary" :aria-label="`查看 ${row.appointmentNo || row.id}`" @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="`编辑 ${row.appointmentNo || row.id}`" @click="openDetail(row as TruckAppointmentResource, true)"><Pencil :size="15" />编辑</el-button></div></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>
</DataTable>
</section>
</ListPageLayout>
@@ -28,7 +28,7 @@ describe('UnifiedOrdersView contract', () => {
it('provides labeled prototype-aligned advanced filters backed by real query fields', () => {
for (const key of [
'receivingWarehouseId', 'destinationWarehouseId', 'inboundCode', 'internalOrderStatus', 'expectedArrivalDate',
'receivingWarehouseId', 'destinationWarehouseId', 'inboundCode', 'expectedArrivalDate',
'feeReviewStatus', 'containerNo', 'cabinetNo', 'channelName', 'sailingDate', 'loaded', 'fullContainer',
'measurementReviewStatus', 'fbaCode', 'createdRange',
]) expect(source).toContain(`key: '${key}'`)
@@ -45,9 +45,11 @@ describe('UnifiedOrdersView contract', () => {
expect(source).not.toContain("{ value: 'CANCELLED', label: '已取消' }")
expect(source).not.toContain("key: 'orderNo'")
expect(source.match(/key: 'inboundCode', type: 'input'/g)).toHaveLength(1)
expect(source.match(/key: 'internalOrderStatus'/g)).toHaveLength(1)
expect(source).not.toContain('internalOrderStatus: filters.value.internalOrderStatus')
expect(source).not.toContain("key: 'keyword'")
const prototypeFieldOrder = [
'receivingWarehouseId', 'destinationWarehouseId', 'inboundCode', 'internalOrderStatus', 'expectedArrivalDate',
'receivingWarehouseId', 'destinationWarehouseId', 'inboundCode', 'expectedArrivalDate',
'feeReviewStatus', 'tenantId', 'containerNo', 'cabinetNo', 'channelName', 'sailingDate', 'loaded',
'fullContainer', 'measurementReviewStatus', 'fbaCode', 'createdRange',
]
@@ -56,8 +58,17 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('preference-key="unified-orders-query-v2"')
})
it('uses tabs as the only order-status filter and keeps advanced controls inside QueryToolbar', () => {
expect(source).toContain('filters.value.mainStatus || undefined')
expect(source).toContain('<template #actions>')
expect(source).toContain(':aria-expanded="showAdvancedFilters"')
expect(source).not.toContain('class="filter-mode-actions"')
expect(source).not.toContain('.filter-mode-actions')
expect(source).not.toContain('请选择订单状态')
})
it('covers real list states and keeps operation column first and left-fixed', () => {
expect(source).toContain("{ key: 'actions', label: '操作', width: 180, fixed: 'left'")
expect(source).toContain("{ key: 'actions', label: '操作', width: 300, fixed: 'left'")
expect(source).toContain("{ key: 'selection', type: 'selection', width: 52, fixed: 'left'")
expect(source).toContain('批量审核接口待接入')
expect(source).toContain('@selection-change="handleSelectionChange"')
@@ -74,6 +85,23 @@ describe('UnifiedOrdersView contract', () => {
expect(source.match(/<LineClampText v-if="column\.key === 'remark'"/g)).toHaveLength(3)
})
it('uses ListPageLayout regions for one 16px query-to-table gap', () => {
expect(source).toContain('<ListPageLayout ariaLabel="统一订单列表">')
expect(source).toContain('<template #toolbar>')
expect(source).toContain('<template v-if="loadError" #state>')
expect(source).toContain('<template #default>')
expect(source).toContain('v-if="!loadError" id="unified-orders-results" class="surface-panel table-panel table-card"')
expect(source).not.toContain('class="orders-load-error"')
expect(source).not.toContain('.orders-load-error { margin-top: 0; }')
})
it('counts detail in the three directly visible row actions', () => {
expect(source).toContain('unifiedOrderSecondaryActions(row).slice(0, 2)')
expect(source).toContain('unifiedOrderSecondaryActions(row).slice(2)')
expect(source).toContain('<Eye :size="15" />详情')
expect(source).toContain('v-if="moreUnifiedOrderActions(row as UnifiedOrderResource).length"')
})
it('keeps only confirmed business actions as explicit boundaries', () => {
expect(source).toContain('nextActionBoundary')
expect(source).not.toContain('class="action-boundary"')
@@ -116,11 +144,20 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('<ChevronDown v-else :size="18" />')
expect(source).toContain("{ key: 'girthCm', label: '箱规周长(cm)' }")
expect(source).toContain('runMoreAction($event, row as UnifiedOrderResource)')
expect(source).toContain('aria-label="更多订单操作"')
expect(source).toContain(':aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"')
expect(source).toContain(':title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"')
expect(source).toContain("can('platform:order:note')")
expect(source).toContain('await loadOrders()')
})
it('distinguishes overflow action buttons by row and keeps a stable missing-field fallback', () => {
expect(source).toContain('function moreUnifiedOrderActionsLabel(row: UnifiedOrderResource)')
expect(source).toContain('[compatibleOrder.inboundCode, compatibleOrder.orderNo, compatibleOrder.orderId, compatibleOrder.id]')
expect(source).toContain("identifier || '当前订单'")
expect(source).not.toContain('aria-label="更多订单操作"')
expect(source).not.toContain('title="更多操作"')
})
it('renders order detail as a routed full page instead of a drawer', () => {
expect(source).toContain('<template v-if="orderId">')
expect(source).toContain('class="order-detail-header"')
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch, type Component } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, History, ListChecks, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
@@ -42,7 +42,6 @@ type FilterModel = QueryModel & {
receivingWarehouseId: string
destinationWarehouseId: string
inboundCode: string
internalOrderStatus: string
expectedArrivalDate: string
feeReviewStatus: string
channelName: string
@@ -140,10 +139,6 @@ const primaryQueryFields = computed<QueryField[]>(() => [
{ key: 'receivingWarehouseId', type: 'select', width: 220, label: '定位仓库', placeholder: warehouseLoadError.value ? '仓库加载失败' : '请选择定位仓库', options: receivingWarehouseOptions.value, props: { 'aria-label': '定位仓库', filterable: true, loading: warehouseLoading.value, disabled: Boolean(warehouseLoadError.value) } },
{ key: 'destinationWarehouseId', type: 'select', width: 220, label: '交货仓', placeholder: warehouseLoadError.value ? '仓库加载失败' : '请选择交货仓', options: warehouseOptions.value, props: { 'aria-label': '交货仓', filterable: true, loading: warehouseLoading.value, disabled: Boolean(warehouseLoadError.value) } },
{ key: 'inboundCode', type: 'input', width: 220, label: '入仓码', placeholder: '请输入入仓码', props: { 'aria-label': '入仓码' } },
{ key: 'internalOrderStatus', type: 'select', width: 190, label: '订单状态', placeholder: '请选择订单状态', options: [
{ value: 'SUBMITTED', label: '待审核' }, { value: 'APPROVED', label: '已审核' },
{ value: 'ACCEPTED', label: '已受理' }, { value: 'PROCESSING', label: '处理中' }, { value: 'COMPLETED', label: '已完成' },
], props: { 'aria-label': '订单状态' } },
])
const advancedQueryFields = computed<QueryField[]>(() => [
@@ -184,7 +179,7 @@ const queryFields = computed(() => showAdvancedFilters.value
: primaryQueryFields.value)
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 52, fixed: 'left', align: 'center', selectable: canBulkReview },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, slotName: 'field' },
{ key: 'internalOrderStatus', prop: 'internalOrderStatus', label: '订单状态', minWidth: 130, slotName: 'orderStatus' },
@@ -243,7 +238,7 @@ const columns: DataTableColumn[] = [
function emptyFilters(): FilterModel {
return {
tenantId: '', mainStatus: '', receivingWarehouseId: '', destinationWarehouseId: '',
inboundCode: '', internalOrderStatus: '', expectedArrivalDate: '', feeReviewStatus: '', channelName: '',
inboundCode: '', expectedArrivalDate: '', feeReviewStatus: '', channelName: '',
containerNo: '', cabinetNo: '', sailingDate: '', loaded: '', fullContainer: '', measurementReviewStatus: '',
fbaCode: '', createdRange: [],
}
@@ -634,7 +629,6 @@ async function loadOrders() {
receivingWarehouseId: filters.value.receivingWarehouseId || undefined,
destinationWarehouseId: filters.value.destinationWarehouseId || undefined,
inboundCode: trimmed(filters.value.inboundCode),
internalOrderStatus: filters.value.internalOrderStatus || undefined,
expectedArrivalDate: filters.value.expectedArrivalDate || undefined,
feeReviewStatus: filters.value.feeReviewStatus || undefined,
channelName: trimmed(filters.value.channelName),
@@ -697,6 +691,40 @@ function openInboundFromDetail() {
})
}
type UnifiedOrderRowAction = 'note' | 'approve' | 'measurement'
const unifiedOrderRowActionIcons: Record<UnifiedOrderRowAction, Component> = {
note: StickyNote,
approve: CircleCheck,
measurement: Ruler,
}
function unifiedOrderSecondaryActions(row: UnifiedOrderResource): UnifiedOrderRowAction[] {
return [
...(can('platform:order:note') ? ['note' as const] : []),
...(canApprove(row) ? ['approve' as const] : []),
...(row.mainStatus === 'WAIT_MEASURE' && row.inboundId && can('platform:firstleg:inbound:view') ? ['measurement' as const] : []),
]
}
function directUnifiedOrderActions(row: UnifiedOrderResource) {
return unifiedOrderSecondaryActions(row).slice(0, 2)
}
function moreUnifiedOrderActions(row: UnifiedOrderResource) {
return unifiedOrderSecondaryActions(row).slice(2)
}
function moreUnifiedOrderActionsLabel(row: UnifiedOrderResource) {
const compatibleOrder = row as UnifiedOrderResource & { id?: string | number | null }
const identifier = [compatibleOrder.inboundCode, compatibleOrder.orderNo, compatibleOrder.orderId, compatibleOrder.id]
.map((value) => String(value ?? '').trim())
.find(Boolean)
return `更多订单操作:${identifier || '当前订单'}`
}
function unifiedOrderActionLabel(action: UnifiedOrderRowAction) {
if (action === 'note') return '添加备注'
if (action === 'approve') return '审核通过'
return '测量作业'
}
function unifiedOrderActionType(action: UnifiedOrderRowAction) {
return action === 'approve' ? 'success' : 'primary'
}
function runMoreAction(command: string | number | object, row: UnifiedOrderResource) {
if (command === 'note') openNoteDialog(row)
if (command === 'approve') openApproval(row)
@@ -982,6 +1010,7 @@ onMounted(() => {
<template v-else>
<ListPageLayout ariaLabel="统一订单列表">
<template #toolbar>
<div class="orders-query-panel">
<div class="status-tabs" role="tablist" aria-label="订单主状态">
<button
@@ -998,20 +1027,25 @@ onMounted(() => {
>{{ tab.label }}</button>
</div>
<div class="filter-mode-actions">
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
</el-button>
</div>
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" class="orders-query-toolbar" ariaLabel="订单列表筛选" :fields="queryFields" :available-fields="allQueryFields" preference-key="unified-orders-query-v2" :search-on-reset="false" @search="search" @reset="reset" />
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" class="orders-query-toolbar" ariaLabel="订单列表筛选" :fields="queryFields" :available-fields="allQueryFields" preference-key="unified-orders-query-v2" :search-on-reset="false" @search="search" @reset="reset">
<template #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
</el-button>
</template>
</QueryToolbar>
</div>
<LoadErrorState v-if="loadError" class="orders-load-error" title="订单列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadOrders">
</template>
<template v-if="loadError" #state>
<LoadErrorState title="订单列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadOrders">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template>
<div v-else id="unified-orders-results" class="surface-panel table-panel table-card" role="tabpanel">
<template #default>
<div v-if="!loadError" id="unified-orders-results" class="surface-panel table-panel table-card" role="tabpanel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleSelectionChange">
<template #toolbar-actions>
<el-button type="primary" plain :disabled="selectedOrders.length === 0" title="批量审核接口待接入" @click="openBulkReviewPlaceholder"><ListChecks :size="16" />批量审核<span v-if="selectedOrders.length">({{ selectedOrders.length }})</span></el-button>
@@ -1019,14 +1053,12 @@ onMounted(() => {
<template #actions="{ row }">
<div class="row-actions">
<el-button link type="primary" @click="openDetail(row as UnifiedOrderResource)"><Eye :size="15" />详情</el-button>
<el-dropdown trigger="click" @command="runMoreAction($event, row as UnifiedOrderResource)">
<el-button link type="primary" aria-label="更多订单操作" title="更多操作"><MoreHorizontal :size="17" /></el-button>
<el-button v-for="action in directUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" link :type="unifiedOrderActionType(action)" :loading="action === 'approve' && approvalLoadingId === String((row as UnifiedOrderResource).orderId)" @click="runMoreAction(action, row as UnifiedOrderResource)"><component :is="unifiedOrderRowActionIcons[action]" :size="15" />{{ unifiedOrderActionLabel(action) }}</el-button>
<el-dropdown v-if="moreUnifiedOrderActions(row as UnifiedOrderResource).length" trigger="click" @command="runMoreAction($event, row as UnifiedOrderResource)">
<el-button link type="primary" :aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" :title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"><MoreHorizontal :size="17" /></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="can('platform:order:note')" command="note"><StickyNote :size="15" />添加备注</el-dropdown-item>
<el-dropdown-item v-if="canApprove(row as UnifiedOrderResource)" command="approve"><CircleCheck :size="15" />审核通过</el-dropdown-item>
<el-dropdown-item v-if="(row as UnifiedOrderResource).mainStatus === 'WAIT_MEASURE' && (row as UnifiedOrderResource).inboundId && can('platform:firstleg:inbound:view')" command="measurement">测量作业</el-dropdown-item>
<el-dropdown-item v-if="!can('platform:order:note') && !canApprove(row as UnifiedOrderResource) && !((row as UnifiedOrderResource).inboundId && can('platform:firstleg:inbound:view'))" disabled>暂无可用操作</el-dropdown-item>
<el-dropdown-item v-for="action in moreUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" :command="action"><component :is="unifiedOrderRowActionIcons[action]" :size="15" />{{ unifiedOrderActionLabel(action) }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -1047,6 +1079,7 @@ onMounted(() => {
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条</span></template>
</DataTable>
</div>
</template>
</ListPageLayout>
</template>
@@ -1070,7 +1103,6 @@ onMounted(() => {
.page-intro :deep(.el-button) { flex: 0 0 auto; }
.orders-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.status-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid var(--cc-border); scrollbar-width: thin; }
.filter-mode-actions { display: flex; justify-content: flex-end; padding: 12px 16px 0; }
.status-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }
.status-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }
.status-tab:hover { color: var(--cc-primary); background: #f6f9fd; }
@@ -1078,7 +1110,6 @@ onMounted(() => {
.status-tab.is-active { color: var(--cc-primary); font-weight: 600; }
.status-tab.is-active::after { background: var(--cc-primary); }
.orders-query-toolbar { border: 0; }
.orders-load-error { margin-top: 0; }
.table-card { overflow: hidden; }
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.row-actions :deep(.el-button) { margin-left: 0; }
@@ -102,20 +102,20 @@ const occupiedLocation: PlatformLocationResource = {
version: 5,
}
async function mountView() {
async function mountView(permissions = [
'platform:location:list',
'platform:location:create',
'platform:location:edit',
'platform:location:status',
'platform:location:delete',
]) {
vi.stubGlobal('localStorage', createMemoryStorage())
vi.stubGlobal('sessionStorage', createMemoryStorage())
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = [
'platform:location:list',
'platform:location:create',
'platform:location:edit',
'platform:location:status',
'platform:location:delete',
]
auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(LocationsView, {
attachTo: document.body,
@@ -254,6 +254,51 @@ describe('LocationsView', () => {
expect(bodyRows[1]?.text()).not.toContain('更多')
})
it('shows all available actions directly when a row has no more than three', async () => {
const wrapper = await mountView(['platform:location:list', 'platform:location:status'])
const row = wrapper.find('tbody tr')
expect(row.text()).toContain('详情')
expect(row.text()).toContain('停用')
expect(row.text()).not.toContain('更多')
})
it('keeps the first three actions visible and moves only the fourth action into more', async () => {
vi.mocked(listPlatformLocations).mockResolvedValue({
records: [{ ...availableLocation, status: 'DISABLED' }], page: 1, size: 20, total: 1, pages: 1,
})
const wrapper = await mountView()
const row = wrapper.find('tbody tr')
expect(row.text()).toContain('详情')
expect(row.text()).toContain('编辑')
expect(row.text()).toContain('启用')
expect(row.text()).not.toContain('删除')
expect(row.find('button[aria-label="A1-01的更多操作"]').exists()).toBe(true)
})
it('uses unique fallback labels for more actions across rows', async () => {
vi.mocked(listPlatformLocations).mockResolvedValue({
records: [
{ ...availableLocation, id: 'location-1', locationCode: '', locationName: '东侧库位', status: 'DISABLED' },
{ ...availableLocation, id: 'location-2', locationCode: '', locationName: '', status: 'DISABLED' },
{ ...availableLocation, id: undefined, locationCode: '', locationName: '', status: 'DISABLED' } as unknown as PlatformLocationResource,
],
page: 1,
size: 20,
total: 3,
pages: 1,
})
const wrapper = await mountView()
const moreButtons = wrapper.findAll('button[aria-label$="的更多操作"]')
const labels = moreButtons.map((button) => button.attributes('aria-label'))
expect(labels).toEqual(['东侧库位的更多操作', 'location-2的更多操作', '当前库位的更多操作'])
expect(moreButtons.map((button) => button.attributes('title'))).toEqual(labels)
expect(new Set(labels).size).toBe(labels.length)
expect(labels.every((label) => Boolean(label) && !/undefined|null/.test(label ?? ''))).toBe(true)
})
it('does not offer editing for a location outside a domestic warehouse', async () => {
vi.mocked(listPlatformLocations).mockResolvedValue({
records: [{ ...availableLocation, warehouseId: fbaWarehouse.id }, availableLocation], page: 1, size: 20, total: 2, pages: 1,
@@ -221,6 +221,17 @@ function canDelete(row: PlatformLocationResource) {
return access.can('platform:location:delete') && !row.sharedOverflow && row.status === 'DISABLED'
}
function hasMoreLocationActions(row: PlatformLocationResource) {
return 1 + Number(canEdit(row)) + Number(canChangeStatus(row)) + Number(canDelete(row)) > 3
}
function locationMoreActionsLabel(row: PlatformLocationResource) {
const identifier = [row.locationCode, row.locationName, row.id]
.map((value) => String(value ?? '').trim())
.find(Boolean) || '当前库位'
return `${identifier}的更多操作`
}
function actionError(error: unknown, fallback: string) {
const details = getApiErrorDetails(error, fallback)
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
@@ -562,19 +573,31 @@ onMounted(() => {
:disabled="loading"
@click="loadDetail(row as PlatformLocationResource, 'edit')"
><Pencil :size="15" />编辑</el-button>
<el-button
v-if="canChangeStatus(row as PlatformLocationResource)"
link
:type="(row as PlatformLocationResource).status === 'AVAILABLE' ? 'danger' : 'success'"
:disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
@click="toggleStatus(row as PlatformLocationResource)"
><Power :size="15" />{{ (row as PlatformLocationResource).status === 'AVAILABLE' ? '停用' : '启用' }}</el-button>
<el-button
v-if="canDelete(row as PlatformLocationResource) && !hasMoreLocationActions(row as PlatformLocationResource)"
link
type="danger"
:disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
@click="removeLocation(row as PlatformLocationResource)"
><Trash2 :size="15" />删除</el-button>
<el-dropdown
v-if="canChangeStatus(row as PlatformLocationResource) || canDelete(row as PlatformLocationResource)"
v-if="hasMoreLocationActions(row as PlatformLocationResource)"
trigger="click"
popper-class="location-action-menu"
:disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
@command="handleMoreAction(row as PlatformLocationResource, String($event))"
>
<el-button link type="primary" aria-label="更多库位操作" title="更多库位操作"><MoreHorizontal :size="16" /></el-button>
<el-button link type="primary" :aria-label="locationMoreActionsLabel(row as PlatformLocationResource)" :title="locationMoreActionsLabel(row as PlatformLocationResource)"><MoreHorizontal :size="16" /></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="canChangeStatus(row as PlatformLocationResource)" command="status">
<Power :size="15" />{{ (row as PlatformLocationResource).status === 'AVAILABLE' ? '停用' : '启用' }}
</el-dropdown-item>
<el-dropdown-item v-if="canDelete(row as PlatformLocationResource)" command="delete" divided>
<el-dropdown-item v-if="canDelete(row as PlatformLocationResource)" command="delete" class="location-action-menu__danger">
<Trash2 :size="15" />删除
</el-dropdown-item>
</el-dropdown-menu>
@@ -794,6 +817,7 @@ onMounted(() => {
.readonly-status { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 12px; background: #f7f9fc; }
.readonly-status > span { color: var(--cc-text); font-size: 13px; font-weight: 600; }
.readonly-status small { color: var(--cc-muted); font-size: 11px; }
:global(.location-action-menu .location-action-menu__danger:not(.is-disabled)) { color: var(--el-color-danger); }
@media (max-width: 720px) {
.detail-identity { grid-template-columns: 44px minmax(0, 1fr); }
@@ -241,6 +241,7 @@ describe('WarehousesView', () => {
expect(wrapper.text()).toContain('Gateway unavailable')
expect(wrapper.text()).toContain('重新查询')
expect(wrapper.text()).not.toContain('演示物流园')
expect(wrapper.find('.table-panel .data-table').exists()).toBe(false)
})
it('keeps create actions behind permissions and preserves a read-only table state', async () => {
+26
View File
@@ -114,6 +114,7 @@ describe('DataTable column preferences', () => {
expect(wrapper.get('.data-table__toolbar-actions').text()).toContain('新增订单')
expect(wrapper.get('.data-table__toolbar-actions').text()).toContain('辅助操作')
expect(wrapper.get('.data-table__business-actions').text()).toBe('新增订单')
expect(wrapper.get('.data-table__utility-actions').text()).toContain('辅助操作')
expect(wrapper.get('.data-table__toolbar-end').text()).toBe('辅助操作')
})
@@ -135,6 +136,31 @@ describe('DataTable column preferences', () => {
expect(wrapper.get('.data-table__toolbar').classes()).toContain('is-compact')
expect(wrapper.find('.data-table__toolbar-start').exists()).toBe(false)
expect(wrapper.get('.data-table__toolbar-actions').classes()).toContain('has-business-actions')
expect(wrapper.get('.data-table__business-actions').text()).toBe('新增订单')
})
it('keeps business actions in a separate mobile-wrappable group from column settings', () => {
const wrapper = shallowMount(DataTable, {
props: { data: [], columns: configurableColumns, preferenceKey: 'orders-table' },
slots: { 'toolbar-actions': '<button class="create-action">新增快递公司</button>' },
global: {
provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'user-1') },
stubs: {
ElTable: TableStub,
ElTableColumn: ColumnStub,
ElButton: ButtonStub,
ElPopover: PopoverStub,
},
directives: { loading: {} },
},
})
const toolbarActions = wrapper.get('.data-table__toolbar-actions')
const businessActions = toolbarActions.get('.data-table__business-actions')
expect(toolbarActions.classes()).toContain('has-business-actions')
expect(businessActions.get('.create-action').text()).toBe('新增快递公司')
expect(toolbarActions.find('.data-table__utility-actions').exists()).toBe(true)
expect(toolbarActions.find('button[aria-label="列设置"]').exists()).toBe(true)
})
})
+19 -16
View File
@@ -256,21 +256,23 @@ function setPage(value: number) {
:class="{ 'is-compact': !$slots['toolbar-start'] }"
>
<div v-if="$slots['toolbar-start']" class="data-table__toolbar-start"><slot name="toolbar-start" /></div>
<div class="data-table__toolbar-actions">
<div class="data-table__toolbar-actions" :class="{ 'has-business-actions': $slots['toolbar-actions'] }">
<div v-if="$slots['toolbar-actions']" class="data-table__business-actions"><slot name="toolbar-actions" /></div>
<el-popover v-if="preferenceEnabled" placement="bottom-end" :width="340" trigger="click" popper-class="cc-layout-preference-popper">
<template #reference>
<el-button aria-label="列设置" title="列设置"><Settings2 :size="16" /></el-button>
</template>
<LayoutPreferenceList
:items="preferenceItems"
allow-visibility
@move="moveColumn"
@visibility="setColumnVisibility"
@reset="resetPreference"
/>
</el-popover>
<div v-if="$slots['toolbar-end']" class="data-table__toolbar-end"><slot name="toolbar-end" /></div>
<div v-if="preferenceEnabled || $slots['toolbar-end']" class="data-table__utility-actions">
<el-popover v-if="preferenceEnabled" placement="bottom-end" :width="340" trigger="click" popper-class="cc-layout-preference-popper">
<template #reference>
<el-button aria-label="列设置" title="列设置"><Settings2 :size="16" /></el-button>
</template>
<LayoutPreferenceList
:items="preferenceItems"
allow-visibility
@move="moveColumn"
@visibility="setColumnVisibility"
@reset="resetPreference"
/>
</el-popover>
<div v-if="$slots['toolbar-end']" class="data-table__toolbar-end"><slot name="toolbar-end" /></div>
</div>
</div>
</div>
<el-table ref="tableRef" v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :aria-busy="loading">
@@ -333,7 +335,8 @@ function setPage(value: number) {
.data-table__toolbar-start :deep(h2) { margin: 0; color: var(--cc-text); font-size: 16px; line-height: 1.4; }
.data-table__toolbar-start :deep(p) { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.data-table__toolbar-actions { display: flex; flex: 0 0 auto; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.data-table__business-actions, .data-table__toolbar-end { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.data-table__business-actions, .data-table__utility-actions, .data-table__toolbar-end { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.data-table__utility-actions { flex: 0 0 auto; }
.data-table__toolbar-actions :deep(.el-button + .el-button) { margin-left: 0; }
.data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; }
.data-table__pagination-left { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; }
@@ -343,5 +346,5 @@ function setPage(value: number) {
.data-table__page-buttons { gap: 4px; }
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; }
.data-table__page-ellipsis { display: inline-flex; width: 24px; height: 32px; align-items: center; justify-content: center; }
@media (max-width: 720px) { .data-table__toolbar, .data-table__toolbar.is-compact { min-height: 0; align-items: stretch; flex-direction: column; padding: 14px; } .data-table__toolbar-actions { width: 100%; align-items: flex-start; flex-wrap: nowrap; justify-content: flex-start; } .data-table__business-actions { flex: 1 1 auto; justify-content: flex-start; } .data-table__toolbar-end { flex: 0 1 auto; justify-content: flex-start; } .data-table__toolbar-actions :deep(.el-button) { min-height: 40px; } .data-table__pagination { align-items: flex-start; flex-direction: column; } .data-table__pagination-left, .data-table__pagination-controls, .data-table__page-buttons { width: 100%; } .data-table__pagination-controls { justify-content: flex-start; } .data-table__page-buttons { flex-wrap: wrap; } .data-table__page-button { width: 44px; min-width: 44px; height: 44px; } .data-table__page-ellipsis { height: 44px; } }
@media (max-width: 720px) { .data-table__toolbar, .data-table__toolbar.is-compact { min-height: 0; align-items: stretch; flex-direction: column; padding: 14px; } .data-table__toolbar-actions { width: 100%; align-items: flex-start; flex-wrap: wrap; justify-content: flex-start; } .data-table__toolbar-actions.has-business-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto; } .data-table__business-actions { justify-content: flex-start; } .data-table__utility-actions { align-items: flex-start; justify-content: flex-end; } .data-table__toolbar-end { justify-content: flex-start; } .data-table__toolbar-actions :deep(.el-button) { max-width: 100%; min-height: 40px; } .data-table__pagination { align-items: flex-start; flex-direction: column; } .data-table__pagination-left, .data-table__pagination-controls, .data-table__page-buttons { width: 100%; } .data-table__pagination-controls { justify-content: flex-start; } .data-table__page-buttons { flex-wrap: wrap; } .data-table__page-button { width: 44px; min-width: 44px; height: 44px; } .data-table__page-ellipsis { height: 44px; } }
</style>