feat(phase3): complete loading workbenches
This commit is contained in:
@@ -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',
|
||||
])
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user