优化
This commit is contained in:
@@ -3,13 +3,13 @@ import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const pages = [
|
||||
['src/modules/container-loading/pages/ContainersView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无货柜"', 'statusText(row.planStatus)', 'platform:firstleg:container:view'],
|
||||
['src/modules/loading-lists/components/LoadingListsView.vue', ':loading="loading"', 'LoadErrorState', ':empty-text="`暂无符合筛选条件的${title}`"', 'loadingListStatusLabel(row.status)', 'canView'],
|
||||
['src/modules/ocean-bookings/pages/OceanBookingsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的订舱"', 'oceanBookingStatusLabel(row.status)', 'platform:firstleg:ocean-booking:view'],
|
||||
['src/modules/port-schedules/pages/PortSchedulesView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合条件的船期"', "row.expired ? '已过期' : '有效'", 'platform:firstleg:port-schedule:view'],
|
||||
['src/modules/trucking/pages/TruckAppointmentsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的拖车预约"', 'statusLabel(row.status)', 'platform:firstleg:truck-appointment:view'],
|
||||
['src/modules/customs/pages/CustomsCasesView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无报关协同记录"', 'customsStatusLabel(row.status)', 'platform:firstleg:customs:view'],
|
||||
['src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合条件的出仓记录"', 'outboundDispatchStatusLabel(row.dispatchStatus)', 'platform:firstleg:outbound-dispatch:view'],
|
||||
['src/modules/container-loading/pages/ContainersView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无货柜"', 'statusText(row.planStatus)', 'platform:firstleg:container:view', '@retry="loadRows"'],
|
||||
['src/modules/loading-lists/components/LoadingListsView.vue', ':loading="loading"', 'LoadErrorState', ':empty-text="`暂无符合筛选条件的${title}`"', 'loadingListStatusLabel(row.status)', 'canView', '@retry="load"'],
|
||||
['src/modules/ocean-bookings/pages/OceanBookingsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的订舱"', 'oceanBookingStatusLabel(row.status)', 'platform:firstleg:ocean-booking:view', '@retry="load"'],
|
||||
['src/modules/port-schedules/pages/PortSchedulesView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合条件的船期"', "row.expired ? '已过期' : '有效'", 'platform:firstleg:port-schedule:view', '@retry="load"'],
|
||||
['src/modules/trucking/pages/TruckAppointmentsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的拖车预约"', 'statusLabel(row.status)', 'platform:firstleg:truck-appointment:view', '@retry="load"'],
|
||||
['src/modules/customs/pages/CustomsCasesView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无报关协同记录"', 'customsStatusLabel(row.status)', 'platform:firstleg:customs:view', '@retry="load"'],
|
||||
['src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合条件的出仓记录"', 'outboundDispatchStatusLabel(row.dispatchStatus)', 'platform:firstleg:outbound-dispatch:view', '@retry="load"'],
|
||||
] as const
|
||||
|
||||
function source(path: string) {
|
||||
@@ -17,13 +17,14 @@ function source(path: string) {
|
||||
}
|
||||
|
||||
describe('domestic outbound list state contracts', () => {
|
||||
it.each(pages)('%s exposes loading, error, empty, translated status and view-permission states', (path, loading, error, empty, status, viewPermission) => {
|
||||
it.each(pages)('%s exposes loading, error, empty, translated status, view permission and retry states', (path, loading, error, empty, status, viewPermission, retry) => {
|
||||
const content = source(path)
|
||||
expect(content).toContain(loading)
|
||||
expect(content).toContain(error)
|
||||
expect(content).toContain(empty)
|
||||
expect(content).toContain(status)
|
||||
expect(content).toContain(viewPermission)
|
||||
expect(content).toContain(retry)
|
||||
})
|
||||
|
||||
it('keeps list component state when route details return to the cached workspace list', () => {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listPlatformPermissions, type PlatformPermissionResource } from '@/modules/iam/api/platformIam'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import PermissionsView from './PermissionsView.vue'
|
||||
|
||||
const permissionsSource = readFileSync(resolve(process.cwd(), 'src/modules/iam/pages/PermissionsView.vue'), 'utf8')
|
||||
|
||||
vi.mock('@/modules/iam/api/platformIam', () => ({
|
||||
changePlatformPermissionStatus: vi.fn(),
|
||||
createPlatformPermission: vi.fn(),
|
||||
@@ -81,6 +85,12 @@ describe('PermissionsView IAM mode', () => {
|
||||
vi.mocked(listPlatformPermissions).mockReset()
|
||||
})
|
||||
|
||||
it('keeps technical permission fields out of the default list and labels the table', () => {
|
||||
expect(permissionsSource).toContain("{ key: 'code', label: '权限编码', minWidth: 220, slotName: 'code', defaultVisible: false }")
|
||||
expect(permissionsSource).toContain("{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint', defaultVisible: false }")
|
||||
expect(permissionsSource).toContain('aria-label="平台权限资源列表"')
|
||||
})
|
||||
|
||||
it('loads the platform permission resource tree from IAM', async () => {
|
||||
vi.mocked(listPlatformPermissions).mockResolvedValue([permission])
|
||||
|
||||
|
||||
@@ -276,7 +276,7 @@ onMounted(loadPermissions)
|
||||
</template>
|
||||
|
||||
<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" aria-label="平台权限资源列表" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
|
||||
<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><span class="permission-protection-hint">受保护资源不可编辑、停用或删除</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div></template>
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></template>
|
||||
<template #actions="{ row }">
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPlatformRole, listPlatformInternalUsers, listPlatformPermissions, listPlatformRoles, type PlatformRoleResource } from '@/modules/iam/api/platformIam'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import RolesView from './RolesView.vue'
|
||||
|
||||
const rolesSource = readFileSync(resolve(process.cwd(), 'src/modules/iam/pages/RolesView.vue'), 'utf8')
|
||||
|
||||
vi.mock('@/modules/iam/api/platformIam', () => ({
|
||||
createPlatformRole: vi.fn(),
|
||||
listPlatformInternalUsers: vi.fn(),
|
||||
@@ -84,6 +88,13 @@ describe('RolesView', () => {
|
||||
expect(wrapper.get('[data-testid="permission-tree"]').attributes('data-check-strictly')).toBe('true')
|
||||
})
|
||||
|
||||
it('keeps the role code available but hidden from the default list layout', () => {
|
||||
expect(rolesSource).toContain("{ key: 'roleCode', prop: 'code', label: '角色编码', minWidth: 180, showOverflowTooltip: true, defaultVisible: false }")
|
||||
expect(rolesSource).toContain('<el-form-item label="角色编码"')
|
||||
expect(rolesSource).not.toContain('<small>{{ (row as Role).code }}</small>')
|
||||
expect(rolesSource).toContain('aria-label="平台角色列表"')
|
||||
})
|
||||
|
||||
it('loads platform roles from IAM for a server session', async () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
@@ -80,7 +80,8 @@ const filteredRows = computed(() => roles.value.filter((item) => {
|
||||
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
const roleColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 235, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'role', label: '角色', minWidth: 210, slotName: 'role' },
|
||||
{ key: 'role', label: '角色名称', minWidth: 180, slotName: 'role' },
|
||||
{ key: 'roleCode', prop: 'code', label: '角色编码', minWidth: 180, showOverflowTooltip: true, defaultVisible: false },
|
||||
{ key: 'description', prop: 'description', label: '说明', minWidth: 240, showOverflowTooltip: true },
|
||||
{ key: 'permissionSummary', label: '授权范围', minWidth: 170, slotName: 'permissionSummary' },
|
||||
{ key: 'userCount', prop: 'userCount', label: '用户数', width: 86, align: 'right' },
|
||||
@@ -246,10 +247,10 @@ onMounted(loadRoles)
|
||||
</template>
|
||||
|
||||
<section v-if="!loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" preference-key="roles-table" :loading="loading" :total="filteredRows.length" row-key="id" empty-text="没有符合条件的角色">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" preference-key="roles-table" :loading="loading" :total="filteredRows.length" row-key="id" aria-label="平台角色列表" empty-text="没有符合条件的角色">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增角色</el-button></template>
|
||||
<template #actions="{ row }"><div v-if="canEdit || canGrant || access.can('platform:role:status') || access.can('platform:role:delete')" class="table-actions"><el-button v-if="canEdit || canGrant" link type="primary" :disabled="rowActionId === (row as Role).id || (row as Role).builtIn" @click="openEdit(row as Role)"><Pencil :size="15" />配置</el-button><el-button v-if="access.can('platform:role:status')" link :type="(row as Role).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Role).id" :disabled="(row as Role).builtIn" @click="toggleStatus(row as Role)"><Power :size="15" />{{ (row as Role).status === '启用' ? '停用' : '启用' }}</el-button><el-button v-if="access.can('platform:role:delete')" link type="danger" :disabled="(row as Role).builtIn || (row as Role).userCount > 0 || rowActionId === (row as Role).id" @click="removeRole(row as Role)"><Trash2 :size="15" />删除</el-button></div><span v-else class="secondary-text">仅查看</span></template>
|
||||
<template #role="{ row }"><div class="role-cell"><span><ShieldCheck :size="18" /></span><div><strong>{{ (row as Role).name }}</strong><small>{{ (row as Role).code }}</small></div></div></template>
|
||||
<template #role="{ row }"><div class="role-cell"><span><ShieldCheck :size="18" /></span><div><strong>{{ (row as Role).name }}</strong></div></div></template>
|
||||
<template #permissionSummary="{ row }"><div class="primary-cell"><strong>{{ permissionSummary(row as Role) }}</strong><span>平台 IAM 权限</span></div></template>
|
||||
<template #roleType="{ row }"><el-tag :type="(row as Role).builtIn ? 'warning' : 'info'" effect="plain" size="small">{{ (row as Role).builtIn ? '系统内置' : '自定义' }}</el-tag></template>
|
||||
<template #status="{ row }"><StatusBadge :status="(row as Role).status" /></template>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import {
|
||||
downloadInboundMeasurementImage,
|
||||
createManualCartonScan,
|
||||
getInboundMeasurement,
|
||||
submitInboundMeasurement,
|
||||
updateConfirmedPackageCount,
|
||||
@@ -15,6 +16,7 @@ import type { InboundMeasurementDetail } from './types'
|
||||
import InboundMeasurementPanel from './InboundMeasurementPanel.vue'
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
createManualCartonScan: vi.fn(),
|
||||
downloadInboundMeasurementImage: vi.fn(),
|
||||
getInboundMeasurement: vi.fn(),
|
||||
saveInboundMeasurementDraft: vi.fn(),
|
||||
@@ -133,6 +135,10 @@ describe('InboundMeasurementPanel', () => {
|
||||
measurementId: '9001', version: 2, processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING',
|
||||
recommendedPalletType: 'STANDARD', feeId: null, feeNo: null, feeStatus: null,
|
||||
})
|
||||
vi.mocked(createManualCartonScan).mockReset().mockResolvedValue({
|
||||
measurementId: '9001', measurementVersion: 3, itemId: '9102', boxCode: 'RK202608040001#10-2',
|
||||
confirmedPackageCount: 1,
|
||||
})
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
||||
})
|
||||
|
||||
@@ -151,6 +157,19 @@ describe('InboundMeasurementPanel', () => {
|
||||
expect(wrapper.text()).toContain('预览图片')
|
||||
})
|
||||
|
||||
it('opens the manual carton entry form in an editable measurement state', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
const button = wrapper.findAll('button').find((candidate) => candidate.text().includes('手动录入箱规'))
|
||||
expect(button?.attributes('disabled')).toBeUndefined()
|
||||
await button?.trigger('click')
|
||||
expect(wrapper.text()).toContain('手动录入箱规')
|
||||
|
||||
expect(document.body.textContent).toContain('箱号')
|
||||
expect(document.body.textContent).toContain('测量长(cm)')
|
||||
expect(document.body.textContent).toContain('测量图片')
|
||||
expect(document.body.textContent).toContain('保存箱规')
|
||||
})
|
||||
|
||||
it('does not expose unknown measurement source codes', async () => {
|
||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ sourceType: 'LEGACY_DEVICE' }))
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { AlertTriangle, Image, RefreshCw, Ruler, Save, Send } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import { AlertTriangle, Image, Plus, RefreshCw, Ruler, Save, Send, Upload } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -9,7 +9,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { inboundProcessStatusLabels, inboundStatusLabel } from '../status'
|
||||
import { downloadInboundMeasurementImage, getInboundMeasurement, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
|
||||
import { createManualCartonScan, downloadInboundMeasurementImage, getInboundMeasurement, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
|
||||
import type {
|
||||
InboundMeasurementDetail,
|
||||
InboundMeasurementItem,
|
||||
@@ -42,10 +42,25 @@ interface MeasurementFormModel {
|
||||
items: EditableMeasurementItem[]
|
||||
}
|
||||
|
||||
interface ManualCartonFormModel {
|
||||
boxCode: string
|
||||
length: number | null
|
||||
width: number | null
|
||||
height: number | null
|
||||
weight: number | null
|
||||
scannedShipmentId: string
|
||||
machineNo: string
|
||||
measuredAt: string
|
||||
}
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const submitting = ref(false)
|
||||
const manualEntryOpen = ref(false)
|
||||
const manualEntrySubmitting = ref(false)
|
||||
const manualEntryImage = ref<File | null>(null)
|
||||
const manualEntryErrors = ref<string[]>([])
|
||||
const detail = ref<InboundMeasurementDetail | null>(null)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const operationError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -69,6 +84,17 @@ const form = reactive<MeasurementFormModel>({
|
||||
items: [],
|
||||
})
|
||||
|
||||
const manualForm = reactive<ManualCartonFormModel>({
|
||||
boxCode: '',
|
||||
length: null,
|
||||
width: null,
|
||||
height: null,
|
||||
weight: null,
|
||||
scannedShipmentId: '',
|
||||
machineNo: 'MANUAL-ADMIN',
|
||||
measuredAt: '',
|
||||
})
|
||||
|
||||
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
|
||||
const canEdit = computed(() => access.can('platform:firstleg:inbound:measure:edit'))
|
||||
const canSubmit = computed(() => access.can('platform:firstleg:inbound:measure:submit'))
|
||||
@@ -77,6 +103,8 @@ const hasContext = computed(() => Boolean(customerId.value && props.inboundId))
|
||||
const countEditableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
|
||||
const submitEditableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
|
||||
const mayMaintain = computed(() => canEdit.value && countEditableProcess.value && !loading.value && !submitting.value)
|
||||
const manualEntryEditableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
|
||||
const mayCreateManualEntry = computed(() => canEdit.value && manualEntryEditableProcess.value && !loading.value && !saving.value && !submitting.value && !isDirty.value)
|
||||
const confirmedPackageCount = computed(() => form.items.reduce((sum, item) => sum + (Number.isFinite(item.packageCount) ? item.packageCount : 0), 0))
|
||||
const expectedPackageCount = computed(() => detail.value?.originalPackageCount ?? null)
|
||||
const packageCountDifference = computed(() => expectedPackageCount.value == null ? null : confirmedPackageCount.value - expectedPackageCount.value)
|
||||
@@ -108,6 +136,81 @@ function nextItemKey() {
|
||||
return `measurement-item-${itemSequence}`
|
||||
}
|
||||
|
||||
function localDateTimeNow() {
|
||||
const now = new Date()
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
|
||||
}
|
||||
|
||||
function resetManualEntry() {
|
||||
manualForm.boxCode = ''
|
||||
manualForm.length = null
|
||||
manualForm.width = null
|
||||
manualForm.height = null
|
||||
manualForm.weight = null
|
||||
manualForm.scannedShipmentId = ''
|
||||
manualForm.machineNo = 'MANUAL-ADMIN'
|
||||
manualForm.measuredAt = localDateTimeNow()
|
||||
manualEntryImage.value = null
|
||||
manualEntryErrors.value = []
|
||||
}
|
||||
|
||||
function openManualEntry() {
|
||||
if (!mayCreateManualEntry.value) {
|
||||
if (isDirty.value) ElMessage.warning('请先保存当前确认箱数,再录入新的箱规')
|
||||
return
|
||||
}
|
||||
resetManualEntry()
|
||||
manualEntryOpen.value = true
|
||||
}
|
||||
|
||||
function selectManualImage(uploadFile: UploadFile) {
|
||||
const file = uploadFile.raw
|
||||
manualEntryImage.value = file || null
|
||||
}
|
||||
|
||||
function validateManualEntry() {
|
||||
const errors: string[] = []
|
||||
if (!manualForm.boxCode.trim()) errors.push('请输入箱号')
|
||||
if (manualForm.boxCode.trim().length > 128) errors.push('箱号不能超过 128 个字符')
|
||||
if (!(manualForm.length != null && manualForm.length > 0)) errors.push('测量长必须大于 0')
|
||||
if (!(manualForm.width != null && manualForm.width > 0)) errors.push('测量宽必须大于 0')
|
||||
if (!(manualForm.height != null && manualForm.height > 0)) errors.push('测量高必须大于 0')
|
||||
if (!(manualForm.weight != null && manualForm.weight > 0)) errors.push('单箱毛重必须大于 0')
|
||||
if (!manualForm.measuredAt) errors.push('请选择测量时间')
|
||||
if (!manualEntryImage.value) errors.push('请上传当前箱测量图片')
|
||||
if (manualEntryImage.value && manualEntryImage.value.size > 5 * 1024 * 1024) errors.push('测量图片不能超过 5 MB')
|
||||
manualEntryErrors.value = errors
|
||||
return errors.length === 0
|
||||
}
|
||||
|
||||
async function submitManualEntry() {
|
||||
if (!mayCreateManualEntry.value || !validateManualEntry() || manualForm.weight == null) return
|
||||
manualEntrySubmitting.value = true
|
||||
operationError.value = null
|
||||
try {
|
||||
await createManualCartonScan(customerId.value, props.inboundId, {
|
||||
boxCode: manualForm.boxCode.trim(),
|
||||
length: manualForm.length!,
|
||||
width: manualForm.width!,
|
||||
height: manualForm.height!,
|
||||
weight: manualForm.weight,
|
||||
scannedShipmentId: manualForm.scannedShipmentId.trim() || null,
|
||||
machineNo: manualForm.machineNo.trim() || 'MANUAL-ADMIN',
|
||||
measuredAt: manualForm.measuredAt,
|
||||
}, manualEntryImage.value!)
|
||||
manualEntryOpen.value = false
|
||||
ElMessage.success('箱规已录入,服务端计算结果已更新')
|
||||
await loadMeasurement()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
const details = setOperationError(error, '手动箱规录入失败,请检查后重试')
|
||||
ElMessage.error(details.message)
|
||||
} finally {
|
||||
manualEntrySubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
|
||||
return {
|
||||
itemId: String(item.id),
|
||||
@@ -453,6 +556,7 @@ onBeforeUnmount(releaseImagePreview)
|
||||
<section class="measurement-section measurement-items-section">
|
||||
<div class="measurement-section__heading">
|
||||
<div><h3>扫描箱测量数据明细</h3><p>每条记录对应一个实际扫描箱;人工只确认该条记录代表的箱数,设备实测值不可在此修改。</p></div>
|
||||
<el-button v-if="canEdit" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" />手动录入箱规</el-button>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!form.items.length" description="暂无扫描箱测量数据" />
|
||||
@@ -503,6 +607,34 @@ onBeforeUnmount(releaseImagePreview)
|
||||
<el-empty v-else description="暂无可预览图片" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="manualEntryOpen" title="手动录入箱规" width="min(680px, 92vw)" destroy-on-close>
|
||||
<el-alert v-if="manualEntryErrors.length" type="error" :closable="false" show-icon class="manual-entry-alert" title="请修正以下问题">
|
||||
<ul><li v-for="error in manualEntryErrors" :key="error">{{ error }}</li></ul>
|
||||
</el-alert>
|
||||
<el-form label-position="top" class="manual-entry-form" @submit.prevent="submitManualEntry">
|
||||
<div class="measurement-form-grid">
|
||||
<el-form-item label="箱号 *"><el-input v-model="manualForm.boxCode" placeholder="请输入已打印箱唛上的箱号" /></el-form-item>
|
||||
<el-form-item label="测量长(cm) *"><el-input-number v-model="manualForm.length" :min="0.001" :precision="3" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="测量宽(cm) *"><el-input-number v-model="manualForm.width" :min="0.001" :precision="3" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="测量高(cm) *"><el-input-number v-model="manualForm.height" :min="0.001" :precision="3" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="单箱毛重(kg) *"><el-input-number v-model="manualForm.weight" :min="0.001" :precision="3" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="测量时间 *"><el-date-picker v-model="manualForm.measuredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm:ss" /></el-form-item>
|
||||
<el-form-item label="设备/来源编号"><el-input v-model="manualForm.machineNo" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="Shipment ID"><el-input v-model="manualForm.scannedShipmentId" maxlength="64" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="当前箱测量图片 *">
|
||||
<el-upload class="manual-entry-upload" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectManualImage" :on-remove="() => { manualEntryImage = null }">
|
||||
<el-button><Upload :size="15" />选择图片</el-button>
|
||||
<template #tip><div class="el-upload__tip">支持图片格式,大小不超过 5 MB</div></template>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="manualEntrySubmitting" @click="manualEntryOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="manualEntrySubmitting" @click="submitManualEntry">保存箱规</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -545,6 +677,10 @@ onBeforeUnmount(releaseImagePreview)
|
||||
.measurement-image-field :deep(.el-button) { width: 100%; margin: 0; }
|
||||
.measurement-image-preview { display: grid; min-height: 240px; place-items: center; }
|
||||
.measurement-image-preview img { display: block; width: auto; max-width: 100%; max-height: 68vh; object-fit: contain; }
|
||||
.manual-entry-alert { margin-bottom: 16px; }
|
||||
.manual-entry-alert ul { margin: 6px 0 0; padding-left: 18px; }
|
||||
.manual-entry-form :deep(.el-input-number), .manual-entry-form :deep(.el-date-editor) { width: 100%; }
|
||||
.manual-entry-upload { width: 100%; }
|
||||
.measurement-items-section { background: #fafbfc; }
|
||||
.measurement-item { overflow: hidden; margin-top: 14px; border: 1px solid var(--cc-border); border-radius: 9px; background: #fff; }
|
||||
.measurement-item.has-error { border-color: var(--el-color-danger-light-5); }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { downloadInboundMeasurementImage, getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
|
||||
import { createManualCartonScan, downloadInboundMeasurementImage, getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
|
||||
import type { SaveInboundMeasurementDraftRequest } from './types'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
@@ -101,4 +101,20 @@ describe('inbound measurement API', () => {
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('posts manual carton scan data and image to the scoped multipart route', async () => {
|
||||
const result = { measurementId: '9001', measurementVersion: 3, itemId: '2', boxCode: 'RK#10-2', confirmedPackageCount: 1 }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: result })
|
||||
const image = new File(['image'], 'carton.jpg', { type: 'image/jpeg' })
|
||||
|
||||
await expect(createManualCartonScan('10001', '7001', {
|
||||
boxCode: 'RK#10-2', length: 40, width: 30, height: 20, weight: 5,
|
||||
machineNo: 'MANUAL-ADMIN', measuredAt: '2026-08-12T14:00:00', scannedShipmentId: null,
|
||||
}, image)).resolves.toEqual(result)
|
||||
const [path, body, config] = vi.mocked(http.post).mock.calls[0]!
|
||||
expect(path).toBe('/platform/v1/first-leg/inbounds/7001/measurement/carton-scans')
|
||||
expect(body).toBeInstanceOf(FormData)
|
||||
expect((body as FormData).get('image')).toBe(image)
|
||||
expect(config).toEqual({ params: { tenantId: '10001' }, headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ConfirmedPackageCountRequest,
|
||||
SaveInboundMeasurementDraftRequest,
|
||||
SubmitInboundMeasurementRequest,
|
||||
ManualCartonScanRequest,
|
||||
} from './types'
|
||||
|
||||
function measurementPath(inboundId: string) {
|
||||
@@ -67,6 +68,23 @@ export async function submitInboundMeasurement(
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function createManualCartonScan(
|
||||
customerId: string,
|
||||
inboundId: string,
|
||||
payload: ManualCartonScanRequest,
|
||||
image: File,
|
||||
) {
|
||||
const data = new FormData()
|
||||
data.append('data', new Blob([JSON.stringify(payload)], { type: 'application/json' }))
|
||||
data.append('image', image)
|
||||
const response = await http.post<ApiEnvelope<CartonMeasurementResult>, ApiEnvelope<CartonMeasurementResult>>(
|
||||
`${measurementPath(inboundId)}/carton-scans`,
|
||||
data,
|
||||
{ params: { tenantId: customerId }, headers: { 'Content-Type': 'multipart/form-data' } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export function downloadInboundMeasurementImage(customerId: string, inboundId: string, fileId: string) {
|
||||
return http.get<Blob, Blob>(
|
||||
`/platform/v1/files/measurements/${segment(customerId)}/${segment(inboundId)}/${segment(fileId)}`,
|
||||
|
||||
@@ -133,6 +133,17 @@ export interface CartonMeasurementResult {
|
||||
confirmedPackageCount: number
|
||||
}
|
||||
|
||||
export interface ManualCartonScanRequest {
|
||||
boxCode: string
|
||||
length: number
|
||||
width: number
|
||||
height: number
|
||||
weight: number | null
|
||||
machineNo: string
|
||||
measuredAt: string
|
||||
scannedShipmentId: string | null
|
||||
}
|
||||
|
||||
export interface SubmitInboundMeasurementRequest {
|
||||
measurementId: MeasurementIdentifier
|
||||
version: number
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/TruckAppointmentDetailView.vue'), 'utf8')
|
||||
|
||||
describe('TruckAppointmentDetailView status contract', () => {
|
||||
it('matches the backend edit lifecycle and avoids a self-transition action', () => {
|
||||
expect(source).toContain('const targets: TruckAppointmentStatus[] =')
|
||||
expect(source).not.toContain('const targets: string[] =')
|
||||
expect(source).toContain("['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '')")
|
||||
expect(source).toContain("status !== resource.value?.status")
|
||||
expect(source).toContain("statuses.indexOf(status) === index")
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,7 @@ import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
||||
import { truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
|
||||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0 })
|
||||
const history = ref<TruckAppointmentStatusHistory[]>([])
|
||||
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && !['COMPLETED','CANCELLED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
|
||||
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
|
||||
async function loadRelated() { if (!resource.value) return; const [attachments, histories] = await Promise.all([listTruckAppointmentAttachments(id.value), listTruckAppointmentHistory(id.value)]); resource.value.attachments = attachments; history.value = histories }
|
||||
async function load() { loading.value = true; error.value = null; try { const result = await getTruckAppointment(id.value); resource.value = result; Object.assign(form, { ...result, expectedVersion: result.version ?? 0 }); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
|
||||
async function submit() { saving.value = true; error.value = null; try { await updateTruckAppointment(id.value, form); ElMessage.success('拖车预约已保存'); await load() } catch (err) { error.value = getApiErrorDetails(err, '保存失败,请稍后重试') } finally { saving.value = false } }
|
||||
@@ -25,7 +25,9 @@ const primaryTargetByStatus: Partial<Record<TruckAppointmentStatus, TruckAppoint
|
||||
const availableTargets = computed<TruckAppointmentStatus[]>(() => {
|
||||
if (!resource.value || ['COMPLETED', 'CANCELLED'].includes(resource.value.status)) return []
|
||||
const primary = primaryTargetByStatus[resource.value.status]
|
||||
return [...(primary ? [primary] : []), 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']
|
||||
const targets: TruckAppointmentStatus[] = [...(primary ? [primary] : []), 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']
|
||||
return targets
|
||||
.filter((status, index, statuses) => status !== resource.value?.status && statuses.indexOf(status) === index)
|
||||
})
|
||||
async function changeStatus(status: TruckAppointmentStatus) { if (!resource.value || status === resource.value.status) return; try { await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { type: 'warning' }); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, form.expectedVersion || 0) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false } }
|
||||
onMounted(load)
|
||||
|
||||
@@ -87,11 +87,25 @@ describe('TruckAppointmentsView', () => {
|
||||
|
||||
it('shows the two frequent row actions directly without a more menu', () => {
|
||||
expect(source).toContain("v-if=\"access.can('platform:firstleg:truck-appointment:view')\"")
|
||||
expect(source).toContain("access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)")
|
||||
expect(source).toContain('<Eye :size="15" />详情')
|
||||
expect(source).toContain('<Pencil :size="15" />编辑')
|
||||
expect(source).not.toContain('MoreHorizontal')
|
||||
})
|
||||
|
||||
it('hides edit for completed appointments rejected by the backend edit contract', async () => {
|
||||
vi.mocked(listTruckAppointments).mockResolvedValue({
|
||||
records: [{ ...row, status: 'COMPLETED' }],
|
||||
page: 1,
|
||||
size: 20,
|
||||
total: 1,
|
||||
pages: 1,
|
||||
})
|
||||
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('button[aria-label="编辑 TA-9001"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the detail action when the account lacks the backend view permission', async () => {
|
||||
useAuthStore().permissions = ['platform:firstleg:truck-appointment:list']
|
||||
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
|
||||
|
||||
@@ -65,7 +65,7 @@ function openDetail(row: TruckAppointmentResource, edit = false) { router.push({
|
||||
<template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.driverPhone || '—' }}</small></template>
|
||||
<template #store="{ row }">{{ row.storeName || '—' }}</template>
|
||||
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:firstleg:truck-appointment:view')" link type="primary" :aria-label="appointmentActionLabel('查看', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit')" link type="primary" :aria-label="appointmentActionLabel('编辑', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource, true)"><Pencil :size="15" />编辑</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:firstleg:truck-appointment:view')" link type="primary" :aria-label="appointmentActionLabel('查看', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)" link type="primary" :aria-label="appointmentActionLabel('编辑', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource, true)"><Pencil :size="15" />编辑</el-button></div></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
@@ -153,6 +153,15 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).not.toContain('/actions/')
|
||||
})
|
||||
|
||||
it('treats WAIT_SPLIT as a display/filter state without inventing a split action', () => {
|
||||
expect(source).toContain('...unifiedOrderStatusOptions')
|
||||
expect(source).toContain('function nextActionBoundary(row: UnifiedOrderResource)')
|
||||
expect(source).toContain("return row.nextAction === 'CONFIRM_MEASUREMENT' ? '待完成测量' : ''")
|
||||
expect(source).not.toContain('人工进入分柜')
|
||||
expect(source).not.toContain("action === 'split'")
|
||||
expect(source).not.toContain("nextAction === 'SMART_SPLIT'")
|
||||
})
|
||||
|
||||
it('approves pending review orders without exposing an order carrier assignment', () => {
|
||||
expect(source).toContain("row.nextAction === 'APPROVE_ORDER'")
|
||||
expect(source).toContain("can('platform:order:approve')")
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('API error diagnostics', () => {
|
||||
unwrapApiResponse({ code: 'A0403', message: '无权限', data: null, requestId: 'request-1', traceId: 'trace-1' })
|
||||
} catch (error) {
|
||||
expect(getApiErrorDetails(error)).toEqual({
|
||||
message: '无权限',
|
||||
message: '当前账号无权访问该数据,请联系管理员确认权限',
|
||||
code: 'A0403',
|
||||
requestId: 'request-1',
|
||||
traceId: 'trace-1',
|
||||
@@ -56,13 +56,30 @@ describe('API error diagnostics', () => {
|
||||
})
|
||||
|
||||
expect(getApiErrorDetails(error)).toEqual({
|
||||
message: '服务不可用',
|
||||
message: '服务暂时不可用,请稍后重试',
|
||||
code: 'S0503',
|
||||
requestId: 'request-2',
|
||||
traceId: 'trace-2',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses safe actionable messages for HTTP authentication and permission failures', () => {
|
||||
const createError = (status: number, data: unknown) => new axios.AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, {
|
||||
status,
|
||||
statusText: 'Request failed',
|
||||
headers: {},
|
||||
config: { headers: new AxiosHeaders() },
|
||||
data,
|
||||
})
|
||||
|
||||
expect(getApiErrorDetails(createError(401, { message: 'token parse stack' })).message)
|
||||
.toBe('登录状态已失效,请重新登录')
|
||||
expect(getApiErrorDetails(createError(403, { message: 'policy engine details' })).message)
|
||||
.toBe('当前账号无权访问该数据,请联系管理员确认权限')
|
||||
expect(getApiErrorDetails(createError(502, '<html>upstream failed</html>')).message)
|
||||
.toBe('服务暂时不可用,请稍后重试')
|
||||
})
|
||||
|
||||
it('explains a proxy 413 response without a JSON error envelope', () => {
|
||||
const error = new axios.AxiosError('Request failed', 'ERR_BAD_REQUEST', undefined, undefined, {
|
||||
status: 413,
|
||||
|
||||
+18
-5
@@ -113,7 +113,14 @@ export function unwrapApiResponse<T>(response: ApiEnvelope<T>): T {
|
||||
|
||||
export function getApiErrorDetails(error: unknown, fallback = '接口请求失败'): ApiErrorDetails {
|
||||
if (error instanceof ApiResponseError) {
|
||||
return { message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }
|
||||
const message = error.code === 'A0401'
|
||||
? '登录状态已失效,请重新登录'
|
||||
: error.code === 'A0403'
|
||||
? '当前账号无权访问该数据,请联系管理员确认权限'
|
||||
: error.code.startsWith('S05')
|
||||
? '服务暂时不可用,请稍后重试'
|
||||
: error.message
|
||||
return { message, code: error.code, requestId: error.requestId, traceId: error.traceId }
|
||||
}
|
||||
if (axios.isAxiosError(error)) {
|
||||
const response = error.response?.data as {
|
||||
@@ -124,11 +131,17 @@ export function getApiErrorDetails(error: unknown, fallback = '接口请求失
|
||||
traceId?: string | null
|
||||
} | undefined
|
||||
const status = error.response?.status
|
||||
const statusMessage = status === 413
|
||||
? '上传文件的请求体过大,服务器已拒绝;请压缩后重试,如文件未超过 5MB,请联系管理员检查网关上传限制'
|
||||
: undefined
|
||||
const statusMessage = status === 401
|
||||
? '登录状态已失效,请重新登录'
|
||||
: status === 403
|
||||
? '当前账号无权访问该数据,请联系管理员确认权限'
|
||||
: status === 413
|
||||
? '上传文件的请求体过大,服务器已拒绝;请压缩后重试,如文件未超过 5MB,请联系管理员检查网关上传限制'
|
||||
: status && status >= 500
|
||||
? '服务暂时不可用,请稍后重试'
|
||||
: undefined
|
||||
return {
|
||||
message: response?.message || response?.error || statusMessage || fallback,
|
||||
message: statusMessage || response?.message || response?.error || fallback,
|
||||
code: response?.code || (error.response?.status ? `HTTP_${error.response.status}` : undefined),
|
||||
requestId: response?.requestId || undefined,
|
||||
traceId: response?.traceId || undefined,
|
||||
|
||||
@@ -88,6 +88,29 @@ describe('DataTable column preferences', () => {
|
||||
expect(wrapper.findAll('.column-stub').map((column) => column.attributes('data-label'))).toEqual(['操作', '名称', '状态'])
|
||||
})
|
||||
|
||||
it('keeps a newly introduced default-hidden column hidden for an existing preference', () => {
|
||||
localStorage.setItem(preferenceStorageKey('table', 'user-1', 'orders-table'), JSON.stringify({
|
||||
version: 1,
|
||||
order: ['actions', 'name', 'status'],
|
||||
hidden: [],
|
||||
}))
|
||||
const wrapper = shallowMount(DataTable, {
|
||||
props: {
|
||||
data: [],
|
||||
columns: [...configurableColumns, { key: 'internalCode', label: '内部编码', defaultVisible: false }],
|
||||
preferenceKey: 'orders-table',
|
||||
columnSettingsMode: 'show',
|
||||
},
|
||||
global: {
|
||||
provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'user-1') },
|
||||
stubs: { ElTable: TableStub, ElTableColumn: ColumnStub, ElButton: ButtonStub, ElPopover: PopoverStub },
|
||||
directives: { loading: {} },
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.column-stub').map((column) => column.attributes('data-label'))).toEqual(['操作', '名称', '状态'])
|
||||
})
|
||||
|
||||
it('keeps business actions, column settings, and trailing controls in one toolbar', () => {
|
||||
const wrapper = shallowMount(DataTable, {
|
||||
props: { data: [], columns: configurableColumns, preferenceKey: 'orders-table', columnSettingsMode: 'show' },
|
||||
|
||||
@@ -21,6 +21,39 @@ describe('UI layout preferences', () => {
|
||||
.toEqual({ version: 1, order: ['actions', 'name'], hidden: ['name'] })
|
||||
})
|
||||
|
||||
it('hides newly introduced default-hidden fields without overriding existing visibility choices', () => {
|
||||
writeLayoutPreference('table', 'user-1', 'roles', {
|
||||
version: 1,
|
||||
order: ['actions', 'role', 'status'],
|
||||
hidden: ['status'],
|
||||
})
|
||||
|
||||
expect(readLayoutPreference(
|
||||
'table',
|
||||
'user-1',
|
||||
'roles',
|
||||
['actions', 'role', 'roleCode', 'status'],
|
||||
['roleCode', 'status'],
|
||||
)).toEqual({
|
||||
version: 1,
|
||||
order: ['actions', 'role', 'status', 'roleCode'],
|
||||
hidden: ['status', 'roleCode'],
|
||||
})
|
||||
|
||||
writeLayoutPreference('table', 'user-1', 'roles', {
|
||||
version: 1,
|
||||
order: ['actions', 'role', 'roleCode', 'status'],
|
||||
hidden: [],
|
||||
})
|
||||
expect(readLayoutPreference(
|
||||
'table',
|
||||
'user-1',
|
||||
'roles',
|
||||
['actions', 'role', 'roleCode', 'status'],
|
||||
['roleCode'],
|
||||
).hidden).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps preferences isolated by user and removes them on reset', () => {
|
||||
writeLayoutPreference('query', 'user-1', 'orders', { version: 1, order: ['status', 'keyword'], hidden: [] })
|
||||
expect(readLayoutPreference('query', 'user-1', 'orders', ['keyword', 'status']).order).toEqual(['status', 'keyword'])
|
||||
@@ -30,4 +63,3 @@ describe('UI layout preferences', () => {
|
||||
expect(readLayoutPreference('query', 'user-1', 'orders', ['keyword', 'status']).order).toEqual(['keyword', 'status'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -47,10 +47,12 @@ export function readLayoutPreference(
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(preferenceStorageKey(kind, scope, preferenceKey)) || 'null') as Partial<LayoutPreferenceSnapshot> | null
|
||||
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.order) || !Array.isArray(parsed.hidden)) return fallback
|
||||
const storedKeys = new Set(uniqueKeys(parsed.order))
|
||||
const newlyDefaultHidden = uniqueKeys(defaultHidden).filter((key) => availableKeys.includes(key) && !storedKeys.has(key))
|
||||
return {
|
||||
version: 1,
|
||||
order: reconcileOrder(availableKeys, parsed.order),
|
||||
hidden: uniqueKeys(parsed.hidden).filter((key) => availableKeys.includes(key)),
|
||||
hidden: uniqueKeys([...parsed.hidden, ...newlyDefaultHidden]).filter((key) => availableKeys.includes(key)),
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
@@ -79,4 +81,3 @@ export function removeLayoutPreference(kind: LayoutPreferenceKind, scope: string
|
||||
// Ignore unavailable browser storage and keep the in-memory default layout.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user