This commit is contained in:
2026-09-15 18:17:17 +08:00
parent 55576a7c84
commit a7db793c95
12 changed files with 184 additions and 42 deletions
@@ -0,0 +1,18 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import SmartLoadingCandidateInbounds from './SmartLoadingCandidateInbounds.vue'
import type { SmartLoadingCandidate } from '../types'
describe('sealed pallet composition', () => {
it('shows the two inbound portions, not the whole pallet count twice', async () => {
const candidate = { unitType: 'SEALED_PALLET', inboundCode: 'I180', availablePackageCount: 13,
groupMembers: [{ accountId: '1', inboundId: '180', inboundCode: 'I180', availablePackageCount: 3 },
{ accountId: '1', inboundId: '181', inboundCode: 'I181', availablePackageCount: 10 }] } as SmartLoadingCandidate
const wrapper = mount(SmartLoadingCandidateInbounds, { props: { candidate } })
expect(wrapper.get('summary').text()).toBe('2 个入仓码 · 展开')
expect(wrapper.text()).toContain('I180:3 箱')
expect(wrapper.text()).toContain('I181:10 箱')
expect(wrapper.text()).not.toContain('13 箱')
expect(wrapper.findAll('button')).toHaveLength(0)
})
})
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SmartLoadingCandidate } from '../types'
defineProps<{ candidate: SmartLoadingCandidate }>()
</script>
<template>
<details v-if="candidate.unitType === 'SEALED_PALLET' && candidate.groupMembers.length">
<summary>{{ candidate.groupMembers.length }} 个入仓码 · 展开</summary>
<div v-for="member in candidate.groupMembers" :key="`${member.accountId}:${member.inboundId}`">
{{ member.inboundCode || '—' }}:{{ member.availablePackageCount }} 箱
</div>
</details>
<span v-else>{{ candidate.inboundCode || '—' }}</span>
</template>
@@ -492,7 +492,7 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.text()).toContain('CLG-2093539779795795969 · 2 票') expect(wrapper.text()).toContain('CLG-2093539779795795969 · 2 票')
expect(wrapper.text()).toContain('3 个入仓片段') expect(wrapper.text()).toContain('3 个入仓片段')
expect(wrapper.text()).toContain('ORD-201:NYEXX2608290051、NYEXX2608290051-B') expect(wrapper.text()).toContain('ORD-201:NYEXX2608290051、NYEXX2608290051-B')
const buttons = wrapper.findAll('button').filter((button) => button.text().includes('整组分配')) const buttons = wrapper.findAll('.el-table__body-wrapper button').filter((button) => button.text() === '加入')
expect(buttons).toHaveLength(1) expect(buttons).toHaveLength(1)
await buttons[0].trigger('click') await buttons[0].trigger('click')
await flushPromises() await flushPromises()
@@ -560,7 +560,7 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.text()).toContain('CLG-9900 · 3 票') expect(wrapper.text()).toContain('CLG-9900 · 3 票')
expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票') expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票')
const button = wrapper.findAll('button').find((item) => item.text().includes('整组分配'))! const button = wrapper.findAll('.el-table__body-wrapper button').find((item) => item.text() === '加入')!
expect(button.attributes('disabled')).toBeDefined() expect(button.attributes('disabled')).toBeDefined()
expect(assignSmartLoadingGroup).not.toHaveBeenCalled() expect(assignSmartLoadingGroup).not.toHaveBeenCalled()
}) })
@@ -599,6 +599,57 @@ describe('GlobalSmartLoadingView', () => {
expect(adjustSmartLoadingItem).toHaveBeenCalledWith(plan.id, plan.items[0].id, 4, plan.version, plan.items[0].version) expect(adjustSmartLoadingItem).toHaveBeenCalledWith(plan.id, plan.items[0].id, 4, plan.version, plan.items[0].version)
}) })
it('shows a shared pallet once with exact members and a text-only join action', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
...candidate, unitType: 'SEALED_PALLET', palletId: '801', palletNo: 'DP-0025', indivisible: true,
availablePackageCount: 13, unitGrossWeightKg: 10,
groupMembers: [
{ accountId: '1', inboundId: '180', orderId: '180', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 3, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
{ accountId: '1', inboundId: '181', orderId: '181', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 10, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
],
}]))
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const buttons = wrapper.findAll('.el-table__body-wrapper button').filter(button => button.text() === '加入')
expect(buttons).toHaveLength(1)
expect(buttons[0].find('svg').exists()).toBe(false)
expect(wrapper.text()).toContain('I180:3 箱')
expect(wrapper.text()).toContain('I181:10 箱')
expect(wrapper.text()).toContain('130 kg')
wrapper.unmount()
})
it('searches any shared-pallet inbound, retains its complete composition across pages, and resets the search', async () => {
const sharedPallet = {
...candidate, inboundId: '180', inboundCode: 'I180', unitType: 'SEALED_PALLET' as const,
palletId: '801', palletNo: 'DP-0025', indivisible: true, availablePackageCount: 13,
groupMembers: [
{ accountId: '1', inboundId: '180', orderId: '180', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 3, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
{ accountId: '1', inboundId: '181', orderId: '181', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 10, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
],
}
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([sharedPallet], 1, 20, 21))
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const filters = { warehouseId: '', customerId: '', inboundCode: ' I181 ', createdRange: [], expectedArrivalRange: [], orderStatuses: [] }
wrapper.getComponent(QueryToolbar).vm.$emit('search', filters)
await flushPromises()
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], inboundCode: 'I181', page: 1, size: 20 })
const pane = wrapper.get('.candidate-pane')
expect(pane.text()).toContain('I180:3 箱')
expect(pane.text()).toContain('I181:10 箱')
expect(pane.findAll('.el-table__body-wrapper button').filter(button => button.text() === '加入')).toHaveLength(1)
await pane.get('button[aria-label="下一页"]').trigger('click')
await flushPromises()
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], inboundCode: 'I181', page: 2, size: 20 })
wrapper.getComponent(QueryToolbar).vm.$emit('reset', { ...filters, inboundCode: '' })
await flushPromises()
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 })
wrapper.unmount()
})
it('rounds capacity percentages and hides column settings in embedded tables', async () => { it('rounds capacity percentages and hides column settings in embedded tables', async () => {
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
...plan, ...plan,
@@ -607,8 +658,8 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('0.16%') expect(wrapper.text()).toContain('0.18%')
expect(wrapper.text()).toContain('0.03%') expect(wrapper.text()).toContain('0.05%')
expect(wrapper.text()).not.toContain('0.169999999') expect(wrapper.text()).not.toContain('0.169999999')
expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0) expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0)
}) })
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { Pencil, Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue' import { Pencil, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
import SmartLoadingCandidateInbounds from '../components/SmartLoadingCandidateInbounds.vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -121,7 +122,6 @@ function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
: `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段` : `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段`
} }
function candidateTotalGrossWeight(candidate: SmartLoadingCandidate) { function candidateTotalGrossWeight(candidate: SmartLoadingCandidate) {
if (candidate.unitType === 'SEALED_PALLET') return candidate.unitGrossWeightKg
return candidate.unitGrossWeightKg == null ? null : Number(candidate.unitGrossWeightKg) * Number(candidate.availablePackageCount || 0) return candidate.unitGrossWeightKg == null ? null : Number(candidate.unitGrossWeightKg) * Number(candidate.availablePackageCount || 0)
} }
function candidateTotalVolume(candidate: SmartLoadingCandidate) { function candidateTotalVolume(candidate: SmartLoadingCandidate) {
@@ -228,7 +228,7 @@ function preferredContainerId() {
const candidateColumns: DataTableColumn[] = [ const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true }, { key: 'inboundCode', label: '入仓码', minWidth: 220, fixed: 'left', slotName: 'candidateInbounds' },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true }, { key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
{ key: 'unitType', label: '分配单位', width: 105, slotName: 'unitType' }, { key: 'unitType', label: '分配单位', width: 105, slotName: 'unitType' },
{ key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' }, { key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' },
@@ -745,8 +745,9 @@ onMounted(() => { void load(); void loadCustomers() })
plain plain
:disabled="mutating || candidateBlocked(row as SmartLoadingCandidateRow)" :disabled="mutating || candidateBlocked(row as SmartLoadingCandidateRow)"
@click="openAssign(row)" @click="openAssign(row)"
><Plus :size="15" />{{ row.unitType === 'SEALED_PALLET' ? '整托分配' : row.coLoadGroupId ? '整组分配' : '加入' }}</el-button> >加入</el-button>
</template> </template>
<template #candidateInbounds="{ row }"><SmartLoadingCandidateInbounds :candidate="row as SmartLoadingCandidate" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template> <template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template> <template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template>
<template #orderStatusSnapshot="{ row }"><el-tag size="small" effect="plain">{{ candidateStatusLabel(row.orderStatusSnapshot) }}</el-tag></template> <template #orderStatusSnapshot="{ row }"><el-tag size="small" effect="plain">{{ candidateStatusLabel(row.orderStatusSnapshot) }}</el-tag></template>
@@ -845,7 +846,8 @@ onMounted(() => { void load(); void loadCustomers() })
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /> <el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
</template> </template>
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"> <template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
<p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 入仓码 {{ selectedCandidate.inboundCode || '—' }} · {{ selectedCandidate.availablePackageCount }} 箱</p> <p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 整托 {{ selectedCandidate.availablePackageCount }} 箱</p>
<SmartLoadingCandidateInbounds :candidate="selectedCandidate" />
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" /> <el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
</template> </template>
<template v-else> <template v-else>
@@ -159,8 +159,8 @@ describe('SmartLoadingWorkbenchView', () => {
expect(wrapper.text()).toContain('3 个入仓片段') expect(wrapper.text()).toContain('3 个入仓片段')
expect(wrapper.text()).toContain('ORD-1:IN-201、IN-203') expect(wrapper.text()).toContain('ORD-1:IN-201、IN-203')
expect(wrapper.text()).toContain('整组 12 箱') expect(wrapper.text()).toContain('整组 12 箱')
expect(wrapper.findAll('button').filter((button) => button.text().includes('整组分配'))).toHaveLength(1) expect(wrapper.findAll('.el-table__body-wrapper button').filter((button) => button.text() === '加入')).toHaveLength(1)
await wrapper.findAll('button').find((button) => button.text().includes('整组分配'))!.trigger('click') await wrapper.findAll('.el-table__body-wrapper button').find((button) => button.text() === '加入')!.trigger('click')
await flushPromises() await flushPromises()
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click() ;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
await flushPromises() await flushPromises()
@@ -181,7 +181,7 @@ describe('SmartLoadingWorkbenchView', () => {
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('CLG-9900 · 3 票') expect(wrapper.text()).toContain('CLG-9900 · 3 票')
expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票') expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票')
const button = wrapper.findAll('button').find((item) => item.text().includes('整组分配'))! const button = wrapper.findAll('.el-table__body-wrapper button').find((item) => item.text() === '加入')!
expect(button.attributes('disabled')).toBeDefined() expect(button.attributes('disabled')).toBeDefined()
expect(wrapper.text()).not.toContain('本次计划装柜箱数') expect(wrapper.text()).not.toContain('本次计划装柜箱数')
}) })
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import SmartLoadingCandidateInbounds from '../components/SmartLoadingCandidateInbounds.vue'
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
@@ -142,7 +143,7 @@ const itemColumns: DataTableColumn[] = [
] ]
const candidateColumns: DataTableColumn[] = [ const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true }, { key: 'inboundCode', label: '入仓码', minWidth: 220, fixed: 'left', slotName: 'candidateInbounds' },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150, showOverflowTooltip: true }, { key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150, showOverflowTooltip: true },
{ key: 'unitType', label: '分配单位', width: 110, slotName: 'unitType' }, { key: 'unitType', label: '分配单位', width: 110, slotName: 'unitType' },
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' }, { key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
@@ -393,7 +394,8 @@ onMounted(load)
</DataTable> </DataTable>
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可装柜货物"> <DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可装柜货物">
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></div></template> <template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></div></template>
<template #actions="{ row }"><el-button data-tone="primary" class="density-action" v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)">{{ row.unitType === 'SEALED_PALLET' ? '整托分配' : row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></template> <template #actions="{ row }"><el-button data-tone="primary" class="density-action" v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)">加入</el-button></template>
<template #candidateInbounds="{ row }"><SmartLoadingCandidateInbounds :candidate="row as SmartLoadingCandidate" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template> <template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #goodsSummary="{ row }"><LineClampText :content="row.goodsSummary || '—'" /></template> <template #goodsSummary="{ row }"><LineClampText :content="row.goodsSummary || '—'" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template> <template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template>
@@ -404,7 +406,7 @@ onMounted(load)
</template> </template>
</template> </template>
</template> </template>
<AppDialog class="admin-density-dialog" v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 入仓码 {{ selectedCandidate.inboundCode || '—' }} · {{ selectedCandidate.availablePackageCount }} 箱</p><el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form class="density-form" label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog> <AppDialog class="admin-density-dialog" v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 整托 {{ selectedCandidate.availablePackageCount }} 箱</p><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /><el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form class="density-form" label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
</section> </section>
</AdminPage> </AdminPage>
</template> </template>
+1
View File
@@ -332,6 +332,7 @@ export interface SmartLoadingCandidate {
groupTotalBoxes: number | null groupTotalBoxes: number | null
locked: boolean | null locked: boolean | null
blockReason: string | null blockReason: string | null
/** Complete co-load group or sealed-pallet membership; pallet member counts are inbound-specific. */
groupMembers: SmartLoadingCandidateGroupMember[] groupMembers: SmartLoadingCandidateGroupMember[]
} }
@@ -3,6 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getOutbound } from '../api/outbounds' import { getOutbound } from '../api/outbounds'
import OutboundDetailView from './OutboundDetailView.vue' import OutboundDetailView from './OutboundDetailView.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
const canPermission = vi.hoisted(() => vi.fn((_code: string) => false)) const canPermission = vi.hoisted(() => vi.fn((_code: string) => false))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
@@ -10,6 +11,19 @@ vi.mock('vue-router', () => ({ useRoute: () => ({ params: { outboundNo: 'OUT/1'
vi.mock('../api/outbounds', () => ({ getOutbound: vi.fn(), cancelOutbound: vi.fn() })) vi.mock('../api/outbounds', () => ({ getOutbound: vi.fn(), cancelOutbound: vi.fn() }))
describe('OutboundDetailView', () => { describe('OutboundDetailView', () => {
it.each([
['WAIT_OUTBOUND', '待出库', 'warning'], ['PICKING', '拣货中', 'info'],
['PICKED', '已拣货', 'warning'], ['OUTBOUNDED', '已出库', 'success'], ['CANCELLED', '已取消', 'danger'],
])('uses the same shared status tone as the list for %s', async (status, label, tone) => {
vi.mocked(getOutbound).mockResolvedValueOnce({ outbound: { id: '1', outboundNo: 'OUT/1', status }, pallets: [], deliveries: [] })
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const badge = wrapper.findComponent(StatusBadge)
expect(badge.text()).toBe(label)
expect(badge.classes()).toContain(`is-${tone}`)
wrapper.unmount()
})
beforeEach(() => { beforeEach(() => {
canPermission.mockReset().mockReturnValue(false) canPermission.mockReset().mockReturnValue(false)
vi.mocked(getOutbound).mockReset().mockResolvedValue({ vi.mocked(getOutbound).mockReset().mockResolvedValue({
@@ -36,6 +36,14 @@ async function openCreate() {
} }
function candidatePagination() { return wrapper.findAllComponents(ElPagination).find(item => item.props('total') === 7)! } function candidatePagination() { return wrapper.findAllComponents(ElPagination).find(item => item.props('total') === 7)! }
function pageCheckbox() { return wrapper.findAllComponents(ElCheckbox).find(item => item.props('ariaLabel') === '全选本页订单')! } function pageCheckbox() { return wrapper.findAllComponents(ElCheckbox).find(item => item.props('ariaLabel') === '全选本页订单')! }
async function selectBothPages() {
pageCheckbox().vm.$emit('change', true)
await flushPromises()
candidatePagination().vm.$emit('update:current-page', 2)
await flushPromises()
pageCheckbox().vm.$emit('change', true)
await flushPromises()
}
describe('outbound candidate search and selection', () => { describe('outbound candidate search and selection', () => {
beforeEach(() => { beforeEach(() => {
@@ -71,29 +79,40 @@ describe('outbound candidate search and selection', () => {
})) }))
}) })
it('searches on query, selects every filtered page, shows partial page selection and clears the selection', async () => { it('keeps searches on one row without bulk buttons and preserves filtered selection across pages', async () => {
await openCreate() await openCreate()
expect(wrapper.get('.dropship-search-row').findAll('.el-form-item')).toHaveLength(2)
expect(wrapper.find('.candidate-selection').exists()).toBe(false)
expect(wrapper.text()).not.toContain('全选筛选结果')
expect(wrapper.text()).not.toContain('清空选择')
const query = wrapper.findAllComponents(ElInput).find(item => item.props('ariaLabel') === '搜索可出库订单')! const query = wrapper.findAllComponents(ElInput).find(item => item.props('ariaLabel') === '搜索可出库订单')!
await query.get('input').setValue('MATCH') await query.get('input').setValue('MATCH')
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('全选筛选结果(7)') expect(candidatePagination().props('total')).toBe(7)
await query.get('input').trigger('keyup', { key: 'Enter' }) await query.get('input').trigger('keyup', { key: 'Enter' })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('全选筛选结果(6)') const filteredPagination = wrapper.findAllComponents(ElPagination).find(item => item.props('total') === 6)!
await clickButton('全选筛选结果') expect(filteredPagination).toBeDefined()
pageCheckbox().vm.$emit('change', true)
await flushPromises()
filteredPagination.vm.$emit('update:current-page', 2)
await flushPromises()
pageCheckbox().vm.$emit('change', true)
await flushPromises()
expect(wrapper.text()).toContain('已选择 6 条') expect(wrapper.text()).toContain('已选择 6 条')
await wrapper.get('.candidate-query').findAll('button').find(item => item.text() === '重置')!.trigger('click') await wrapper.get('.candidate-query').findAll('button').find(item => item.text() === '重置')!.trigger('click')
await flushPromises() await flushPromises()
candidatePagination().vm.$emit('update:current-page', 2) candidatePagination().vm.$emit('update:current-page', 2)
await flushPromises() await flushPromises()
expect(pageCheckbox().props('indeterminate')).toBe(true) expect(pageCheckbox().props('indeterminate')).toBe(true)
await clickButton('清空选择') pageCheckbox().vm.$emit('change', false)
expect(wrapper.text()).toContain('已选择 0 条') await flushPromises()
expect(wrapper.get('.candidate-pagination').text()).toContain('已选择 5 条')
}) })
it('resets the page after page-size changes and clears selection when changing searchable warehouses', async () => { it('resets the page after page-size changes and clears selection when changing searchable warehouses', async () => {
await openCreate() await openCreate()
await clickButton('全选筛选结果') await selectBothPages()
const pagination = candidatePagination() const pagination = candidatePagination()
pagination.vm.$emit('update:current-page', 2) pagination.vm.$emit('update:current-page', 2)
await flushPromises() await flushPromises()
@@ -115,7 +134,8 @@ describe('outbound candidate search and selection', () => {
it('warns about a multi-customer bulk selection and preserves the submit restriction', async () => { it('warns about a multi-customer bulk selection and preserves the submit restriction', async () => {
api.candidates.mockResolvedValue([{ ...fixtures[0] }, { ...fixtures[1], customerId: 'customer-b', customerName: '客户乙' }]) api.candidates.mockResolvedValue([{ ...fixtures[0] }, { ...fixtures[1], customerId: 'customer-b', customerName: '客户乙' }])
await openCreate() await openCreate()
await clickButton('全选筛选结果') pageCheckbox().vm.$emit('change', true)
await flushPromises()
expect(wrapper.text()).toContain('当前选择包含多个客户') expect(wrapper.text()).toContain('当前选择包含多个客户')
await wrapper.get('[data-test="submit-outbound"]').trigger('click') await wrapper.get('[data-test="submit-outbound"]').trigger('click')
await flushPromises() await flushPromises()
@@ -5,6 +5,7 @@ import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listOutbounds } from '../api/outbounds' import { listOutbounds } from '../api/outbounds'
import OutboundsView from './OutboundsView.vue' import OutboundsView from './OutboundsView.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
const canPermission = vi.hoisted(() => vi.fn((_code: string) => true)) const canPermission = vi.hoisted(() => vi.fn((_code: string) => true))
const routerPush = vi.hoisted(() => vi.fn()) const routerPush = vi.hoisted(() => vi.fn())
@@ -16,6 +17,24 @@ vi.mock('../api/inventory', () => ({ listInventoryBalances: vi.fn(), listInvento
class ResizeObserverStub { observe() {} unobserve() {} disconnect() {} } class ResizeObserverStub { observe() {} unobserve() {} disconnect() {} }
describe('OutboundsView permissions and navigation', () => { describe('OutboundsView permissions and navigation', () => {
it('distinguishes outbound stages and does not present unknown states as picking', async () => {
const statuses = [
['WAIT_OUTBOUND', '待出库', 'warning'], ['PICKING', '拣货中', 'info'],
['PICKED', '已拣货', 'warning'], ['OUTBOUNDED', '已出库', 'success'],
['CANCELLED', '已取消', 'danger'], ['FUTURE_STATE', '未知状态', 'neutral'],
]
vi.mocked(listOutbounds).mockResolvedValueOnce({ records: statuses.map(([status], index) => ({ id: String(index), outboundNo: `OUT-${index}`, status })), page: 1, size: 20, total: statuses.length, pages: 1 })
const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const badges = wrapper.findAllComponents(StatusBadge).filter(badge => badge.element.closest('tbody'))
expect(badges).toHaveLength(statuses.length)
statuses.forEach(([, label, tone], index) => {
expect(badges[index].text()).toBe(label)
expect(badges[index].classes()).toContain(`is-${tone}`)
})
wrapper.unmount()
})
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined }) vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined })
@@ -68,7 +68,6 @@ const selectedDropshipKeys = computed(() => new Set(selectedDropshipIds.value))
const selectedPageCount = computed(() => pagedDropshipCandidates.value.filter(item => selectedDropshipKeys.value.has(dropshipCandidateKey(item))).length) const selectedPageCount = computed(() => pagedDropshipCandidates.value.filter(item => selectedDropshipKeys.value.has(dropshipCandidateKey(item))).length)
const allPageSelected = computed(() => pagedDropshipCandidates.value.length > 0 && selectedPageCount.value === pagedDropshipCandidates.value.length) const allPageSelected = computed(() => pagedDropshipCandidates.value.length > 0 && selectedPageCount.value === pagedDropshipCandidates.value.length)
const somePageSelected = computed(() => selectedPageCount.value > 0 && !allPageSelected.value) const somePageSelected = computed(() => selectedPageCount.value > 0 && !allPageSelected.value)
const allFilteredSelected = computed(() => filteredDropshipCandidates.value.length > 0 && filteredDropshipCandidates.value.every(item => selectedDropshipKeys.value.has(dropshipCandidateKey(item))))
const selectedDropshipCustomerCount = computed(() => new Set(dropshipCandidates.value.filter(item => selectedDropshipKeys.value.has(dropshipCandidateKey(item))).map(item => item.customerId).filter(Boolean)).size) const selectedDropshipCustomerCount = computed(() => new Set(dropshipCandidates.value.filter(item => selectedDropshipKeys.value.has(dropshipCandidateKey(item))).map(item => item.customerId).filter(Boolean)).size)
const oneClickMarks = computed(() => typeof cartonMarks.value === 'string' ? cartonMarks.value.split(/[,\n,]+/).map(value => value.trim()).filter(Boolean) : []) const oneClickMarks = computed(() => typeof cartonMarks.value === 'string' ? cartonMarks.value.split(/[,\n,]+/).map(value => value.trim()).filter(Boolean) : [])
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'outboundType', prop: 'outboundType', label: '出库类型', width: 150, slotName: 'outboundType' }, { key: 'lxCodes', prop: 'lxCodes', label: 'LX代码', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }] const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'outboundType', prop: 'outboundType', label: '出库类型', width: 150, slotName: 'outboundType' }, { key: 'lxCodes', prop: 'lxCodes', label: 'LX代码', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
@@ -133,7 +132,7 @@ onMounted(load)
<template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" />新建出库单</el-button></template> <template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" />新建出库单</el-button></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row as OutboundRow)">详情</el-button><el-button class="density-action" data-tone="danger" v-if="canCancel(row as OutboundRow)" size="small" @click="cancel(row as OutboundRow)">取消</el-button></div></template> <template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row as OutboundRow)">详情</el-button><el-button class="density-action" data-tone="danger" v-if="canCancel(row as OutboundRow)" size="small" @click="cancel(row as OutboundRow)">取消</el-button></div></template>
<template #outboundType="{ row }">{{ outboundTypeLabel(row.outboundType) }}</template> <template #outboundType="{ row }">{{ outboundTypeLabel(row.outboundType) }}</template>
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : '拣货中'" /></template> <template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : row.status === 'WAIT_OUTBOUND' ? '待出库' : row.status === 'PICKING' ? '拣货中' : '未知状态'" /></template>
</DataTable></section> </DataTable></section>
</ListPageLayout></AdminPage> </ListPageLayout></AdminPage>
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1320px, calc(100vw - 32px))" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"> <AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1320px, calc(100vw - 32px))" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit">
@@ -150,22 +149,21 @@ onMounted(load)
</el-select> </el-select>
</el-form-item> </el-form-item>
<template v-if="outboundType !== 'RETURN_OUTBOUND'"> <template v-if="outboundType !== 'RETURN_OUTBOUND'">
<div class="dropship-search-row">
<el-form-item label="海外仓" required> <el-form-item label="海外仓" required>
<el-select v-model="selectedWarehouseId" filterable clearable default-first-option aria-label="搜索海外仓" placeholder="输入仓库名称或编码搜索" @change="changeDropshipWarehouse"> <el-select v-model="selectedWarehouseId" filterable clearable default-first-option aria-label="搜索海外仓" placeholder="输入仓库名称或编码搜索" @change="changeDropshipWarehouse">
<el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /> <el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="已完成入库的一件代发订单"> <el-form-item label="一件代发订单">
<div class="candidate-query"> <div class="candidate-query">
<el-input v-model="dropshipQueryInput" :prefix-icon="Search" clearable aria-label="搜索可出库订单" placeholder="按订单号、入库单号、客户、托盘号、SKU或品名筛选" @keyup.enter="searchDropshipCandidates" /> <el-input v-model="dropshipQueryInput" :prefix-icon="Search" clearable aria-label="搜索可出库订单" placeholder="订单号 / 入库单号 / 客户 / 托盘 / SKU / 品名" @keyup.enter="searchDropshipCandidates" />
<el-button type="primary" :loading="inventoryLoading" @click="searchDropshipCandidates">查询</el-button> <el-button type="primary" :loading="inventoryLoading" @click="searchDropshipCandidates">查询</el-button>
<el-button @click="resetDropshipCandidates">重置</el-button> <el-button @click="resetDropshipCandidates">重置</el-button>
</div> </div>
<div class="candidate-selection" aria-label="订单批量选择"> </el-form-item>
<el-button :disabled="inventoryLoading || !filteredDropshipCandidates.length || allFilteredSelected" @click="selectDropshipRows(filteredDropshipCandidates, true)">全选筛选结果({{ filteredDropshipCandidates.length }})</el-button> </div>
<el-button :disabled="!selectedDropshipIds.length" @click="selectedDropshipIds = []">清空选择</el-button> <section class="candidate-results" aria-label="已完成入库的一件代发订单">
<span aria-live="polite">已选择 {{ selectedDropshipIds.length }} 条,翻页保留选择</span>
</div>
<el-table v-loading="inventoryLoading" :data="pagedDropshipCandidates" border stripe class="dropship-candidate-table" max-height="360"> <el-table v-loading="inventoryLoading" :data="pagedDropshipCandidates" border stripe class="dropship-candidate-table" max-height="360">
<el-table-column label="选择" width="68" align="center"> <el-table-column label="选择" width="68" align="center">
<template #header><el-checkbox aria-label="全选本页订单" :model-value="allPageSelected" :indeterminate="somePageSelected" :disabled="inventoryLoading || !pagedDropshipCandidates.length" @change="selectDropshipRows(pagedDropshipCandidates, $event)" /></template> <template #header><el-checkbox aria-label="全选本页订单" :model-value="allPageSelected" :indeterminate="somePageSelected" :disabled="inventoryLoading || !pagedDropshipCandidates.length" @change="selectDropshipRows(pagedDropshipCandidates, $event)" /></template>
@@ -180,13 +178,13 @@ onMounted(load)
<el-table-column prop="availablePackages" label="可出箱数" width="95" align="right" /> <el-table-column prop="availablePackages" label="可出箱数" width="95" align="right" />
</el-table> </el-table>
<div v-if="!inventoryLoading && !filteredDropshipCandidates.length" class="form-hint">暂无可直接出库的已完成入库订单</div> <div v-if="!inventoryLoading && !filteredDropshipCandidates.length" class="form-hint">暂无可直接出库的已完成入库订单</div>
<div v-if="filteredDropshipCandidates.length" class="candidate-pagination"> <div class="candidate-pagination">
<span>第 {{ dropshipPage }} / {{ Math.max(1, Math.ceil(filteredDropshipCandidates.length / dropshipPageSize)) }} 页</span> <span aria-live="polite" title="翻页保留选择">已选择 {{ selectedDropshipIds.length }} 条 · 第 {{ dropshipPage }} / {{ Math.max(1, Math.ceil(filteredDropshipCandidates.length / dropshipPageSize)) }} 页</span>
<el-pagination v-model:current-page="dropshipPage" v-model:page-size="dropshipPageSize" :page-sizes="[5, 10, 20, 50]" :pager-count="5" :total="filteredDropshipCandidates.length" layout="total, sizes, prev, pager, next, jumper" @size-change="dropshipPage = 1" /> <el-pagination v-model:current-page="dropshipPage" v-model:page-size="dropshipPageSize" :page-sizes="[5, 10, 20, 50]" :pager-count="5" :total="filteredDropshipCandidates.length" layout="total, sizes, prev, pager, next, jumper" @size-change="dropshipPage = 1" />
</div> </div>
<el-alert v-if="selectedDropshipCustomerCount > 1" title="当前选择包含多个客户,请按客户搜索后选择;一张出库单只能属于同一客户。" type="warning" :closable="false" /> <el-alert v-if="selectedDropshipCustomerCount > 1" title="当前选择包含多个客户,请按客户搜索后选择;一张出库单只能属于同一客户。" type="warning" :closable="false" />
<el-alert v-else title="一件代发按托盘与SKU选择;数量默认使用所选行全部可用箱数,同一张出库单只能选择同一客户、同一海外仓。" type="info" :closable="false" /> <p v-else class="candidate-hint">按托盘与 SKU 选择,默认出库全部可用箱数;仅限同一客户、同一海外仓,翻页保留选择。</p>
</el-form-item> </section>
</template> </template>
<template v-else> <template v-else>
<el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item> <el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item>
@@ -207,7 +205,7 @@ onMounted(load)
</div> </div>
<el-alert title="退货出库支持按客户选择单个或多个箱子;SKU数量直接从所选箱唛明细扣减。" type="info" :closable="false" /> <el-alert title="退货出库支持按客户选择单个或多个箱子;SKU数量直接从所选箱唛明细扣减。" type="info" :closable="false" />
</template> </template>
<el-form-item label="备注"><el-input v-model="remark" type="textarea" placeholder="请输入备注(选填)" /></el-form-item> <el-form-item label="备注"><el-input v-model="remark" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" placeholder="请输入备注(选填)" /></el-form-item>
</el-form> </el-form>
</AppDialog> </AppDialog>
</template> </template>
@@ -223,9 +221,11 @@ onMounted(load)
.candidate-query { display: flex; width: 100%; gap: var(--admin-space-4); } .candidate-query { display: flex; width: 100%; gap: var(--admin-space-4); }
.candidate-query :deep(.el-input) { flex: 1; } .candidate-query :deep(.el-input) { flex: 1; }
.candidate-query :deep(.el-button) { flex: 0 0 auto; min-width: 78px; margin-left: 0; } .candidate-query :deep(.el-button) { flex: 0 0 auto; min-width: 78px; margin-left: 0; }
.candidate-selection { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); width: 100%; margin-top: var(--admin-space-3); color: var(--cc-muted); font-size: var(--admin-font-caption); } .dropship-search-row { display: grid; grid-template-columns: minmax(0, 280px) minmax(0, 640px); align-items: end; gap: var(--admin-space-5); }
.candidate-selection :deep(.el-button + .el-button) { margin-left: 0; } .dropship-search-row :deep(.el-form-item) { min-width: 0; margin-bottom: var(--admin-space-3); }
.dropship-candidate-table { margin-top: var(--admin-space-5); width: 100%; } .candidate-results { min-width: 0; }
.candidate-hint { margin: 0 0 var(--admin-space-3); color: var(--cc-muted); font-size: var(--admin-font-caption); line-height: 1.5; }
.dropship-candidate-table { width: 100%; }
.candidate-pagination { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding: var(--admin-space-5) 0; color: var(--cc-muted); font-size: var(--admin-font-caption); } .candidate-pagination { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding: var(--admin-space-5) 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.candidate-pagination, .return-pagination { flex-wrap: wrap; } .candidate-pagination, .return-pagination { flex-wrap: wrap; }
.candidate-pagination > span, .return-pagination > span { flex-shrink: 0; white-space: nowrap; } .candidate-pagination > span, .return-pagination > span { flex-shrink: 0; white-space: nowrap; }
+2 -2
View File
@@ -4,8 +4,8 @@ import { computed } from 'vue'
const props = defineProps<{ status: string }>() const props = defineProps<{ status: string }>()
const tone = computed(() => { const tone = computed(() => {
if (['正常', '启用', '已完成', '已审核', '审核通过', '已入库', '装柜完成', '派送完成', '已确认', '已放舱'].includes(props.status)) return 'success' if (['正常', '启用', '已完成', '已审核', '审核通过', '已入库', '已出库', '装柜完成', '派送完成', '已确认', '已放舱'].includes(props.status)) return 'success'
if (['待审核', '草稿', '待处理', '待分配', '未提交', '未录入', '待测量数据', '待分柜', '待装柜', '预装柜中', '变更中'].includes(props.status)) return 'warning' if (['待审核', '草稿', '待处理', '待分配', '未提交', '未录入', '待测量数据', '待分柜', '待装柜', '预装柜中', '待出库', '已拣货', '变更中'].includes(props.status)) return 'warning'
if (['已受理', '已订舱', '已提交', '运输中', '处理中', '拣货中', '装柜中'].includes(props.status)) return 'info' if (['已受理', '已订舱', '已提交', '运输中', '处理中', '拣货中', '装柜中'].includes(props.status)) return 'info'
if (['停用', '已取消', '已驳回', '审核驳回', '失败', '订舱失败'].includes(props.status)) return 'danger' if (['停用', '已取消', '已驳回', '审核驳回', '失败', '订舱失败'].includes(props.status)) return 'danger'
return 'neutral' return 'neutral'