This commit is contained in:
2026-08-13 19:00:37 +08:00
parent 41038e1edd
commit 0f0ae612ca
18 changed files with 175 additions and 170 deletions
+6 -9
View File
@@ -7,9 +7,6 @@ import { ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import AppLayout from './AppLayout.vue'
const { getUnreadCount } = vi.hoisted(() => ({ getUnreadCount: vi.fn() }))
vi.mock('@/modules/notifications/api/notificationCenter', () => ({ getUnreadCount }))
const CachedPage = defineComponent({
setup() {
const count = ref(0)
@@ -29,14 +26,13 @@ async function mountLayout(path = '/') {
const auth = useAuthStore()
auth.token = 'token'
auth.user = { id: '1', name: '平台管理员', account: 'admin', roles: ['PLATFORM_ADMIN'] }
auth.permissions = ['platform:warehouse:list', 'platform:notification:view']
auth.permissions = ['platform:warehouse:list', 'platform:notification:task:list', 'platform:notification:template:list']
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<p>page</p>' }, meta: { requiresAuth: true, workspaceTab: false, keepAlive: false } },
{ path: '/customers', name: 'customers', component: CachedPage, meta: { title: '客户管理', showPageHeading: false, requiresAuth: true } },
{ path: '/warehouses', name: 'warehouses', component: { template: '<p>warehouses</p>' }, meta: { title: '仓库管理', requiresAuth: true } },
{ path: '/notifications/mine', name: 'my-notifications', component: { template: '<p>messages</p>' }, meta: { title: '我的消息', requiresAuth: true } },
{ path: '/login', component: { template: '<p>login</p>' } },
],
})
@@ -49,15 +45,16 @@ async function mountLayout(path = '/') {
}
describe('AppLayout', () => {
beforeEach(() => { vi.mocked(ElMessageBox.confirm).mockReset(); getUnreadCount.mockReset().mockResolvedValue(4) })
beforeEach(() => { vi.mocked(ElMessageBox.confirm).mockReset() })
it('shows the authenticated identity without a role preview switcher', async () => {
const { wrapper } = await mountLayout()
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
expect(wrapper.text()).not.toContain('权限预览')
await flushPromises()
expect(wrapper.get('[aria-label="消息中心,4 条未读"]').attributes('disabled')).toBeUndefined()
expect(wrapper.get('.notification-count').text()).toBe('4')
expect(wrapper.get('[aria-label="消息中心"]').attributes('disabled')).toBeUndefined()
expect(wrapper.text()).toContain('消息管理')
expect(wrapper.text()).toContain('模板管理')
expect(wrapper.text()).not.toContain('我的消息')
})
it('hides the shell heading when the route opts out', async () => {
+9 -18
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Bell, FileText, LogOut, Mail, Menu } from '@lucide/vue'
@@ -13,7 +13,6 @@ import { useAuthStore } from '@/core/auth/auth'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import { canAccessWorkspaceRoute, workspaceRouteId, workspaceTabCandidate } from '@/core/workspace-tabs/routeWorkspace'
import { MAX_CACHED_WORKSPACE_VIEWS, useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
import { getUnreadCount } from '@/modules/notifications/api/notificationCenter'
const route = useRoute()
const router = useRouter()
@@ -27,8 +26,10 @@ const pageTitle = computed(() => route.meta.title || '管理后台')
const pageDescription = computed(() => route.meta.description || '')
const showPageHeading = computed(() => route.meta.showPageHeading !== false)
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
const unreadCount = ref<number | null>(null)
const unreadLoading = ref(false)
const canOpenNotificationManagement = computed(() => (
access.can('platform:notification:task:list')
|| access.can('platform:notification:template:list')
))
access.initializeAfterLogin()
workspace.initialize(auth.user?.id || auth.user?.account || '')
@@ -95,12 +96,6 @@ function handleMenuToggle() {
collapsed.value = !collapsed.value
}
async function loadUnreadCount() {
if (!access.can('platform:notification:view')) return
unreadLoading.value = true
try { unreadCount.value = await getUnreadCount() } catch { unreadCount.value = null } finally { unreadLoading.value = false }
}
function openNotificationPage(path: string) { void router.push(path) }
watch(() => route.fullPath, () => {
@@ -114,8 +109,6 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
workspace.initialize(userId)
void pruneInaccessibleTabs().then(syncCurrentRoute)
})
onMounted(() => { window.addEventListener('cc-notification-unread-changed', loadUnreadCount); void loadUnreadCount() })
onBeforeUnmount(() => window.removeEventListener('cc-notification-unread-changed', loadUnreadCount))
</script>
<template>
@@ -142,14 +135,13 @@ onBeforeUnmount(() => window.removeEventListener('cc-notification-unread-changed
><Menu :size="22" /></button>
<div v-if="showPageHeading" class="page-heading"><h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p></div>
<div class="header-user">
<el-dropdown v-if="access.can('platform:notification:view')" trigger="click" placement="bottom-end">
<button class="notification-trigger" type="button" :aria-label="`消息中心,${unreadCount ?? '未知'} 条未读`">
<Bell :size="18" /><span>消息中心</span><span class="notification-count">{{ unreadLoading ? '…' : unreadCount ?? '!' }}</span>
<el-dropdown v-if="canOpenNotificationManagement" trigger="click" placement="bottom-end">
<button class="notification-trigger" type="button" aria-label="消息中心">
<Bell :size="18" /><span>消息中心</span>
</button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item v-if="access.can('platform:notification:task:list')" @click="openNotificationPage('/notifications/messages')"><Mail :size="15" />消息管理</el-dropdown-item>
<el-dropdown-item v-if="access.can('platform:notification:template:list')" @click="openNotificationPage('/notifications/templates')"><FileText :size="15" />模板管理</el-dropdown-item>
<el-dropdown-item @click="openNotificationPage('/notifications/mine')"><Bell :size="15" />我的消息</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
<div class="user-avatar">{{ auth.user?.name.slice(0, 1) }}</div>
@@ -178,6 +170,5 @@ onBeforeUnmount(() => window.removeEventListener('cc-notification-unread-changed
.app-header--without-page-heading .header-user { margin-left: auto; }
.notification-trigger { display: inline-flex; min-height: 34px; align-items: center; gap: 6px; padding: 0 9px; border: 0; border-radius: 4px; background: transparent; color: var(--cc-muted); font: inherit; }
.notification-trigger { cursor: pointer; }
.notification-count { display: inline-flex; min-width: 22px; height: 18px; align-items: center; justify-content: center; padding: 0 5px; border-radius: 9px; background: #e5e7eb; color: #64748b; font-size: 11px; font-weight: 700; }
@media (max-width: 720px) { .notification-trigger > span:not(.notification-count) { display: none; } }
@media (max-width: 720px) { .notification-trigger > span { display: none; } }
</style>
+1 -1
View File
@@ -95,13 +95,13 @@ describe('admin router access metadata', () => {
const expected = [
['notification-messages', '/notifications/messages', 'platform:notification:task:list'],
['notification-templates', '/notifications/templates', 'platform:notification:template:list'],
['my-notifications', '/notifications/mine', 'platform:notification:view'],
]
for (const [name, path, permission] of expected) {
const route = router.getRoutes().find((item) => item.name === name)
expect(route?.path).toBe(path)
expect(route?.meta.permission).toBe(permission)
}
expect(router.getRoutes().find((item) => item.name === 'my-notifications')).toBeUndefined()
})
it('redirects the removed enterprise review route to the protected customer list', () => {
+2 -7
View File
@@ -21,7 +21,6 @@ describe('adminNavigation', () => {
'财务管理',
'销售管理',
'通用',
'消息中心',
'系统管理',
])
expect(adminNavigation.find((item) => item.key === 'customers')?.children?.map((item) => item.title)).toEqual([
@@ -68,11 +67,7 @@ describe('adminNavigation', () => {
'车辆列表',
'下载中心',
])
expect(adminNavigation.find((item) => item.key === 'notifications')?.children?.map((item) => item.title)).toEqual([
'消息管理',
'模板管理',
'我的消息',
])
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([
'用户管理',
'角色权限',
@@ -93,7 +88,7 @@ describe('adminNavigation', () => {
expect(paths).not.toContain('/inbounds')
expect(paths).not.toContain('/first-leg-pricing/customer-rules')
expect(paths).toContain('/downloads')
expect(paths).toEqual(expect.arrayContaining(['/notifications/messages', '/notifications/templates', '/notifications/mine']))
expect(paths).not.toEqual(expect.arrayContaining(['/notifications/messages', '/notifications/templates', '/notifications/mine']))
expect(paths).toContain('/master-data/fee-items')
expect(paths).toContain('/container-loading')
expect(paths).toContain('/container-loading/smart-loading')
-11
View File
@@ -1,7 +1,6 @@
import type { Component } from 'vue'
import {
Building2,
Bell,
ContactRound,
ClipboardList,
CircleDollarSign,
@@ -171,16 +170,6 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
],
},
{
key: 'notifications',
title: '消息中心',
icon: Bell,
children: [
{ key: 'notification-messages', title: '消息管理', icon: Bell, path: '/notifications/messages', permission: 'platform:notification:task:list' },
{ key: 'notification-templates', title: '模板管理', icon: Bell, path: '/notifications/templates', permission: 'platform:notification:template:list' },
{ key: 'my-notifications', title: '我的消息', icon: Bell, path: '/notifications/mine', permission: 'platform:notification:view' },
],
},
{
key: 'system',
title: '系统管理',
@@ -9,4 +9,12 @@ describe('InboundTaskList measurement scope', () => {
expect(source.match(/:tenant-id="selectedInbound\.tenantId"/g)).toHaveLength(2)
expect(source).not.toContain('selectedInbound.customerId || selectedInbound.tenantId')
})
it('opens the shared unified order detail from the view action', () => {
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
expect(source).toContain("name: 'unified-order-detail', params: { orderId: row.orderId }, query: { tenantId: row.tenantId }")
expect(source).toContain("access.can('platform:firstleg:inbound:view') && (row as InboundOrderSummary).orderId")
expect(source).not.toContain("name: 'inbound-order-detail', params: { inboundId: row.id }")
})
})
@@ -170,7 +170,8 @@ function openTask(row: InboundOrderSummary) {
}
function openDetail(row: InboundOrderSummary) {
void router.push({ name: 'inbound-order-detail', params: { inboundId: row.id }, query: { tenantId: row.tenantId } })
if (!row.orderId) return
void router.push({ name: 'unified-order-detail', params: { orderId: row.orderId }, query: { tenantId: row.tenantId } })
}
function handleTaskChanged() {
@@ -233,10 +234,10 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1 })
<Ruler v-else :size="15" />
{{ taskLabel }}
</el-button>
<el-button v-if="access.can('platform:firstleg:inbound:view')" size="small" plain @click="openDetail(row as InboundOrderSummary)">
<el-button v-if="access.can('platform:firstleg:inbound:view') && (row as InboundOrderSummary).orderId" size="small" plain @click="openDetail(row as InboundOrderSummary)">
<Eye :size="15" />查看
</el-button>
<span v-if="!access.can(actionPermission) && !access.can('platform:firstleg:inbound:view')" class="muted-action">无操作权限</span>
<span v-if="!access.can(actionPermission) && (!access.can('platform:firstleg:inbound:view') || !(row as InboundOrderSummary).orderId)" class="muted-action">无操作权限</span>
</div>
</template>
<template #inboundCode="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
@@ -24,9 +24,9 @@ describe('InboundWorkflowWorkspace', () => {
it('opens measurement editing and review in prototype-sized centered dialogs', () => {
expect(source).toContain('title="编辑入库测量数据"')
expect(source).toContain('width="min(1900px, calc(100vw - 32px))"')
expect(source).toContain('width="min(1240px, calc(100vw - 32px))"')
expect(source).toContain('title="审核入库测量数据"')
expect(source).toContain('width="min(1700px, calc(100vw - 32px))"')
expect(source).toContain('width="min(1240px, calc(100vw - 32px))"')
expect(source).toContain('height: 92vh')
expect(source).toContain('max-height: 92vh')
expect(source).toContain('.el-dialog__body) { min-height: 0; flex: 1 1 auto; overflow-y: auto;')
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { Box, ClipboardCheck, Pencil, RefreshCw } from '@lucide/vue'
import { ClipboardCheck, Pencil, RefreshCw } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -103,7 +103,6 @@ onBeforeUnmount(() => {
<header class="workspace-header">
<div>
<div class="workspace-title-row">
<span class="workspace-mark" aria-hidden="true"><Box :size="20" /></span>
<h2>入库码:{{ title }}</h2>
<StatusBadge v-if="detail" :status="statusLabel" />
</div>
@@ -135,8 +134,8 @@ onBeforeUnmount(() => {
<InboundSummaryPanel :summary="detail.summary" :public-remark="detail.publicRemark" />
</el-tab-pane>
<el-tab-pane label="货品与测量" name="measurement" lazy>
<div class="workflow-launch-panel">
<div><h3>编辑入库测量数据</h3><p>核对原单数据、确认扫描箱数,并查看服务端计算的重量和体积结果。</p></div>
<div class="workflow-action-row">
<span>核对原单数据、确认扫描箱数,并查看服务端计算的重量和体积结果。</span>
<el-button type="primary" @click="measurementDialogOpen = true"><Pencil :size="16" />编辑测量数据</el-button>
</div>
<div class="measurement-workflow">
@@ -149,8 +148,8 @@ onBeforeUnmount(() => {
</div>
</el-tab-pane>
<el-tab-pane label="测量审核" name="review" lazy>
<div v-if="canViewMeasurement" class="workflow-launch-panel">
<div><h3>审核入库测量数据</h3><p>对照原单、设备实测和称重 OCR 结果后作出审核决定。</p></div>
<div v-if="canViewMeasurement" class="workflow-action-row">
<span>对照原单、设备实测和称重 OCR 结果后作出审核决定。</span>
<el-button type="primary" @click="reviewDialogOpen = true"><ClipboardCheck :size="16" />审核测量数据</el-button>
</div>
<el-alert
@@ -196,7 +195,7 @@ onBeforeUnmount(() => {
v-model="measurementDialogOpen"
class="inbound-operation-dialog inbound-measurement-dialog"
title="编辑入库测量数据"
width="min(1900px, calc(100vw - 32px))"
width="min(1240px, calc(100vw - 32px))"
top="4vh"
destroy-on-close
:close-on-click-modal="false"
@@ -213,7 +212,7 @@ onBeforeUnmount(() => {
<template #footer>
<div class="inbound-operation-dialog__footer">
<el-button @click="measurementDialogOpen = false">取消</el-button>
<el-button v-if="measurementPanelRef?.canEdit" type="primary" plain :loading="measurementPanelRef?.saving" :disabled="!measurementPanelRef?.mayMaintain" @click="measurementPanelRef?.saveDraft()">保存确认箱数</el-button>
<el-button v-if="measurementPanelRef?.canEdit" type="primary" plain :loading="measurementPanelRef?.saving" :disabled="!measurementPanelRef?.mayMaintain" @click="measurementPanelRef?.saveDraft()">保存测量数据</el-button>
<el-button v-if="measurementPanelRef?.canSubmit" type="primary" :loading="measurementPanelRef?.submitting" :disabled="!measurementPanelRef?.maySubmit" @click="measurementPanelRef?.submitForReview()">提交审核</el-button>
</div>
</template>
@@ -223,7 +222,7 @@ onBeforeUnmount(() => {
v-model="reviewDialogOpen"
class="inbound-operation-dialog inbound-review-dialog"
title="审核入库测量数据"
width="min(1700px, calc(100vw - 32px))"
width="min(1240px, calc(100vw - 32px))"
top="4vh"
destroy-on-close
:close-on-click-modal="false"
@@ -254,7 +253,6 @@ onBeforeUnmount(() => {
.inbound-workspace { min-width: 0; }
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 18px 24px 16px; border-bottom: 1px solid var(--cc-border); }
.workspace-title-row { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 10px; }
.workspace-mark { display: grid; width: 36px; height: 36px; flex: 0 0 36px; place-items: center; border-radius: 8px; background: #1e293b; color: #fff; }
.workspace-title-row h2 { margin: 0; overflow-wrap: anywhere; color: #172033; font-size: 16px; line-height: 1.35; }
.workspace-header p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.workspace-state { margin: 24px; }
@@ -266,9 +264,7 @@ onBeforeUnmount(() => {
.workflow-tabs :deep(.el-tabs__item) { min-height: 44px; padding: 0 18px; color: #475467; font-size: 13px; font-weight: 500; }
.workflow-tabs :deep(.el-tabs__item.is-active) { color: var(--cc-primary); font-weight: 650; }
.measurement-workflow { display: grid; min-width: 0; gap: 28px; }
.workflow-launch-panel { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 20px; padding: 20px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.workflow-launch-panel h3 { margin: 0; color: var(--cc-text); font-size: 15px; }
.workflow-launch-panel p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.workflow-action-row { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 0 0 14px; border-bottom: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.inbound-operation-dialog__body { min-width: 0; }
.inbound-operation-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.inbound-operation-dialog__footer :deep(.el-button) { margin-left: 0; }
@@ -286,8 +282,8 @@ onBeforeUnmount(() => {
.workflow-tabs { padding: 0 16px 16px; }
.workflow-tabs :deep(.el-tabs__nav-wrap) { overflow-x: auto; }
.workflow-tabs :deep(.el-tabs__item) { min-height: 44px; padding-inline: 14px; }
.workflow-launch-panel { align-items: stretch; flex-direction: column; padding: 16px; }
.workflow-launch-panel :deep(.el-button) { width: 100%; min-height: 44px; }
.workflow-action-row { align-items: stretch; flex-direction: column; }
.workflow-action-row :deep(.el-button) { width: 100%; min-height: 44px; }
:global(.inbound-operation-dialog.el-dialog) { width: calc(100vw - 20px) !important; max-width: calc(100vw - 20px); height: 92vh; }
:global(.inbound-operation-dialog .el-dialog__header) { padding: 16px; }
:global(.inbound-operation-dialog .el-dialog__body) { padding: 14px; }
+1
View File
@@ -1,3 +1,4 @@
export { getInboundMeasurement } from './measurement/api'
export { default as InboundMeasurementPanel } from './measurement/InboundMeasurementPanel.vue'
export { downloadWeightImage } from './weight/api/weightOcr'
export type { InboundMeasurementDetail } from './measurement/types'
@@ -9,6 +9,7 @@ import {
downloadInboundMeasurementImage,
createManualCartonScan,
getInboundMeasurement,
saveInboundMeasurementDraft,
submitInboundMeasurement,
updateConfirmedPackageCount,
} from './api'
@@ -132,6 +133,7 @@ describe('InboundMeasurementPanel', () => {
measurementId: '9001', measurementVersion: 3, itemId: '9101',
boxCode: 'RK202608040001#10-1', confirmedPackageCount: 10,
})
vi.mocked(saveInboundMeasurementDraft).mockReset().mockResolvedValue({ measurementId: '9001', version: 3, status: 'DRAFT' })
vi.mocked(submitInboundMeasurement).mockReset().mockResolvedValue({
measurementId: '9001', version: 2, processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING',
recommendedPalletType: 'STANDARD', feeId: null, feeNo: null, feeStatus: null,
@@ -230,7 +232,7 @@ describe('InboundMeasurementPanel', () => {
const wrapper = await mountPanel()
const countInput = wrapper.find('.measurement-count-editor input')
await countInput?.setValue('9')
const save = wrapper.findAll('button').find((button) => button.text().includes('保存确认箱数'))
const save = wrapper.findAll('button').find((button) => button.text().includes('保存测量数据'))
await save?.trigger('click')
await flushPromises()
@@ -247,7 +249,7 @@ describe('InboundMeasurementPanel', () => {
const wrapper = await mountPanel()
const countInput = wrapper.find('.measurement-count-editor input')
await countInput?.setValue('9')
const save = wrapper.findAll('button').find((button) => button.text().includes('保存确认箱数'))
const save = wrapper.findAll('button').find((button) => button.text().includes('保存测量数据'))
await save?.trigger('click')
await flushPromises()
@@ -288,8 +290,7 @@ describe('InboundMeasurementPanel', () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ originalPackageCount: 12 }))
const wrapper = await mountPanel()
expect(wrapper.text()).toContain('确认箱数尚未与订单总箱数匹配')
expect(wrapper.text()).toContain('还差 2 箱')
expect(wrapper.text()).not.toContain('确认箱数尚未与订单总箱数匹配')
const submit = wrapper.findAll('button').find((button) => button.text().includes('提交审核'))
expect(submit?.attributes('disabled')).toBeDefined()
expect(submitInboundMeasurement).not.toHaveBeenCalled()
@@ -301,7 +302,7 @@ describe('InboundMeasurementPanel', () => {
const countInput = wrapper.find('.measurement-count-editor input')
expect((countInput?.element as HTMLInputElement).matches(':disabled')).toBe(false)
expect(wrapper.findAll('button').find((button) => button.text().includes('保存确认箱数'))?.attributes('disabled')).toBeUndefined()
expect(wrapper.findAll('button').find((button) => button.text().includes('保存测量数据'))?.attributes('disabled')).toBeUndefined()
expect(wrapper.findAll('button').find((button) => button.text().includes('提交审核'))?.attributes('disabled')).toBeDefined()
expect(vi.mocked(downloadInboundMeasurementImage)).not.toHaveBeenCalled()
})
@@ -8,7 +8,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { inboundProcessStatusLabels, inboundStatusLabel } from '../status'
import { createManualCartonScan, downloadInboundMeasurementImage, getInboundMeasurement, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
import { createManualCartonScan, downloadInboundMeasurementImage, getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
import type {
InboundMeasurementDetail,
InboundMeasurementItem,
@@ -109,12 +109,6 @@ const confirmedPackageCount = computed(() => form.items.reduce((sum, item) => su
const expectedPackageCount = computed(() => detail.value?.originalPackageCount ?? null)
const packageCountDifference = computed(() => expectedPackageCount.value == null ? null : confirmedPackageCount.value - expectedPackageCount.value)
const packageCountMatched = computed(() => packageCountDifference.value === null || packageCountDifference.value === 0)
const packageCountDifferenceLabel = computed(() => {
if (packageCountDifference.value == null) return ''
return packageCountDifference.value > 0
? `超出 ${packageCountDifference.value} 箱`
: `还差 ${Math.abs(packageCountDifference.value)} 箱`
})
const maySubmit = computed(() => canSubmit.value && submitEditableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && !loading.value && !saving.value)
const snapshot = computed(() => JSON.stringify(form))
const isDirty = computed(() => Boolean(detail.value) && snapshot.value !== baseline.value)
@@ -335,8 +329,29 @@ async function saveDraft() {
conflictDetected.value = false
try {
const originalCounts = new Map((detail.value?.items || []).map((item) => [String(item.id), item.packageCount]))
const originalDraft = detail.value
? { originalDocumentNo: detail.value.originalDocumentNo || '', originalGrossWeightKg: detail.value.originalGrossWeightKg, originalVolumeCbm: detail.value.originalVolumeCbm, actualGoodsName: detail.value.actualGoodsName || '' }
: null
const draftChanged = Boolean(originalDraft && (
originalDraft.originalDocumentNo !== form.originalDocumentNo
|| originalDraft.originalGrossWeightKg !== form.originalGrossWeightKg
|| originalDraft.originalVolumeCbm !== form.originalVolumeCbm
|| originalDraft.actualGoodsName !== form.actualGoodsName
))
const changedItems = form.items.filter((item) => originalCounts.get(item.itemId) !== item.packageCount)
let version = form.version
if (draftChanged) {
const saved = await saveInboundMeasurementDraft(customerId.value, props.inboundId, {
measurementId: form.measurementId,
version,
originalDocumentNo: form.originalDocumentNo.trim() || null,
originalGrossWeightKg: form.originalGrossWeightKg,
originalVolumeCbm: form.originalVolumeCbm,
actualGoodsName: form.actualGoodsName.trim() || null,
items: form.items.map(({ itemId: _itemId, clientKey: _clientKey, serverValues: _serverValues, ...item }) => item),
})
version = saved.version
}
for (const item of changedItems) {
const saved = await updateConfirmedPackageCount(customerId.value, props.inboundId, item.itemId, {
measurementVersion: version,
@@ -496,11 +511,6 @@ defineExpose({ saveDraft, submitForReview, mayMaintain, maySubmit, saving, submi
</ul>
</el-alert>
<el-alert v-if="expectedPackageCount !== null && !packageCountMatched" type="warning" :closable="false" show-icon class="measurement-alert">
<template #title>确认箱数尚未与订单总箱数匹配</template>
当前已确认 {{ confirmedPackageCount }} 箱,订单总箱数 {{ expectedPackageCount }} 箱,{{ packageCountDifferenceLabel }}。
</el-alert>
<form class="measurement-form" @submit.prevent="saveDraft">
<fieldset :disabled="!mayMaintain || saving">
<legend class="sr-only">测量草稿</legend>
@@ -518,12 +528,12 @@ defineExpose({ saveDraft, submitForReview, mayMaintain, maySubmit, saving, submi
<label><span>确认箱数合计</span><el-input :model-value="formatNumber(confirmedPackageCount, 0)" readonly /></label>
<label><span>订单总箱数</span><el-input :model-value="formatNumber(expectedPackageCount, 0)" readonly /></label>
<label><span>实测总毛重(T.K.GW)</span><el-input :model-value="formatNumber(detail.actualGrossWeightKg)" readonly /></label>
<label><span>原单总毛重(T.K.GW)</span><el-input :model-value="formatNumber(form.originalGrossWeightKg)" readonly /></label>
<label><span>原单总毛重(T.K.GW)</span><el-input-number v-model="form.originalGrossWeightKg" :min="0" :precision="3" controls-position="right" /></label>
<label><span>实测总体积(T.CBM)</span><el-input :model-value="formatNumber(detail.actualVolumeCbm, 4)" readonly /></label>
<label><span>原单总体积(T.CBM)</span><el-input :model-value="formatNumber(form.originalVolumeCbm, 4)" readonly /></label>
<label><span>原单总体积(T.CBM)</span><el-input-number v-model="form.originalVolumeCbm" :min="0" :precision="4" controls-position="right" /></label>
<label><span>实测总材积重(kg)</span><el-input :model-value="formatNumber(detail.volumeWeightKg)" readonly /></label>
<label><span>原单据号码(CKW)</span><el-input :model-value="form.originalDocumentNo || '—'" readonly /></label>
<label><span>实际品名</span><el-input :model-value="form.actualGoodsName || '—'" readonly /></label>
<label><span>原单据号码(CKW)</span><el-input v-model="form.originalDocumentNo" placeholder="请输入原单据号码" maxlength="128" /></label>
<label><span>实际品名</span><el-input v-model="form.actualGoodsName" placeholder="请输入实际品名" maxlength="128" /></label>
</div>
</section>
@@ -610,7 +620,7 @@ defineExpose({ saveDraft, submitForReview, mayMaintain, maySubmit, saving, submi
<span v-if="isDirty"><AlertTriangle :size="15" />有尚未保存的修改</span>
<span v-else>当前显示版本 {{ form.version }}</span>
<div>
<el-button v-if="canEdit" type="primary" plain :loading="saving" :disabled="!mayMaintain" @click="saveDraft"><Save :size="15" />保存确认箱数</el-button>
<el-button v-if="canEdit" type="primary" plain :loading="saving" :disabled="!mayMaintain" @click="saveDraft"><Save :size="15" />保存测量数据</el-button>
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="!maySubmit" @click="submitForReview"><Send :size="15" />提交审核</el-button>
</div>
</footer>
@@ -34,7 +34,7 @@ describe('InboundOrderDetailView', () => {
expect(wrapper.get('[aria-label="面包屑"]').text()).toContain('首页入库单列表入库单详情')
expect(wrapper.get('[data-testid="workspace"]').text()).toBe('10002:19001')
expect(wrapper.findAll('button').filter((button) => button.text().includes('返回入库单列表'))).toHaveLength(2)
expect(wrapper.findAll('button').filter((button) => button.text().includes('返回入库单列表'))).toHaveLength(1)
await wrapper.findAll('button').filter((button) => button.text().includes('返回入库单列表')).at(-1)?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.name).toBe('inbound-orders')
@@ -50,11 +50,11 @@ describe('InboundOrderDetailView', () => {
expect(source).not.toContain('租户')
expect(source).toContain('<PageToolbar title="入库单详情"')
expect(source).toContain('货品与测量、费用信息、状态历史及其他记录')
expect(source).toContain('class="surface-panel detail-page"')
expect(source).toContain('class="detail-page"')
expect(source).toContain('class="detail-page-actions"')
expect(source).toContain('返回入库单列表')
expect(source).toContain('class="detail-breadcrumb"')
expect(source).toContain('class="detail-page-footer"')
expect(source).not.toContain('class="detail-page-footer"')
expect(source).toContain('@media (max-width: 720px)')
expect(source).toContain('@media (max-width: 360px)')
})
@@ -4,7 +4,6 @@ import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, ChevronRight, Home } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import InboundWorkflowWorkspace from '../components/InboundWorkflowWorkspace.vue'
@@ -37,7 +36,7 @@ function backToHome() {
<ChevronRight :size="14" aria-hidden="true" />
<span aria-current="page">入库单详情</span>
</nav>
<DetailPageLayout class="surface-panel detail-page" aria-label="入库单详情">
<DetailPageLayout class="detail-page" aria-label="入库单详情">
<template #header>
<PageToolbar title="入库单详情" description="查看基本信息、货品与测量、费用信息、状态历史及其他记录。">
<div class="detail-page-actions">
@@ -46,21 +45,15 @@ function backToHome() {
</PageToolbar>
</template>
<FormSection title="入库处理工作区" :bordered="false">
<InboundWorkflowWorkspace
v-if="inboundId && customerId"
:customer-id="customerId"
:inbound-id="inboundId"
/>
<el-empty
v-else
:description="inboundId ? '缺少所属企业信息,请从入库单列表重新打开' : '缺少有效的入库单标识'"
/>
</FormSection>
<footer class="detail-page-footer">
<el-button @click="backToList"><ArrowLeft :size="16" />返回入库单列表</el-button>
</footer>
<InboundWorkflowWorkspace
v-if="inboundId && customerId"
:customer-id="customerId"
:inbound-id="inboundId"
/>
<el-empty
v-else
:description="inboundId ? '缺少所属企业信息,请从入库单列表重新打开' : '缺少有效的入库单标识'"
/>
</DetailPageLayout>
</AdminPage>
</template>
@@ -71,14 +64,12 @@ function backToHome() {
.detail-breadcrumb button { display: inline-flex; min-height: 32px; align-items: center; gap: 5px; padding: 4px 6px; border: 0; border-radius: 4px; background: transparent; color: var(--cc-primary); cursor: pointer; font: inherit; }
.detail-breadcrumb button:hover { background: #f0f7ff; }
.detail-breadcrumb button:focus-visible { outline: 3px solid rgba(102, 126, 234, .2); outline-offset: 1px; }
.detail-page { overflow: hidden; padding: 20px 24px; }
.detail-page { min-width: 0; overflow: hidden; padding: 20px 24px; }
.detail-page-actions { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 12px; }
.detail-page-footer { display: flex; justify-content: flex-start; padding: 20px 0 0; border-top: 1px solid var(--cc-border); }
@media (max-width: 720px) {
.detail-page { padding: 16px; }
.detail-page-actions { width: 100%; align-items: stretch; flex-direction: column; }
.detail-page-actions :deep(.el-button) { width: 100%; min-height: 44px; }
.detail-page-footer :deep(.el-button) { width: 100%; min-height: 44px; }
}
@media (max-width: 360px) { .detail-page { padding: 12px; } }
</style>
@@ -67,6 +67,16 @@ const measurementColumns: DataTableColumn[] = [
{ key: 'note', label: '超限提示 / 备注', minWidth: 200, slotName: 'note' },
]
const weightColumns: DataTableColumn[] = [
{ key: 'file', label: '称重文件 / OCR 原文', minWidth: 260, slotName: 'file' },
{ key: 'status', label: '识别状态', width: 100, slotName: 'status' },
{ key: 'ocrWeight', label: '原始重量', minWidth: 110, slotName: 'ocrWeight' },
{ key: 'adjustedWeight', label: '调整后重量', minWidth: 120, slotName: 'adjustedWeight' },
{ key: 'finalWeight', label: '最终重量', minWidth: 110, slotName: 'finalWeight' },
{ key: 'confidence', label: '置信度', width: 90, slotName: 'confidence' },
{ key: 'reason', label: '调整原因', minWidth: 180, slotName: 'reason' },
]
function formatNumber(value: number | null | undefined, unit = '') {
if (value === null || value === undefined || Number.isNaN(value)) return '-'
return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })}${unit}`
@@ -214,10 +224,7 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
<template>
<section class="review-panel" aria-labelledby="measurement-review-title">
<header class="review-header">
<div>
<h2 id="measurement-review-title">测量审核</h2>
<p>核对原单、设备实测和称重 OCR 后,再决定是否进入自动计费入库。</p>
</div>
<h2 id="measurement-review-title">测量信息</h2>
<div class="review-header-status">
<StatusBadge v-if="detail" :status="reviewStatusLabel" />
<el-button text :loading="loading || historyLoading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
@@ -232,39 +239,29 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
<el-skeleton v-if="loading && !detail" :rows="8" animated />
<template v-else-if="detail">
<div class="comparison-grid">
<article class="comparison-card">
<span class="comparison-label">原单申报</span>
<strong>{{ detail.originalDocumentNo || detail.inboundCode || '-' }}</strong>
<dl>
<div><dt>申报毛重</dt><dd>{{ formatNumber(detail.originalGrossWeightKg, ' kg') }}</dd></div>
<div><dt>申报体积</dt><dd>{{ formatNumber(detail.originalVolumeCbm, ' CBM') }}</dd></div>
</dl>
</article>
<article class="comparison-card">
<span class="comparison-label">设备实测</span>
<strong>{{ formatNumber(detail.actualPackageCount, ' 箱') }}</strong>
<dl>
<div><dt>实测体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, ' CBM') }}</dd></div>
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg, ' kg') }}</dd></div>
</dl>
</article>
<article class="comparison-card">
<span class="comparison-label">称重 OCR</span>
<strong>{{ formatNumber(detail.actualGrossWeightKg, ' kg') }}</strong>
<dl>
<div><dt>已确认图片</dt><dd>{{ confirmedWeights }} / {{ detail.weightResults.length }}</dd></div>
<div><dt>测量来源</dt><dd>{{ measurementSourceLabel(detail.sourceType) }}</dd></div>
</dl>
</article>
</div>
<dl class="review-fields">
<div><dt>入库单号</dt><dd>{{ detail.inboundCode || '-' }}</dd></div>
<div><dt>客户名称</dt><dd>{{ detail.companyName || '-' }}</dd></div>
<div><dt>原单申报</dt><dd>{{ detail.originalDocumentNo || '-' }}</dd></div>
<div><dt>渠道</dt><dd>{{ detail.channelName || detail.channelCode || '-' }}</dd></div>
<div><dt>申报箱数</dt><dd>{{ formatNumber(detail.originalPackageCount, ' 箱') }}</dd></div>
<div><dt>实测箱数</dt><dd>{{ formatNumber(detail.actualPackageCount, ' 箱') }}</dd></div>
<div><dt>申报毛重</dt><dd>{{ formatNumber(detail.originalGrossWeightKg, ' kg') }}</dd></div>
<div><dt>称重 OCR</dt><dd>{{ formatNumber(detail.actualGrossWeightKg, ' kg') }}</dd></div>
<div><dt>申报体积</dt><dd>{{ formatNumber(detail.originalVolumeCbm, ' CBM') }}</dd></div>
<div><dt>设备实测体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, ' CBM') }}</dd></div>
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg, ' kg') }}</dd></div>
<div><dt>测量来源</dt><dd>{{ measurementSourceLabel(detail.sourceType) }}</dd></div>
<div><dt>已确认称重图片</dt><dd>{{ confirmedWeights }} / {{ detail.weightResults.length }}</dd></div>
<div><dt>审核状态</dt><dd><StatusBadge :status="reviewStatusLabel" /></dd></div>
</dl>
<div v-if="detail.reviewReason" class="review-reason">
<CircleAlert :size="17" /><span><strong>最近审核说明:</strong>{{ detail.reviewReason }}</span>
</div>
<div class="review-section">
<div class="section-heading"><div><h3>三组数据对比</h3><p>{{ detail.companyName || '-' }} · {{ detail.channelName || detail.channelCode || '-' }}</p></div><span class="section-count">{{ detail.items.length }} 条箱规</span></div>
<div class="section-heading"><h3>测量明细</h3><span class="section-count">{{ detail.items.length }} 条</span></div>
<DataTable :data="detail.items" :columns="measurementColumns" empty-text="暂无设备测量明细" border stripe>
<template #boxCode="{ row }">{{ (row as MeasurementReviewItem).boxCode || (row as MeasurementReviewItem).itemNo || '-' }}</template>
<template #packageCount="{ row }">{{ (row as MeasurementReviewItem).packageCount ?? '-' }}</template>
@@ -285,17 +282,16 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
</div>
<div class="review-section">
<div class="section-heading"><div><h3>称重图片识别结果</h3><p>原始 OCR 值不可覆盖,只有已确认且有效的结果会计入实际重量。</p></div><span class="section-count">{{ detail.weightResults.length }} 张</span></div>
<div class="weight-list">
<div v-for="item in detail.weightResults" :key="item.id" class="weight-row">
<div><strong>{{ item.fileId || '未关联文件' }}</strong><span>{{ item.ocrProvider || 'OCR' }} · {{ recognitionStatusLabel(item.recognitionStatus) }}</span><p v-if="item.ocrRawText">OCR 原文:{{ item.ocrRawText }}</p><p v-if="item.adjustmentReason">调整原因:{{ item.adjustmentReason }}</p></div>
<div><span>原始</span><strong>{{ formatNumber(item.ocrWeightValue, ` ${item.ocrWeightUnit || ''}`) }}</strong></div>
<div><span>调整后</span><strong>{{ formatNumber(item.adjustedWeightValue, ` ${item.adjustedWeightUnit || ''}`) }}</strong></div>
<div><span>最终</span><strong>{{ formatNumber(item.finalWeightKg, ' kg') }}</strong></div>
<div><span>置信度</span><strong>{{ item.confidence === null ? '-' : `${Math.round(item.confidence * 100)}%` }}</strong></div>
</div>
<el-empty v-if="!detail.weightResults.length" description="暂无称重图片识别结果" :image-size="48" />
</div>
<div class="section-heading"><h3>称重图片识别结果</h3><span class="section-count">{{ detail.weightResults.length }} 张</span></div>
<DataTable :data="detail.weightResults" :columns="weightColumns" empty-text="暂无称重图片识别结果" border stripe>
<template #file="{ row }"><div class="table-wrap"><strong>{{ row.fileId || '未关联文件' }}</strong><span v-if="row.ocrRawText">OCR 原文:{{ row.ocrRawText }}</span></div></template>
<template #status="{ row }">{{ recognitionStatusLabel(row.recognitionStatus) }}</template>
<template #ocrWeight="{ row }">{{ formatNumber(row.ocrWeightValue, ` ${row.ocrWeightUnit || ''}`) }}</template>
<template #adjustedWeight="{ row }">{{ formatNumber(row.adjustedWeightValue, ` ${row.adjustedWeightUnit || ''}`) }}</template>
<template #finalWeight="{ row }">{{ formatNumber(row.finalWeightKg, ' kg') }}</template>
<template #confidence="{ row }">{{ row.confidence === null ? '-' : `${Math.round(row.confidence * 100)}%` }}</template>
<template #reason="{ row }">{{ row.adjustmentReason ? `调整原因:${row.adjustmentReason}` : '-' }}</template>
</DataTable>
</div>
<div class="review-actions" :class="{ 'review-actions--disabled': !isPending && !isRejected }">
@@ -336,18 +332,16 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
.review-header p, .section-heading p { margin: 0; color: var(--cc-muted); font-size: 13px; line-height: 1.55; }
.review-header-status { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
.review-alert-title { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.comparison-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.comparison-card { min-width: 0; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface); }
.comparison-card strong { display: block; margin: 8px 0 12px; font-size: 21px; line-height: 1.2; overflow-wrap: anywhere; }
.comparison-label { color: var(--cc-muted); font-size: 12px; font-weight: 700; }
dl { display: grid; gap: 7px; margin: 0; }.comparison-card dl div { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; }.comparison-card dt { color: var(--cc-muted); }.comparison-card dd { margin: 0; font-weight: 600; text-align: right; }
.review-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 32px; margin: 0; border-top: 1px solid var(--cc-border); }
.review-fields > div { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; min-width: 0; padding: 10px 0; border-bottom: 1px solid var(--cc-border); font-size: 13px; }
.review-fields dt { color: var(--cc-muted); }.review-fields dd { min-width: 0; margin: 0; color: var(--cc-text); font-weight: 600; overflow-wrap: anywhere; }
.review-reason { display: flex; gap: 8px; align-items: flex-start; padding: 12px 14px; border: 1px solid #f3d19e; border-radius: 8px; background: #fdf6ec; color: #8a5a00; font-size: 13px; line-height: 1.55; }
.review-section { display: grid; gap: 13px; padding-top: 2px; }.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }.section-heading h3 { display: flex; align-items: center; gap: 7px; margin: 0 0 4px; font-size: 16px; }.section-count { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
.measurement-note { overflow-wrap: anywhere; }.measurement-note strong, .measurement-note span { display: block; }.measurement-note strong { color: var(--el-color-warning-dark-2); }.measurement-note strong + span { margin-top: 4px; color: var(--cc-muted); }
.weight-list { display: grid; gap: 8px; }.weight-row { display: grid; grid-template-columns: minmax(190px, 1.8fr) repeat(4, minmax(80px, 1fr)); gap: 12px; align-items: center; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface); }.weight-row > div { min-width: 0; }.weight-row strong, .weight-row span, .weight-row p { display: block; overflow-wrap: anywhere; }.weight-row span { margin-top: 3px; color: var(--cc-muted); font-size: 11px; }.weight-row p { margin: 5px 0 0; color: var(--cc-muted); font-size: 11px; line-height: 1.45; }.weight-row > div:not(:first-child) { text-align: right; }
.table-wrap { display: grid; gap: 4px; min-width: 0; }.table-wrap span { color: var(--cc-muted); font-size: 12px; line-height: 1.45; overflow-wrap: anywhere; }
.review-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 16px; border: 1px solid #b3d8ff; border-radius: 10px; background: #ecf5ff; }.review-actions > div:first-child { display: grid; gap: 4px; min-width: 0; }.review-actions span { color: #606266; font-size: 12px; line-height: 1.5; }.review-actions--disabled { border-color: var(--cc-border); background: var(--cc-surface-muted); }.action-buttons { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.history-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }.history-list li { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 10px; position: relative; padding-bottom: 16px; }.history-list li:not(:last-child)::after { content: ''; position: absolute; top: 16px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); }.history-marker { z-index: 1; width: 14px; height: 14px; margin-top: 2px; border: 3px solid #909399; border-radius: 50%; background: var(--cc-surface); }.history-marker--success { border-color: #67c23a; }.history-marker--danger { border-color: #f56c6c; }.history-content { min-width: 0; }.history-content > div { display: flex; justify-content: space-between; gap: 12px; }.history-content time, .history-content > span { color: var(--cc-muted); font-size: 11px; }.history-content p { margin: 5px 0 4px; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
.el-button { min-height: 36px; }.el-button :deep(svg) { flex: 0 0 auto; }.review-panel :deep(.el-dialog__body) { display: grid; gap: 14px; }
@media (max-width: 720px) { .review-header, .section-heading, .review-actions { align-items: flex-start; flex-direction: column; }.review-header-status, .action-buttons { width: 100%; justify-content: space-between; }.comparison-grid { grid-template-columns: 1fr; }.weight-row { grid-template-columns: 1fr 1fr; }.weight-row > div:not(:first-child) { text-align: left; }.review-actions .action-buttons .el-button { flex: 1 1 140px; }.review-actions .action-buttons { justify-content: stretch; }.review-actions .action-buttons :deep(.el-button) { width: 100%; } }
@media (max-width: 380px) { .review-header h2 { font-size: 19px; }.review-header p { font-size: 12px; }.comparison-card { padding: 13px; }.weight-row { grid-template-columns: 1fr; }.review-actions { padding: 13px; } }
@media (max-width: 720px) { .review-header, .section-heading, .review-actions { align-items: flex-start; flex-direction: column; }.review-header-status, .action-buttons { width: 100%; justify-content: space-between; }.review-fields { grid-template-columns: 1fr; gap: 0; }.review-actions .action-buttons .el-button { flex: 1 1 140px; }.review-actions .action-buttons { justify-content: stretch; }.review-actions .action-buttons :deep(.el-button) { width: 100%; } }
@media (max-width: 380px) { .review-header h2 { font-size: 19px; }.review-header p { font-size: 12px; }.review-fields > div { grid-template-columns: 100px minmax(0, 1fr); }.review-actions { padding: 13px; } }
</style>
-6
View File
@@ -13,10 +13,4 @@ export const notificationRoutes = [
component: () => import('@/modules/notifications/pages/MessageTemplatesView.vue'),
meta: { title: '模板管理', description: '维护平台站内消息模板', permission: 'platform:notification:template:list' },
},
{
path: 'notifications/mine',
name: 'my-notifications',
component: () => import('@/modules/notifications/pages/MyMessagesView.vue'),
meta: { title: '我的消息', description: '查看当前平台员工收到的站内消息', permission: 'platform:notification:view' },
},
] satisfies RouteRecordRaw[]
@@ -146,6 +146,10 @@ describe('UnifiedOrdersView contract', () => {
expect(source).not.toContain('class="action-boundary"')
expect(source).toContain("can('platform:firstleg:inbound:view')")
expect(source).toContain('openMeasurement')
expect(source).toContain('<InboundMeasurementPanel')
expect(source).toContain('width="min(1180px, calc(100vw - 32px))"')
expect(source).toContain('measurementDialogOpen.value = true')
expect(source).not.toContain("router.push({ name: 'inbound-order-detail'")
expect(source).not.toContain('待接入:智能分柜')
expect(source).not.toContain('updateMainStatus')
expect(source).not.toContain('人工进入分柜')
@@ -49,6 +49,7 @@ import {
import {
downloadWeightImage,
getInboundMeasurement,
InboundMeasurementPanel,
type InboundMeasurementDetail,
} from '@/modules/inbounds'
import {
@@ -113,6 +114,8 @@ const measurementLoading = ref(false)
const measurementLoadedKey = ref('')
const measurementImageUrls = ref<string[]>([])
const measurementCollapsed = reactive({ customer: false, outer: false, actual: false })
const measurementDialogOpen = ref(false)
const measurementOrder = ref<UnifiedOrderResource | null>(null)
let detailLoadingKey = ''
let detailRequestSerial = 0
const showAdvancedFilters = ref(false)
@@ -641,7 +644,14 @@ async function openDetail(row: UnifiedOrderResource) {
function openMeasurement(row: UnifiedOrderResource) {
if (!row.inboundId || !can('platform:firstleg:inbound:view')) return
void router.push({ name: 'inbound-order-detail', params: { inboundId: String(row.inboundId) }, query: { tenantId: String(row.tenantId) } })
measurementOrder.value = row
measurementDialogOpen.value = true
}
function handleMeasurementChanged() {
measurementDialogOpen.value = false
measurementOrder.value = null
void loadOrders()
}
function openInboundFromDetail() {
@@ -991,10 +1001,10 @@ onMounted(() => {
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" border stripe empty-text="暂无符合条件的订单" aria-label="统一订单列表">
<template #actions="{ row }">
<div class="row-actions">
<el-button link type="primary" @click="openDetail(row as UnifiedOrderResource)"><Eye :size="15" />详情</el-button>
<el-button size="small" plain type="primary" @click="openDetail(row as UnifiedOrderResource)"><Eye :size="15" />详情</el-button>
<el-button v-for="action in directUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" size="small" plain :type="unifiedOrderActionType(action)" :loading="action === 'approve' && approvalLoadingId === String((row as UnifiedOrderResource).orderId)" @click="runMoreAction(action, row as UnifiedOrderResource)"><component :is="unifiedOrderRowActionIcons[action]" :size="15" />{{ unifiedOrderActionLabel(action) }}</el-button>
<el-dropdown v-if="moreUnifiedOrderActions(row as UnifiedOrderResource).length" trigger="click" @command="runMoreAction($event, row as UnifiedOrderResource)">
<el-button link type="primary" :aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" :title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"><MoreHorizontal :size="17" /></el-button>
<el-button size="small" plain type="primary" :aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" :title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"><MoreHorizontal :size="17" /></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="action in moreUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" :command="action"><component :is="unifiedOrderRowActionIcons[action]" :size="15" />{{ unifiedOrderActionLabel(action) }}</el-dropdown-item>
@@ -1051,6 +1061,24 @@ onMounted(() => {
<el-alert title="确认订单资料无误后审核通过,订单将进入待测量阶段。" type="info" :closable="false" show-icon />
</AppDialog>
<el-dialog
v-model="measurementDialogOpen"
:title="measurementOrder ? `测量 · ${measurementOrder.inboundCode || measurementOrder.orderNo}` : '测量'"
width="min(1180px, calc(100vw - 32px))"
top="4vh"
destroy-on-close
append-to-body
class="inbound-task-dialog"
@closed="measurementOrder = null"
>
<InboundMeasurementPanel
v-if="measurementOrder?.inboundId"
:tenant-id="String(measurementOrder.tenantId)"
:inbound-id="String(measurementOrder.inboundId)"
@changed="handleMeasurementChanged"
/>
</el-dialog>
</section>
</AdminPage>
</template>
@@ -1157,3 +1185,8 @@ onMounted(() => {
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { display: grid; grid-template-columns: auto 36px minmax(0, 1fr) auto; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .detail-overview, .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .detail-overview article { border-right: 0; border-bottom: 1px solid var(--cc-border); } .detail-overview article:last-child { border-bottom: 0; } .order-info-grid > div.wide { grid-column: auto; } .detail-tabs button { padding: 0 18px; } .detail-panel { padding: 16px; } .measurement-section-title { align-items: flex-start; flex-wrap: wrap; padding: 12px; } .measurement-section-summary { width: 100%; flex-basis: calc(100% - 34px); flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; white-space: normal; } .measurement-section-title > svg { margin-left: auto; } .measurement-total { flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; } .measurement-total strong { width: 100%; } }
@media (max-width: 420px) { .order-detail-heading { grid-template-columns: auto minmax(0, 1fr) auto; } .order-mark { display: none; } }
</style>
<style>
.inbound-task-dialog { max-height: 92vh; overflow: hidden; }
.inbound-task-dialog .el-dialog__body { max-height: calc(92vh - 62px); overflow: auto; padding: 20px 24px 24px; }
</style>