130 lines
8.8 KiB
Vue
130 lines
8.8 KiB
Vue
<script setup lang="ts">
|
|||
|
|
import { computed, reactive, ref, watch } from 'vue'
|
||
|
|
import { Plus, Trash2 } from '@lucide/vue'
|
||
|
|
import { ElMessage } from 'element-plus'
|
||
|
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
|
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
|
|
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||
|
|
import { listDispatchCandidates, preArrangeDispatch, type DispatchPalletCandidateDto } from '../api/dispatches'
|
||
|
|
import type { InboundOrderListItem } from '../types'
|
||
|
|
|
||
|
|
const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>()
|
||
|
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>()
|
||
|
|
|
||
|
|
const loading = ref(false)
|
||
|
|
const saving = ref(false)
|
||
|
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||
|
|
const mutationError = ref<ApiErrorDetails | null>(null)
|
||
|
|
const candidates = ref<DispatchPalletCandidateDto[]>([])
|
||
|
|
const selectedIds = ref<string[]>([])
|
||
|
|
const query = reactive({ orderNo: '', sourceStage: '' as '' | 'PLAN' | 'RECEIVED' })
|
||
|
|
const route = ref('')
|
||
|
|
|
||
|
|
const fbaWarehouseCode = computed(() => props.orders[0]?.fbaWarehouse || '')
|
||
|
|
const selected = computed(() => candidates.value.filter((item) => selectedIds.value.includes(String(item.planPalletId))))
|
||
|
|
const selectedPackages = computed(() => selected.value.reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
|
||
|
|
const selectedVolume = computed(() => selected.value.reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
|
||
|
|
|
||
|
|
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' }
|
||
|
|
function toggle(candidate: DispatchPalletCandidateDto, checked: boolean) {
|
||
|
|
const id = String(candidate.planPalletId)
|
||
|
|
selectedIds.value = checked ? [...new Set([...selectedIds.value, id])] : selectedIds.value.filter((value) => value !== id)
|
||
|
|
}
|
||
|
|
function remove(candidate: DispatchPalletCandidateDto) { toggle(candidate, false) }
|
||
|
|
|
||
|
|
async function load() {
|
||
|
|
loading.value = true; loadError.value = null
|
||
|
|
try {
|
||
|
|
candidates.value = await listDispatchCandidates({
|
||
|
|
orderNo: query.orderNo.trim() || undefined,
|
||
|
|
fbaWarehouseCode: fbaWarehouseCode.value || undefined,
|
||
|
|
sourceStage: query.sourceStage || undefined,
|
||
|
|
})
|
||
|
|
const validIds = new Set(candidates.value.map((item) => String(item.planPalletId)))
|
||
|
|
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id))
|
||
|
|
} catch (error) {
|
||
|
|
candidates.value = []
|
||
|
|
loadError.value = getApiErrorDetails(error, '可排车托盘加载失败,请重试')
|
||
|
|
} finally { loading.value = false }
|
||
|
|
}
|
||
|
|
|
||
|
|
function reset() { query.orderNo = ''; query.sourceStage = ''; void load() }
|
||
|
|
|
||
|
|
async function submit() {
|
||
|
|
if (!selectedIds.value.length) { ElMessage.warning('请至少选择一个托盘'); return }
|
||
|
|
saving.value = true; mutationError.value = null
|
||
|
|
try {
|
||
|
|
await preArrangeDispatch({
|
||
|
|
planPalletIds: selectedIds.value,
|
||
|
|
route: route.value.trim() || undefined,
|
||
|
|
fbaAppointmentNo: props.orders.find((item) => item.appointmentNo !== '—')?.appointmentNo,
|
||
|
|
})
|
||
|
|
emit('update:modelValue', false); emit('created'); ElMessage.success('预排车创建成功')
|
||
|
|
} catch (error) { mutationError.value = getApiErrorDetails(error, '预排车创建失败,请重试') }
|
||
|
|
finally { saving.value = false }
|
||
|
|
}
|
||
|
|
|
||
|
|
watch(() => props.modelValue, (open) => {
|
||
|
|
if (!open) return
|
||
|
|
selectedIds.value = []; query.orderNo = ''; query.sourceStage = ''; route.value = ''; mutationError.value = null
|
||
|
|
void load()
|
||
|
|
})
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<OperationErrorToast :details="mutationError" />
|
||
|
|
<AppDialog :model-value="modelValue" title="智能排车" width="min(1600px, calc(100vw - 48px))" confirm-text="预排车" :confirm-loading="saving" :confirm-disabled="!selectedIds.length" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
|
||
|
|
<section class="dispatch-query" aria-label="智能排车筛选">
|
||
|
|
<el-input v-model="query.orderNo" clearable placeholder="订单号" @keyup.enter="load" />
|
||
|
|
<el-select v-model="query.sourceStage" clearable placeholder="全部类型"><el-option label="计划托盘" value="PLAN" /><el-option label="已收货托盘" value="RECEIVED" /></el-select>
|
||
|
|
<el-input :model-value="fbaWarehouseCode" readonly placeholder="FBA仓库" />
|
||
|
|
<el-button type="primary" :loading="loading" @click="load">查询</el-button><el-button :disabled="loading" @click="reset">重置</el-button>
|
||
|
|
</section>
|
||
|
|
<LoadErrorState v-if="loadError" title="可排车托盘加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||
|
|
<div v-else class="dispatch-workbench">
|
||
|
|
<section class="candidate-pane" aria-label="可排车托盘列表">
|
||
|
|
<header><h3>可排车托盘 <span>({{ candidates.length }}个)</span></h3></header>
|
||
|
|
<el-table v-loading="loading" :data="candidates" border height="500" empty-text="暂无可排车托盘">
|
||
|
|
<el-table-column width="48"><template #default="{ row }"><el-checkbox :model-value="selectedIds.includes(String((row as DispatchPalletCandidateDto).planPalletId))" @change="toggle(row as DispatchPalletCandidateDto, Boolean($event))" /></template></el-table-column>
|
||
|
|
<el-table-column prop="orderNo" label="订单号" min-width="150" />
|
||
|
|
<el-table-column prop="inboundCode" label="入仓码" min-width="130" />
|
||
|
|
<el-table-column prop="palletNo" label="托盘号" min-width="130" />
|
||
|
|
<el-table-column prop="fbaWarehouseCode" label="目的地FBA" width="115" />
|
||
|
|
<el-table-column prop="packageCount" label="箱数" width="80" align="right" />
|
||
|
|
<el-table-column prop="volumeCbm" label="体积(m³)" width="105" align="right" />
|
||
|
|
</el-table>
|
||
|
|
</section>
|
||
|
|
<aside class="plan-pane" aria-label="装车托盘">
|
||
|
|
<header><div><h3>装车托盘</h3><p>托盘 {{ selected.length }} · 箱数 {{ selectedPackages }} · 立方 {{ selectedVolume.toFixed(2) }}</p></div><el-button disabled><Plus :size="15" />添加装托盘</el-button></header>
|
||
|
|
<el-input v-model="route" maxlength="255" placeholder="排车路线(选填)" />
|
||
|
|
<div class="selected-list">
|
||
|
|
<div v-for="item in selected" :key="String(item.planPalletId)" class="selected-pallet">
|
||
|
|
<div><strong>{{ text(item.palletNo) }}</strong><span>{{ text(item.orderNo) }} · {{ text(item.fbaWarehouseCode) }}</span></div>
|
||
|
|
<div><span>{{ Number(item.packageCount || 0) }}箱 / {{ Number(item.volumeCbm || 0).toFixed(2) }}m³</span><el-button text type="danger" :aria-label="`移除托盘${text(item.palletNo)}`" @click="remove(item)"><Trash2 :size="15" /></el-button></div>
|
||
|
|
</div>
|
||
|
|
<el-empty v-if="!selected.length" :image-size="72" description="请选择左侧托盘" />
|
||
|
|
</div>
|
||
|
|
</aside>
|
||
|
|
</div>
|
||
|
|
<template #footer="{ close }"><div class="dialog-footer"><span>已选托盘会在预排车成功后锁定。</span><div><el-button :disabled="saving" @click="close">取消</el-button><el-button type="primary" :loading="saving" :disabled="!selectedIds.length" @click="submit">预排车</el-button></div></div></template>
|
||
|
|
</AppDialog>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.dispatch-query { display: flex; align-items: center; gap: 12px; padding: 18px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-bg); }
|
||
|
|
.dispatch-query :deep(.el-input), .dispatch-query :deep(.el-select) { width: 210px; }
|
||
|
|
.dispatch-workbench { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(420px, .95fr); gap: 20px; margin-top: 18px; }
|
||
|
|
.candidate-pane, .plan-pane { min-width: 0; }
|
||
|
|
.candidate-pane header, .plan-pane header { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: 12px; }
|
||
|
|
h3, p { margin: 0; } h3 { font-size: 17px; } h3 span, p { color: var(--cc-muted); font-size: 13px; font-weight: 400; }
|
||
|
|
.plan-pane > .el-input { margin-bottom: 12px; }
|
||
|
|
.selected-list { min-height: 444px; max-height: 444px; overflow-y: auto; border: 1px dashed #cfd5df; border-radius: 6px; background: #fafbfc; }
|
||
|
|
.selected-pallet { display: flex; min-height: 72px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 16px; border-bottom: 1px solid var(--cc-border); background: #fff; }
|
||
|
|
.selected-pallet > div { display: flex; min-width: 0; align-items: center; gap: 12px; } .selected-pallet > div:first-child { align-items: flex-start; flex-direction: column; gap: 4px; }
|
||
|
|
.selected-pallet span { color: var(--cc-muted); font-size: 13px; }
|
||
|
|
.dialog-footer { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 16px; } .dialog-footer > span { color: var(--cc-muted); font-size: 13px; }
|
||
|
|
@media (max-width: 1100px) { .dispatch-workbench { grid-template-columns: 1fr; } }
|
||
|
|
</style>
|