优化
This commit is contained in:
@@ -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')")
|
||||
|
||||
Reference in New Issue
Block a user