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')
|
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', () => {
|
it('protects counterparty records with a stable master-data permission', () => {
|
||||||
const route = router.getRoutes().find((item) => item.name === 'counterparties')
|
const route = router.getRoutes().find((item) => item.name === 'counterparties')
|
||||||
expect(route?.path).toBe('/master-data/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([
|
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('/downloads')
|
||||||
expect(paths).toContain('/master-data/fee-items')
|
expect(paths).toContain('/master-data/fee-items')
|
||||||
expect(paths).toContain('/container-loading')
|
expect(paths).toContain('/container-loading')
|
||||||
|
expect(paths).toContain('/container-loading/smart-loading')
|
||||||
expect(paths).toContain('/loading-plans')
|
expect(paths).toContain('/loading-plans')
|
||||||
expect(paths).toContain('/loading-lists')
|
expect(paths).toContain('/loading-lists')
|
||||||
expect(paths).not.toContain('/prototype/container-management/container-list')
|
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', () => {
|
it('shows each formal container workflow entry only with its matching permission', () => {
|
||||||
const filtered = filterAdminNavigation(adminNavigation, new Set([
|
const filtered = filterAdminNavigation(adminNavigation, new Set([
|
||||||
'platform:firstleg:container:list',
|
'platform:firstleg:container:list',
|
||||||
|
'platform:firstleg:smart-loading:list',
|
||||||
'platform:firstleg:preload:list',
|
'platform:firstleg:preload:list',
|
||||||
'platform:firstleg:loading-list:list',
|
'platform:firstleg:loading-list:list',
|
||||||
]))
|
]))
|
||||||
@@ -157,6 +160,7 @@ describe('adminNavigation', () => {
|
|||||||
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
|
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
|
||||||
'/dashboard',
|
'/dashboard',
|
||||||
'/container-loading',
|
'/container-loading',
|
||||||
|
'/container-loading/smart-loading',
|
||||||
'/loading-plans',
|
'/loading-plans',
|
||||||
'/loading-lists',
|
'/loading-lists',
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
|||||||
icon: WarehouseIcon,
|
icon: WarehouseIcon,
|
||||||
children: [
|
children: [
|
||||||
{ key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading', permission: 'platform:firstleg:container:list' },
|
{ 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-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' },
|
{ 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')
|
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 () => {
|
it('initializes preview access after login and before redirecting', async () => {
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
setActivePinia(pinia)
|
setActivePinia(pinia)
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ async function submit() {
|
|||||||
<section class="login-intro">
|
<section class="login-intro">
|
||||||
<div class="login-brand"><img class="login-brand-logo" :src="baiyueLogoWhite" alt="柏悦物流" /></div>
|
<div class="login-brand"><img class="login-brand-logo" :src="baiyueLogoWhite" alt="柏悦物流" /></div>
|
||||||
<div>
|
<div>
|
||||||
<h1>让跨境物流管理<br />清晰、可靠、可追踪</h1>
|
<h1>柏悦物流管理后台</h1>
|
||||||
<p class="intro-copy">统一管理企业入驻、内部权限和头程订单,让每一次操作都有边界、有记录。</p>
|
<p class="intro-copy">平台内部运营与管理系统</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="security-note">
|
<div class="security-note">
|
||||||
<ShieldCheck :size="22" />
|
<ShieldCheck :size="22" />
|
||||||
@@ -65,12 +65,13 @@ async function submit() {
|
|||||||
|
|
||||||
<section class="login-panel">
|
<section class="login-panel">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
|
<div class="login-mobile-brand"><img :src="baiyueLogoWhite" alt="柏悦物流" /></div>
|
||||||
<div class="login-card-icon"><LockKeyhole :size="24" /></div>
|
<div class="login-card-icon"><LockKeyhole :size="24" /></div>
|
||||||
<h2>登录管理后台</h2>
|
<h2>登录管理后台</h2>
|
||||||
<p>使用平台内部员工账号登录</p>
|
<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-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>
|
||||||
<el-form-item label="密码" prop="password" required>
|
<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" />
|
<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" />
|
登录 <ArrowRight :size="17" />
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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.text()).toContain('宁波 → 长滩')
|
||||||
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
|
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 optional(value: string | null) { return value?.trim() || null }
|
||||||
function statusText(status: ContainerPlanStatus) { return ({ EDITING: '编辑中', LOADING: '装柜中', COMPLETED: '已完成' })[status] }
|
function statusText(status: ContainerPlanStatus) { return ({ EDITING: '编辑中', LOADING: '装柜中', COMPLETED: '已完成' })[status] }
|
||||||
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
|
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) {
|
function replaceForm(source: ContainerBookingResource | null) {
|
||||||
Object.assign(form, emptyForm(), source || {})
|
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">
|
<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="暂无货柜">
|
<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 #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 #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>{{ row.bookingNo }}</small></template>
|
||||||
<template #containerNo="{ row }">{{ row.containerNo || '待录入' }}</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>
|
<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'),
|
component: () => import('./pages/ContainersView.vue'),
|
||||||
meta: { title: '货柜与排柜', description: '维护货柜资料并进入智能排柜工作台', permission: 'platform:firstleg:container:list' },
|
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',
|
path: 'container-loading/:containerId/smart-loading',
|
||||||
name: 'smart-loading-workbench',
|
name: 'smart-loading-workbench',
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ function tableButtonByText(text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clickMoreAction(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()
|
await flushPromises()
|
||||||
const item = [...document.querySelectorAll<HTMLElement>('.el-dropdown-menu__item')]
|
const item = [...document.querySelectorAll<HTMLElement>('.el-dropdown-menu__item')]
|
||||||
.find((candidate) => candidate.textContent?.trim() === text && !candidate.classList.contains('is-disabled'))
|
.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] } })
|
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await clickMoreAction('重置密码')
|
tableButtonByText('重置密码')?.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
const password = document.querySelector<HTMLInputElement>('input[autocomplete="new-password"]')
|
const password = document.querySelector<HTMLInputElement>('input[autocomplete="new-password"]')
|
||||||
const confirmation = document.querySelector<HTMLInputElement>('input[placeholder="请再次输入新密码"]')
|
const confirmation = document.querySelector<HTMLInputElement>('input[placeholder="请再次输入新密码"]')
|
||||||
@@ -479,11 +480,10 @@ describe('CustomersView', () => {
|
|||||||
useAccessPreviewStore().initializeAfterLogin()
|
useAccessPreviewStore().initializeAfterLogin()
|
||||||
vi.mocked(archivePlatformTenant).mockResolvedValue({ ...fullTenant, status: 'DISABLED' })
|
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()
|
await flushPromises()
|
||||||
const dropdowns = wrapper.findAllComponents({ name: 'ElDropdown' })
|
expect(document.querySelector('.el-table__body button[aria-label^="更多客户操作:"]')).toBeNull()
|
||||||
expect(dropdowns.length).toBeGreaterThan(0)
|
tableButtonByText('删除')?.click()
|
||||||
dropdowns.at(-1)!.vm.$emit('command', 'delete')
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const confirmation = document.querySelector<HTMLInputElement>('input[placeholder="TEN8"]')
|
const confirmation = document.querySelector<HTMLInputElement>('input[placeholder="TEN8"]')
|
||||||
@@ -519,9 +519,53 @@ describe('CustomersView', () => {
|
|||||||
|
|
||||||
expect(tableButtonByText('详情')).toBeDefined()
|
expect(tableButtonByText('详情')).toBeDefined()
|
||||||
expect(tableButtonByText('编辑')).toBeDefined()
|
expect(tableButtonByText('编辑')).toBeDefined()
|
||||||
expect(document.querySelector('.el-table__body button[aria-label="更多客户操作"]')).toBeTruthy()
|
expect(document.querySelector('.el-table__body button[aria-label="更多客户操作:柏悦物流"]')).toBeTruthy()
|
||||||
expect(tableButtonByText('重置密码')).toBeUndefined()
|
expect(tableButtonByText('重置密码')).toBeDefined()
|
||||||
await clickMoreAction('重置密码')
|
expect(tableButtonByText('跳转登录')).toBeUndefined()
|
||||||
expect(document.querySelector('input[autocomplete="new-password"]')).toBeTruthy()
|
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">
|
<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 { 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 { 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'
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
@@ -633,8 +633,60 @@ function canChangeStatus(row: PlatformTenantResource) {
|
|||||||
function canArchive() {
|
function canArchive() {
|
||||||
return access.can('platform:customer:delete')
|
return access.can('platform:customer:delete')
|
||||||
}
|
}
|
||||||
function hasMoreActions(row: PlatformTenantResource) {
|
type CustomerRowAction = 'reset-password' | 'impersonate' | 'status' | 'delete'
|
||||||
return canResetPassword(row) || canImpersonate(row) || canChangeStatus(row) || canArchive()
|
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) {
|
function handleMoreAction(row: PlatformTenantResource, command: string) {
|
||||||
if (command === 'reset-password') openResetPassword(row)
|
if (command === 'reset-password') openResetPassword(row)
|
||||||
@@ -917,15 +969,13 @@ onMounted(() => { void loadCustomers() })
|
|||||||
<div class="table-actions">
|
<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')" 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="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-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-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 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-button link type="primary" aria-label="更多客户操作" title="更多操作"><MoreHorizontal :size="16" /></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>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<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-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-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-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
|
|||||||
@@ -25,13 +25,36 @@ describe('PlatformFirstLegOrdersPanel review queue', () => {
|
|||||||
expect(source).toContain('await nextTick()')
|
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('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('内部备注')
|
||||||
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', () => {
|
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 { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { FormInstance, FormRules } 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 { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
acceptPlatformFirstLegOrder,
|
acceptPlatformFirstLegOrder,
|
||||||
@@ -97,7 +97,7 @@ const queryFields = computed<QueryField[]>(() => [
|
|||||||
])
|
])
|
||||||
|
|
||||||
const orderColumns: DataTableColumn[] = [
|
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: 'status', label: '状态', width: 108, slotName: 'status' },
|
||||||
{ key: 'orderInfo', label: '订单信息', minWidth: 210, slotName: 'orderInfo' },
|
{ key: 'orderInfo', label: '订单信息', minWidth: 210, slotName: 'orderInfo' },
|
||||||
{ key: 'customer', label: '客户', minWidth: 190, showOverflowTooltip: true, slotName: 'customer' },
|
{ 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)
|
return can('platform:order:fulfillment') && !['DRAFT', 'REJECTED', 'CANCELLED'].includes(order.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasSecondaryActions(order: PlatformFirstLegOrderResource) {
|
type SecondaryAction = 'express' | 'note' | 'cancel'
|
||||||
return canCancel(order) || canMaintainExpress(order) || can('platform:order:note')
|
|
||||||
|
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) {
|
function runSecondaryAction(command: string | number | object, order: PlatformFirstLegOrderResource) {
|
||||||
@@ -511,7 +544,7 @@ onMounted(() => void loadTenants())
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ListPageLayout ariaLabel="头程订单列表">
|
<ListPageLayout class="first-leg-order-list" ariaLabel="头程订单列表">
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<QueryToolbar
|
<QueryToolbar
|
||||||
v-model="filters"
|
v-model="filters"
|
||||||
@@ -525,18 +558,18 @@ onMounted(() => void loadTenants())
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-alert v-if="tenantLoadError" type="warning" :closable="false" show-icon role="alert">
|
<template v-if="tenantLoadError || loadError" #state>
|
||||||
<template #title><div class="api-error-alert-title"><span>{{ tenantLoadError.message }}</span><ApiErrorMeta :details="tenantLoadError" /></div></template>
|
<el-alert v-if="tenantLoadError" type="warning" :closable="false" show-icon role="alert">
|
||||||
<el-button link type="warning" @click="loadTenants">重新加载企业列表</el-button>
|
<template #title><div class="api-error-alert-title"><span>{{ tenantLoadError.message }}</span><ApiErrorMeta :details="tenantLoadError" /></div></template>
|
||||||
</el-alert>
|
<el-button link type="warning" @click="loadTenants">重新加载企业列表</el-button>
|
||||||
|
</el-alert>
|
||||||
<template v-if="loadError" #state>
|
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
|
||||||
<el-alert 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>
|
<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-button link type="danger" @click="loadOrders">重新加载</el-button>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #default>
|
||||||
<section v-if="!loadError" class="surface-panel table-panel">
|
<section v-if="!loadError" class="surface-panel table-panel">
|
||||||
<DataTable
|
<DataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
@@ -554,14 +587,17 @@ onMounted(() => void loadTenants())
|
|||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<div class="table-actions order-row-actions">
|
<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 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-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-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 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 link type="primary" aria-label="更多订单操作" title="更多订单操作"><MoreHorizontal :size="17" /></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>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<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="isOverflowSecondaryAction(row as PlatformFirstLegOrderResource, 'express')" 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="isOverflowSecondaryAction(row as PlatformFirstLegOrderResource, '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, 'cancel')" command="cancel" divided><CircleX :size="15" />取消订单</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</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>
|
<template #express="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).expressCompany || '未维护' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).expressTrackingNo || '—' }}</span></div></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
|
</template>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -595,6 +632,7 @@ onMounted(() => void loadTenants())
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.detail-page { overflow: hidden; }
|
.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 { 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 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; }
|
.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,
|
tenantId: 0,
|
||||||
appCode: 'PLATFORM',
|
appCode: 'PLATFORM',
|
||||||
permissionType: 'MENU',
|
permissionType: 'MENU',
|
||||||
parentId: 5100000,
|
parentId: 0,
|
||||||
permissionCode: 'platform:customer:list',
|
permissionCode: 'platform:customer:list',
|
||||||
permissionName: '企业客户',
|
permissionName: '企业客户',
|
||||||
routePath: '/customers',
|
routePath: '/customers',
|
||||||
@@ -27,12 +27,20 @@ const permission: PlatformPermissionResource = {
|
|||||||
status: 'ACTIVE',
|
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()
|
const pinia = createPinia()
|
||||||
setActivePinia(pinia)
|
setActivePinia(pinia)
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
auth.isServerSession = true
|
auth.isServerSession = true
|
||||||
auth.permissions = ['platform:permission:list', 'platform:permission:create']
|
auth.permissions = permissionCodes
|
||||||
return shallowMount(PermissionsView, {
|
return shallowMount(PermissionsView, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [pinia],
|
plugins: [pinia],
|
||||||
@@ -43,12 +51,18 @@ function mountServerView() {
|
|||||||
FormSection: { template: '<section><slot /></section>' },
|
FormSection: { template: '<section><slot /></section>' },
|
||||||
AppDrawer: { template: '<section><slot /><slot name="footer" /></section>' },
|
AppDrawer: { template: '<section><slot /><slot name="footer" /></section>' },
|
||||||
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<slot name="details" /></div>' },
|
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-alert': { props: ['title'], template: '<div role="alert">{{ title }}<slot name="title" /></div>' },
|
||||||
'el-input': true,
|
'el-input': true,
|
||||||
'el-option': true,
|
'el-option': true,
|
||||||
'el-select': 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': true,
|
||||||
'el-table-column': true,
|
'el-table-column': true,
|
||||||
'el-input-number': 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')
|
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">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
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 StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.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 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)
|
onMounted(loadPermissions)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -246,8 +277,22 @@ onMounted(loadPermissions)
|
|||||||
|
|
||||||
<section v-if="!loadError" class="surface-panel table-panel">
|
<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="没有符合条件的权限资源">
|
<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 #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 #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-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 #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 #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>
|
<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>
|
<style scoped>
|
||||||
.permission-tree-tools { display: flex; align-items: center; gap: 16px; }
|
.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 { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||||
.tree-view-actions .el-button + .el-button { margin-left: 0; }
|
.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 > 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; }
|
.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) {
|
@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-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) {
|
@media (max-width: 420px) {
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ export const iamRoutes = [
|
|||||||
path: 'system/users',
|
path: 'system/users',
|
||||||
name: 'users',
|
name: 'users',
|
||||||
component: () => import('@/modules/iam/pages/InternalUsersView.vue'),
|
component: () => import('@/modules/iam/pages/InternalUsersView.vue'),
|
||||||
meta: { title: '用户管理', description: '统一管理平台和企业租户用户、状态及角色', permission: 'platform:user:list' },
|
meta: { title: '用户管理', description: '统一管理平台内部用户、状态及角色', permission: 'platform:user:list' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'system/roles',
|
path: 'system/roles',
|
||||||
name: 'roles',
|
name: 'roles',
|
||||||
component: () => import('@/modules/iam/pages/RolesView.vue'),
|
component: () => import('@/modules/iam/pages/RolesView.vue'),
|
||||||
meta: { title: '角色管理', description: '维护平台角色并配置菜单及操作权限', permission: 'platform:role:list' },
|
meta: { title: '角色权限', description: '维护平台角色并配置菜单及操作权限', permission: 'platform:role:list' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'system/permissions',
|
path: 'system/permissions',
|
||||||
|
|||||||
@@ -210,7 +210,8 @@ describe('InboundOrdersView', () => {
|
|||||||
expect(toolbarRows[1].classList.contains('inbound-stats-row')).toBe(true)
|
expect(toolbarRows[1].classList.contains('inbound-stats-row')).toBe(true)
|
||||||
expect(toolbarRows).toHaveLength(2)
|
expect(toolbarRows).toHaveLength(2)
|
||||||
expect(wrapper.findComponent(DataTable).find('.data-table__pagination-left').text()).toContain('当前显示 1 条 · 共 1 条')
|
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(wrapper.text()).not.toContain('全部入库单')
|
expect(wrapper.text()).not.toContain('全部入库单')
|
||||||
expect(tabButtons[0].attributes('aria-selected')).toBe('true')
|
expect(tabButtons[0].attributes('aria-selected')).toBe('true')
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ const detail = {
|
|||||||
describe('platform loading lists API', () => {
|
describe('platform loading lists API', () => {
|
||||||
beforeEach(() => vi.restoreAllMocks())
|
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 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.total).toBe(1)
|
||||||
expect(result.records[0]?.planId).toBe('1900000000000000001')
|
expect(result.records[0]?.planId).toBe('1900000000000000001')
|
||||||
expect(result.records[0]?.customers[0]?.accountId).toBe('1900000000000000002')
|
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: { records: [confirmed], page: '1', size: '20', total: '1', pages: '1' } })
|
||||||
.mockResolvedValueOnce({ code: '200', message: 'ok', data: { ...detail, summary: confirmed } })
|
.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')
|
const loaded = await getConfirmedLoadingList('100', '300')
|
||||||
|
|
||||||
expect(get.mock.calls[0]?.[0]).toBe('/platform/v1/first-leg/loading-lists')
|
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(get.mock.calls[1]?.[0]).toBe('/platform/v1/first-leg/loading-lists/100')
|
||||||
expect(page.records[0]?.createdBy).toBe('9')
|
expect(page.records[0]?.createdBy).toBe('9')
|
||||||
expect(loaded.summary.status).toBe('COMPLETED')
|
expect(loaded.summary.status).toBe('COMPLETED')
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export interface LoadingListPage<T extends LoadingListSummary = LoadingListSumma
|
|||||||
export interface LoadingListQuery {
|
export interface LoadingListQuery {
|
||||||
page: number
|
page: number
|
||||||
size: number
|
size: number
|
||||||
warehouseId: string
|
warehouseId?: string
|
||||||
keyword?: string
|
keyword?: string
|
||||||
createdFrom?: string
|
createdFrom?: string
|
||||||
createdTo?: string
|
createdTo?: string
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ describe('LoadingListsView', () => {
|
|||||||
expect(source.indexOf("key: 'actions'", source.indexOf("key: 'actions'") + 1)).toBeLessThan(source.indexOf("key: 'loadingListNo'"))
|
expect(source.indexOf("key: 'actions'", source.indexOf("key: 'actions'") + 1)).toBeLessThan(source.indexOf("key: 'loadingListNo'"))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires a warehouse before sending a list request', () => {
|
it('queries all warehouses when the warehouse filter is empty', () => {
|
||||||
expect(source).toContain("if (!warehouseId) return null")
|
expect(source).toContain("placeholder: loadingWarehouses.value ? '仓库加载中' : '全部仓库'")
|
||||||
expect(source).toContain("ElMessage.warning('请先选择仓库')")
|
expect(source).toContain('warehouseId: warehouseId || undefined')
|
||||||
expect(source).toContain("'请先选择仓库后查询'")
|
expect(source).not.toContain("ElMessage.warning('请先选择仓库')")
|
||||||
|
expect(source).not.toContain("'请先选择仓库后查询'")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the shared query, table, drawer, dialog and error components', () => {
|
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 canList = computed(() => access.can(listPermission.value))
|
||||||
const canView = computed(() => access.can(viewPermission.value))
|
const canView = computed(() => access.can(viewPermission.value))
|
||||||
const canEdit = computed(() => isPreload.value && access.can('platform:firstleg:preload:edit'))
|
const canEdit = computed(() => isPreload.value && access.can('platform:firstleg:preload:edit'))
|
||||||
const hasWarehouse = computed(() => Boolean(String(query.warehouseId || '').trim()))
|
|
||||||
|
|
||||||
const fields = computed<QueryField[]>(() => [
|
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 })),
|
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: 'keyword', type: 'input', label: '关键词', placeholder: isPreload.value ? '预装单号/集装箱号/柜号' : '装柜清单号/集装箱号/柜号', width: 250 },
|
||||||
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 260 },
|
{ 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 })
|
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: digits })
|
||||||
}
|
}
|
||||||
|
|
||||||
function toQuery(): LoadingListQuery | null {
|
function toQuery(): LoadingListQuery {
|
||||||
const warehouseId = String(query.warehouseId || '').trim()
|
const warehouseId = String(query.warehouseId || '').trim()
|
||||||
if (!warehouseId) return null
|
|
||||||
const range = Array.isArray(query.createdRange) ? query.createdRange.map(String) : []
|
const range = Array.isArray(query.createdRange) ? query.createdRange.map(String) : []
|
||||||
return {
|
return {
|
||||||
page: page.value,
|
page: page.value,
|
||||||
size: pageSize.value,
|
size: pageSize.value,
|
||||||
warehouseId,
|
warehouseId: warehouseId || undefined,
|
||||||
keyword: String(query.keyword || '').trim() || undefined,
|
keyword: String(query.keyword || '').trim() || undefined,
|
||||||
createdFrom: range[0] ? `${range[0]}T00:00:00` : undefined,
|
createdFrom: range[0] ? `${range[0]}T00:00:00` : undefined,
|
||||||
createdTo: range[1] ? `${range[1]}T23:59:59` : undefined,
|
createdTo: range[1] ? `${range[1]}T23:59:59` : undefined,
|
||||||
@@ -146,12 +143,6 @@ async function loadWarehouses() {
|
|||||||
async function load() {
|
async function load() {
|
||||||
if (!canList.value) return
|
if (!canList.value) return
|
||||||
const request = toQuery()
|
const request = toQuery()
|
||||||
if (!request) {
|
|
||||||
rows.value = []
|
|
||||||
total.value = 0
|
|
||||||
error.value = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
@@ -166,21 +157,13 @@ async function load() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function search() {
|
function search() {
|
||||||
if (!hasWarehouse.value) {
|
|
||||||
rows.value = []
|
|
||||||
total.value = 0
|
|
||||||
ElMessage.warning('请先选择仓库')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (page.value !== 1) page.value = 1
|
if (page.value !== 1) page.value = 1
|
||||||
else void load()
|
else void load()
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
rows.value = []
|
void load()
|
||||||
total.value = 0
|
|
||||||
error.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDetail() {
|
async function loadDetail() {
|
||||||
@@ -240,8 +223,11 @@ async function saveTransport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([page, pageSize], () => { if (hasWarehouse.value) void load() })
|
watch([page, pageSize], () => void load())
|
||||||
onMounted(() => void loadWarehouses())
|
onMounted(() => {
|
||||||
|
void loadWarehouses()
|
||||||
|
void load()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -277,7 +263,7 @@ onMounted(() => void loadWarehouses())
|
|||||||
:total="total"
|
:total="total"
|
||||||
:preference-key="`loading-lists-${mode}`"
|
:preference-key="`loading-lists-${mode}`"
|
||||||
row-key="planId"
|
row-key="planId"
|
||||||
:empty-text="hasWarehouse ? `暂无符合筛选条件的${title}` : '请先选择仓库后查询'"
|
:empty-text="`暂无符合筛选条件的${title}`"
|
||||||
:aria-label="title"
|
:aria-label="title"
|
||||||
>
|
>
|
||||||
<template #actions="{ row }"><el-button v-if="canView" link type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
|
<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'),
|
component: () => import('./pages/ConfirmedLoadingListsView.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '装柜清单',
|
title: '装柜清单',
|
||||||
description: '按仓库查看已完成且永久只读的装柜清单',
|
description: '查看全部仓库已完成且永久只读的装柜清单',
|
||||||
permission: 'platform:firstleg:loading-list:list',
|
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 ElementPlus from 'element-plus'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
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 QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||||
import PartnersView from './PartnersView.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', () => ({
|
vi.mock('@/modules/master-data/counterparties/api/platformPartners', () => ({
|
||||||
listPlatformPartners: vi.fn(),
|
listPlatformPartners: vi.fn(),
|
||||||
createPlatformPartner: vi.fn(),
|
createPlatformPartner: vi.fn(),
|
||||||
@@ -222,4 +226,11 @@ describe('PartnersView real API integration', () => {
|
|||||||
|
|
||||||
expect(vm.detail?.partnerCode).toBe('CUS-002')
|
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 { describe, expect, it } from 'vitest'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
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)
|
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', () => {
|
it('uses the table toolbar for create and keeps a single empty state', () => {
|
||||||
expect(source).toContain('<template #toolbar-actions>')
|
expect(source).toContain('<template #toolbar-actions>')
|
||||||
expect(source).not.toContain('<template #toolbar-start>')
|
expect(source).not.toContain('<template #toolbar-start>')
|
||||||
@@ -43,4 +64,10 @@ describe('TruckAppointmentsView', () => {
|
|||||||
expect(source).not.toContain('empty-hint')
|
expect(source).not.toContain('empty-hint')
|
||||||
expect(source.match(/empty-text=/g)).toHaveLength(1)
|
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) },
|
{ 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 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) {
|
function statusLabel(value: unknown) {
|
||||||
return typeof value === 'string' && value in truckAppointmentStatusLabels
|
return typeof value === 'string' && value in truckAppointmentStatusLabels
|
||||||
? truckAppointmentStatusLabels[value as TruckAppointmentStatus]
|
? 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 #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.driverPhone || '—' }}</small></template>
|
||||||
<template #store="{ row }">{{ row.storeName || '—' }}</template>
|
<template #store="{ row }">{{ row.storeName || '—' }}</template>
|
||||||
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
|
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
|
||||||
<template #actions="{ row }"><div class="table-actions"><el-button link type="primary" :aria-label="`查看 ${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>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe('UnifiedOrdersView contract', () => {
|
|||||||
|
|
||||||
it('provides labeled prototype-aligned advanced filters backed by real query fields', () => {
|
it('provides labeled prototype-aligned advanced filters backed by real query fields', () => {
|
||||||
for (const key of [
|
for (const key of [
|
||||||
'receivingWarehouseId', 'destinationWarehouseId', 'inboundCode', 'internalOrderStatus', 'expectedArrivalDate',
|
'receivingWarehouseId', 'destinationWarehouseId', 'inboundCode', 'expectedArrivalDate',
|
||||||
'feeReviewStatus', 'containerNo', 'cabinetNo', 'channelName', 'sailingDate', 'loaded', 'fullContainer',
|
'feeReviewStatus', 'containerNo', 'cabinetNo', 'channelName', 'sailingDate', 'loaded', 'fullContainer',
|
||||||
'measurementReviewStatus', 'fbaCode', 'createdRange',
|
'measurementReviewStatus', 'fbaCode', 'createdRange',
|
||||||
]) expect(source).toContain(`key: '${key}'`)
|
]) expect(source).toContain(`key: '${key}'`)
|
||||||
@@ -45,9 +45,11 @@ describe('UnifiedOrdersView contract', () => {
|
|||||||
expect(source).not.toContain("{ value: 'CANCELLED', label: '已取消' }")
|
expect(source).not.toContain("{ value: 'CANCELLED', label: '已取消' }")
|
||||||
expect(source).not.toContain("key: 'orderNo'")
|
expect(source).not.toContain("key: 'orderNo'")
|
||||||
expect(source.match(/key: 'inboundCode', type: 'input'/g)).toHaveLength(1)
|
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'")
|
expect(source).not.toContain("key: 'keyword'")
|
||||||
const prototypeFieldOrder = [
|
const prototypeFieldOrder = [
|
||||||
'receivingWarehouseId', 'destinationWarehouseId', 'inboundCode', 'internalOrderStatus', 'expectedArrivalDate',
|
'receivingWarehouseId', 'destinationWarehouseId', 'inboundCode', 'expectedArrivalDate',
|
||||||
'feeReviewStatus', 'tenantId', 'containerNo', 'cabinetNo', 'channelName', 'sailingDate', 'loaded',
|
'feeReviewStatus', 'tenantId', 'containerNo', 'cabinetNo', 'channelName', 'sailingDate', 'loaded',
|
||||||
'fullContainer', 'measurementReviewStatus', 'fbaCode', 'createdRange',
|
'fullContainer', 'measurementReviewStatus', 'fbaCode', 'createdRange',
|
||||||
]
|
]
|
||||||
@@ -56,8 +58,17 @@ describe('UnifiedOrdersView contract', () => {
|
|||||||
expect(source).toContain('preference-key="unified-orders-query-v2"')
|
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', () => {
|
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("{ key: 'selection', type: 'selection', width: 52, fixed: 'left'")
|
||||||
expect(source).toContain('批量审核接口待接入')
|
expect(source).toContain('批量审核接口待接入')
|
||||||
expect(source).toContain('@selection-change="handleSelectionChange"')
|
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)
|
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', () => {
|
it('keeps only confirmed business actions as explicit boundaries', () => {
|
||||||
expect(source).toContain('nextActionBoundary')
|
expect(source).toContain('nextActionBoundary')
|
||||||
expect(source).not.toContain('class="action-boundary"')
|
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('<ChevronDown v-else :size="18" />')
|
||||||
expect(source).toContain("{ key: 'girthCm', label: '箱规周长(cm)' }")
|
expect(source).toContain("{ key: 'girthCm', label: '箱规周长(cm)' }")
|
||||||
expect(source).toContain('runMoreAction($event, row as UnifiedOrderResource)')
|
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("can('platform:order:note')")
|
||||||
expect(source).toContain('await loadOrders()')
|
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', () => {
|
it('renders order detail as a routed full page instead of a drawer', () => {
|
||||||
expect(source).toContain('<template v-if="orderId">')
|
expect(source).toContain('<template v-if="orderId">')
|
||||||
expect(source).toContain('class="order-detail-header"')
|
expect(source).toContain('class="order-detail-header"')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { ElMessage } from 'element-plus'
|
||||||
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, History, ListChecks, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
|
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, History, ListChecks, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
@@ -42,7 +42,6 @@ type FilterModel = QueryModel & {
|
|||||||
receivingWarehouseId: string
|
receivingWarehouseId: string
|
||||||
destinationWarehouseId: string
|
destinationWarehouseId: string
|
||||||
inboundCode: string
|
inboundCode: string
|
||||||
internalOrderStatus: string
|
|
||||||
expectedArrivalDate: string
|
expectedArrivalDate: string
|
||||||
feeReviewStatus: string
|
feeReviewStatus: string
|
||||||
channelName: 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: '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: '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: '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[]>(() => [
|
const advancedQueryFields = computed<QueryField[]>(() => [
|
||||||
@@ -184,7 +179,7 @@ const queryFields = computed(() => showAdvancedFilters.value
|
|||||||
: primaryQueryFields.value)
|
: primaryQueryFields.value)
|
||||||
|
|
||||||
const columns: DataTableColumn[] = [
|
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: 'selection', type: 'selection', width: 52, fixed: 'left', align: 'center', selectable: canBulkReview },
|
||||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, slotName: 'field' },
|
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, slotName: 'field' },
|
||||||
{ key: 'internalOrderStatus', prop: 'internalOrderStatus', label: '订单状态', minWidth: 130, slotName: 'orderStatus' },
|
{ key: 'internalOrderStatus', prop: 'internalOrderStatus', label: '订单状态', minWidth: 130, slotName: 'orderStatus' },
|
||||||
@@ -243,7 +238,7 @@ const columns: DataTableColumn[] = [
|
|||||||
function emptyFilters(): FilterModel {
|
function emptyFilters(): FilterModel {
|
||||||
return {
|
return {
|
||||||
tenantId: '', mainStatus: '', receivingWarehouseId: '', destinationWarehouseId: '',
|
tenantId: '', mainStatus: '', receivingWarehouseId: '', destinationWarehouseId: '',
|
||||||
inboundCode: '', internalOrderStatus: '', expectedArrivalDate: '', feeReviewStatus: '', channelName: '',
|
inboundCode: '', expectedArrivalDate: '', feeReviewStatus: '', channelName: '',
|
||||||
containerNo: '', cabinetNo: '', sailingDate: '', loaded: '', fullContainer: '', measurementReviewStatus: '',
|
containerNo: '', cabinetNo: '', sailingDate: '', loaded: '', fullContainer: '', measurementReviewStatus: '',
|
||||||
fbaCode: '', createdRange: [],
|
fbaCode: '', createdRange: [],
|
||||||
}
|
}
|
||||||
@@ -634,7 +629,6 @@ async function loadOrders() {
|
|||||||
receivingWarehouseId: filters.value.receivingWarehouseId || undefined,
|
receivingWarehouseId: filters.value.receivingWarehouseId || undefined,
|
||||||
destinationWarehouseId: filters.value.destinationWarehouseId || undefined,
|
destinationWarehouseId: filters.value.destinationWarehouseId || undefined,
|
||||||
inboundCode: trimmed(filters.value.inboundCode),
|
inboundCode: trimmed(filters.value.inboundCode),
|
||||||
internalOrderStatus: filters.value.internalOrderStatus || undefined,
|
|
||||||
expectedArrivalDate: filters.value.expectedArrivalDate || undefined,
|
expectedArrivalDate: filters.value.expectedArrivalDate || undefined,
|
||||||
feeReviewStatus: filters.value.feeReviewStatus || undefined,
|
feeReviewStatus: filters.value.feeReviewStatus || undefined,
|
||||||
channelName: trimmed(filters.value.channelName),
|
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) {
|
function runMoreAction(command: string | number | object, row: UnifiedOrderResource) {
|
||||||
if (command === 'note') openNoteDialog(row)
|
if (command === 'note') openNoteDialog(row)
|
||||||
if (command === 'approve') openApproval(row)
|
if (command === 'approve') openApproval(row)
|
||||||
@@ -982,6 +1010,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ListPageLayout ariaLabel="统一订单列表">
|
<ListPageLayout ariaLabel="统一订单列表">
|
||||||
|
<template #toolbar>
|
||||||
<div class="orders-query-panel">
|
<div class="orders-query-panel">
|
||||||
<div class="status-tabs" role="tablist" aria-label="订单主状态">
|
<div class="status-tabs" role="tablist" aria-label="订单主状态">
|
||||||
<button
|
<button
|
||||||
@@ -998,20 +1027,25 @@ onMounted(() => {
|
|||||||
>{{ tab.label }}</button>
|
>{{ tab.label }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-mode-actions">
|
<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">
|
||||||
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
|
<template #actions>
|
||||||
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
|
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
|
||||||
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
|
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
|
||||||
</el-button>
|
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
|
||||||
</div>
|
</el-button>
|
||||||
|
</template>
|
||||||
<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>
|
||||||
</div>
|
</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>
|
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||||
</LoadErrorState>
|
</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">
|
<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>
|
<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>
|
<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 }">
|
<template #actions="{ row }">
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<el-button link type="primary" @click="openDetail(row as UnifiedOrderResource)"><Eye :size="15" />详情</el-button>
|
<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 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-button link type="primary" aria-label="更多订单操作" title="更多操作"><MoreHorizontal :size="17" /></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>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item v-if="can('platform:order:note')" command="note"><StickyNote :size="15" />添加备注</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-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-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
@@ -1047,6 +1079,7 @@ onMounted(() => {
|
|||||||
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条</span></template>
|
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条</span></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1070,7 +1103,6 @@ onMounted(() => {
|
|||||||
.page-intro :deep(.el-button) { flex: 0 0 auto; }
|
.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; }
|
.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; }
|
.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 { 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::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }
|
||||||
.status-tab:hover { color: var(--cc-primary); background: #f6f9fd; }
|
.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 { color: var(--cc-primary); font-weight: 600; }
|
||||||
.status-tab.is-active::after { background: var(--cc-primary); }
|
.status-tab.is-active::after { background: var(--cc-primary); }
|
||||||
.orders-query-toolbar { border: 0; }
|
.orders-query-toolbar { border: 0; }
|
||||||
.orders-load-error { margin-top: 0; }
|
|
||||||
.table-card { overflow: hidden; }
|
.table-card { overflow: hidden; }
|
||||||
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||||
.row-actions :deep(.el-button) { margin-left: 0; }
|
.row-actions :deep(.el-button) { margin-left: 0; }
|
||||||
|
|||||||
@@ -102,20 +102,20 @@ const occupiedLocation: PlatformLocationResource = {
|
|||||||
version: 5,
|
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('localStorage', createMemoryStorage())
|
||||||
vi.stubGlobal('sessionStorage', createMemoryStorage())
|
vi.stubGlobal('sessionStorage', createMemoryStorage())
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
setActivePinia(pinia)
|
setActivePinia(pinia)
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
auth.token = 'token'
|
auth.token = 'token'
|
||||||
auth.permissions = [
|
auth.permissions = permissions
|
||||||
'platform:location:list',
|
|
||||||
'platform:location:create',
|
|
||||||
'platform:location:edit',
|
|
||||||
'platform:location:status',
|
|
||||||
'platform:location:delete',
|
|
||||||
]
|
|
||||||
useAccessPreviewStore().initializeAfterLogin()
|
useAccessPreviewStore().initializeAfterLogin()
|
||||||
const wrapper = mount(LocationsView, {
|
const wrapper = mount(LocationsView, {
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
@@ -254,6 +254,51 @@ describe('LocationsView', () => {
|
|||||||
expect(bodyRows[1]?.text()).not.toContain('更多')
|
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 () => {
|
it('does not offer editing for a location outside a domestic warehouse', async () => {
|
||||||
vi.mocked(listPlatformLocations).mockResolvedValue({
|
vi.mocked(listPlatformLocations).mockResolvedValue({
|
||||||
records: [{ ...availableLocation, warehouseId: fbaWarehouse.id }, availableLocation], page: 1, size: 20, total: 2, pages: 1,
|
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'
|
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) {
|
function actionError(error: unknown, fallback: string) {
|
||||||
const details = getApiErrorDetails(error, fallback)
|
const details = getApiErrorDetails(error, fallback)
|
||||||
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
|
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
|
||||||
@@ -562,19 +573,31 @@ onMounted(() => {
|
|||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@click="loadDetail(row as PlatformLocationResource, 'edit')"
|
@click="loadDetail(row as PlatformLocationResource, 'edit')"
|
||||||
><Pencil :size="15" />编辑</el-button>
|
><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
|
<el-dropdown
|
||||||
v-if="canChangeStatus(row as PlatformLocationResource) || canDelete(row as PlatformLocationResource)"
|
v-if="hasMoreLocationActions(row as PlatformLocationResource)"
|
||||||
trigger="click"
|
trigger="click"
|
||||||
|
popper-class="location-action-menu"
|
||||||
:disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
|
:disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
|
||||||
@command="handleMoreAction(row as PlatformLocationResource, String($event))"
|
@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>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item v-if="canChangeStatus(row as PlatformLocationResource)" command="status">
|
<el-dropdown-item v-if="canDelete(row as PlatformLocationResource)" command="delete" class="location-action-menu__danger">
|
||||||
<Power :size="15" />{{ (row as PlatformLocationResource).status === 'AVAILABLE' ? '停用' : '启用' }}
|
|
||||||
</el-dropdown-item>
|
|
||||||
<el-dropdown-item v-if="canDelete(row as PlatformLocationResource)" command="delete" divided>
|
|
||||||
<Trash2 :size="15" />删除
|
<Trash2 :size="15" />删除
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</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 { 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 > span { color: var(--cc-text); font-size: 13px; font-weight: 600; }
|
||||||
.readonly-status small { color: var(--cc-muted); font-size: 11px; }
|
.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) {
|
@media (max-width: 720px) {
|
||||||
.detail-identity { grid-template-columns: 44px minmax(0, 1fr); }
|
.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('Gateway unavailable')
|
||||||
expect(wrapper.text()).toContain('重新查询')
|
expect(wrapper.text()).toContain('重新查询')
|
||||||
expect(wrapper.text()).not.toContain('演示物流园')
|
expect(wrapper.text()).not.toContain('演示物流园')
|
||||||
|
expect(wrapper.find('.table-panel .data-table').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps create actions behind permissions and preserves a read-only table state', async () => {
|
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__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__business-actions').text()).toBe('新增订单')
|
||||||
|
expect(wrapper.get('.data-table__utility-actions').text()).toContain('辅助操作')
|
||||||
expect(wrapper.get('.data-table__toolbar-end').text()).toBe('辅助操作')
|
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.get('.data-table__toolbar').classes()).toContain('is-compact')
|
||||||
expect(wrapper.find('.data-table__toolbar-start').exists()).toBe(false)
|
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('新增订单')
|
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'] }"
|
:class="{ 'is-compact': !$slots['toolbar-start'] }"
|
||||||
>
|
>
|
||||||
<div v-if="$slots['toolbar-start']" class="data-table__toolbar-start"><slot name="toolbar-start" /></div>
|
<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>
|
<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">
|
<div v-if="preferenceEnabled || $slots['toolbar-end']" class="data-table__utility-actions">
|
||||||
<template #reference>
|
<el-popover v-if="preferenceEnabled" placement="bottom-end" :width="340" trigger="click" popper-class="cc-layout-preference-popper">
|
||||||
<el-button aria-label="列设置" title="列设置"><Settings2 :size="16" /></el-button>
|
<template #reference>
|
||||||
</template>
|
<el-button aria-label="列设置" title="列设置"><Settings2 :size="16" /></el-button>
|
||||||
<LayoutPreferenceList
|
</template>
|
||||||
:items="preferenceItems"
|
<LayoutPreferenceList
|
||||||
allow-visibility
|
:items="preferenceItems"
|
||||||
@move="moveColumn"
|
allow-visibility
|
||||||
@visibility="setColumnVisibility"
|
@move="moveColumn"
|
||||||
@reset="resetPreference"
|
@visibility="setColumnVisibility"
|
||||||
/>
|
@reset="resetPreference"
|
||||||
</el-popover>
|
/>
|
||||||
<div v-if="$slots['toolbar-end']" class="data-table__toolbar-end"><slot name="toolbar-end" /></div>
|
</el-popover>
|
||||||
|
<div v-if="$slots['toolbar-end']" class="data-table__toolbar-end"><slot name="toolbar-end" /></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-table ref="tableRef" v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :aria-busy="loading">
|
<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(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-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__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__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 { 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; }
|
.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-buttons { gap: 4px; }
|
||||||
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; }
|
.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; }
|
.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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user