This commit is contained in:
2026-08-12 14:08:33 +08:00
parent 4fb8b85c13
commit 975e0057e3
20 changed files with 378 additions and 29 deletions
+9 -8
View File
@@ -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])
+1 -1
View File
@@ -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 }">
+11
View File
@@ -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)
+4 -3
View File
@@ -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); }
+17 -1
View File
@@ -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' } })
})
})
+18
View File
@@ -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)}`,
+11
View File
@@ -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')")
+19 -2
View File
@@ -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
View File
@@ -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,
+23
View File
@@ -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' },
+33 -1
View File
@@ -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'])
})
})
+3 -2
View File
@@ -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.
}
}