This commit is contained in:
2026-09-14 20:03:27 +08:00
parent 66f49b97e5
commit 349e8f60df
44 changed files with 658 additions and 459 deletions
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { Eye, Plus, XCircle } from '@lucide/vue'
import { Plus } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -106,25 +107,25 @@ function toggleReturnCarton(cartonMark: string, checked: boolean | string | numb
async function submit() { const marks = String(cartonMarks.value).split(/[,\n,]+/).map(value => value.trim()).filter(Boolean); if (!selectedWarehouseId.value) { ElMessage.warning('请选择海外仓'); return }; if (outboundType.value === 'RETURN_OUTBOUND' && !selectedCustomerId.value) { ElMessage.warning('请选择客户'); return }; saving.value = true; try { if (outboundType.value === 'RETURN_OUTBOUND') { if (!recipient.value.trim() || !selectedReturnNo.value || !selectedReturnMarks.value.length) { ElMessage.warning('请填写收件信息并选择退货单和箱唛'); return }; await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` }); ElMessage.success('退货出库单已创建并推送PDA箱唛拣货任务') } else { const selected = dropshipCandidates.value.filter(item => selectedDropshipIds.value.includes(dropshipCandidateKey(item))); if (!selected.length) { ElMessage.warning('请至少选择一个已完成入库的一件代发订单'); return }; const customerIds = new Set(selected.map(item => item.customerId).filter(Boolean)); if (customerIds.size !== 1) { ElMessage.warning('一张出库单只能选择同一个客户的订单'); return }; const warehouses = new Set(selected.map(item => item.overseasWarehouseId).filter(Boolean)); if (warehouses.size > 1 || (warehouses.size === 1 && !warehouses.has(selectedWarehouseId.value))) { ElMessage.warning('所选订单必须属于当前海外仓'); return }; const items = selected.map(item => ({ inventoryPositionId: item.inventoryPositionId, packages: item.availablePackages, sku: item.skuCode || '', cartonMarks: [] as string[] })); await createOneClickDropshipOutbound([...customerIds][0] || '', { recipient: '一件代发订单直出', items, remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success(`已按订单生成 ${selected.length} 个托盘出库明细并推送PDA拣货任务`) } createOpen.value = false; await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '出库单创建失败').message) } finally { saving.value = false } }
function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) }
function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && row.status === 'PICKED' }
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
onMounted(load)
</script>
<template>
<AdminPage><ListPageLayout aria-label="出库单">
<AdminPage class="admin-density"><ListPageLayout aria-label="出库单">
<OperationErrorToast :details="mutationError" />
<template #toolbar><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" preference-key="overseas-outbounds-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="error" #state><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-outbounds" row-key="id" empty-text="暂无出库单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" />新建出库单</el-button></template>
<template #actions="{ row }"><el-button size="small" type="primary" plain @click="openDetail(row as OutboundRow)"><Eye :size="15" />详情</el-button><el-button v-if="canCancel(row as OutboundRow)" size="small" type="danger" plain @click="cancel(row as OutboundRow)"><XCircle :size="15" />取消</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 #outboundType="{ row }">{{ outboundTypeLabel(row.outboundType) }}</template>
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : '拣货中'" /></template>
</DataTable></section>
</ListPageLayout></AdminPage>
<AppDialog v-model="createOpen" title="新建出库单" width="min(1320px, calc(100vw - 32px))" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit">
<el-form class="create-outbound-form" label-position="top">
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1320px, calc(100vw - 32px))" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit">
<el-form class="create-outbound-form density-form" label-position="top">
<el-form-item label="出库类型" required>
<el-radio-group v-model="outboundType" @change="changeOutboundType">
<el-radio-button label="ONE_CLICK_DROPSHIP">一件代发出库</el-radio-button>
@@ -193,25 +194,25 @@ onMounted(load)
</template>
<style scoped>
.create-outbound-form { padding: 0 4px; }
.create-outbound-form { padding: 0 var(--admin-space-1); }
.create-outbound-form :deep(.el-input), .create-outbound-form :deep(.el-select), .create-outbound-form :deep(.el-input-number) { width: 100%; }
.form-hint { margin-top: 6px; color: var(--cc-muted); font-size: 12px; }
.candidate-list { display: grid; gap: 8px; max-height: 300px; overflow: auto; padding: 8px 0; }
.form-hint { margin-top: var(--admin-space-2); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.candidate-list { display: grid; gap: var(--admin-space-3); max-height: 300px; overflow: auto; padding: var(--admin-space-3) 0; }
.candidate-list :deep(.el-checkbox) { margin-right: 0; }
.candidate-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(150px, 1fr) auto; gap: 12px; align-items: center; width: min(700px, 100%); }
.candidate-row span, .candidate-row em { color: var(--cc-muted); font-style: normal; font-size: 12px; }
.candidate-query { display: flex; width: 100%; gap: 10px; }
.candidate-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(150px, 1fr) auto; gap: var(--admin-space-5); align-items: center; width: min(700px, 100%); }
.candidate-row span, .candidate-row em { color: var(--cc-muted); font-style: normal; font-size: var(--admin-font-caption); }
.candidate-query { display: flex; width: 100%; gap: var(--admin-space-4); }
.candidate-query :deep(.el-input) { flex: 1; }
.candidate-query :deep(.el-button) { flex: 0 0 auto; min-width: 78px; margin-left: 0; }
.dropship-candidate-table { margin-top: 12px; width: 100%; }
.candidate-pagination { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; color: var(--cc-muted); font-size: 12px; }
.return-lists { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; width: 100%; margin: 4px 0 16px; }
.return-list-card { min-width: 0; padding: 16px; border: 1px solid var(--el-border-color-light); border-radius: 10px; background: var(--el-fill-color-extra-light); }
.list-title { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.list-title span { color: var(--el-text-color-primary); font-size: 16px; font-weight: 650; }
.list-title small { color: var(--cc-muted); font-size: 12px; font-weight: 400; }
.return-result-table { width: 100%; margin-top: 12px; background: var(--el-bg-color); }
.return-pagination { display: flex; min-height: 44px; align-items: center; justify-content: space-between; gap: 12px; padding-top: 12px; color: var(--cc-muted); font-size: 12px; }
.dropship-candidate-table { margin-top: var(--admin-space-5); 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); }
.return-lists { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--admin-space-5); width: 100%; margin: var(--admin-space-1) 0 var(--admin-space-5); }
.return-list-card { min-width: 0; padding: var(--admin-space-5); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius); background: var(--el-fill-color-extra-light); }
.list-title { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
.list-title span { color: var(--el-text-color-primary); font-size: var(--admin-font-section); font-weight: 650; }
.list-title small { color: var(--cc-muted); font-size: var(--admin-font-caption); font-weight: 400; }
.return-result-table { width: 100%; margin-top: var(--admin-space-5); background: var(--el-bg-color); }
.return-pagination { display: flex; min-height: 44px; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding-top: var(--admin-space-5); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.return-pagination :deep(.el-pagination) { flex-wrap: wrap; justify-content: flex-end; }
@media (max-width: 980px) { .return-lists { grid-template-columns: 1fr; }.return-pagination { align-items: flex-start; flex-direction: column; } }
</style>