125 lines
15 KiB
Vue
125 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminWorkbench.css'
|
||
import '@/styles/adminDensity.css'
|
||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
|
||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||
import { BusinessDocumentFees } from '@/modules/overseas-business'
|
||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
const access = useAccessPreviewStore(); const rows = ref<CargoDiscardSummary[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref<CargoDiscardDetail | null>(null); const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
|
||
/** 数量事实缺失时明确提示,不用零代替未记录。 */
|
||
function quantity(value: number | null | undefined, unit: string) { return value == null ? '未记录 / 不适用' : `${value} ${unit}` }
|
||
const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }]
|
||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'status', label: '状态', width: 150, slotName: 'status', afterColumn: 'discardNo' }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'requestedPackages', label: '申请箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }]
|
||
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
|
||
const customerError = ref<ApiErrorDetails | null>(null); const listError = ref<ApiErrorDetails | null>(null)
|
||
const detailError = ref<ApiErrorDetails | null>(null); const reviewError = ref<ApiErrorDetails | null>(null)
|
||
const detailLoading = ref(false); const reviewing = ref(''); const detailTarget = ref<CargoDiscardSummary | null>(null)
|
||
const photoUrls = reactive<Record<string, string>>({}); const photoLoading = reactive<Record<string, boolean>>({}); const photoErrors = reactive<Record<string, ApiErrorDetails | null>>({})
|
||
const photoPreviewUrls = computed(() => (detail.value?.photos || []).map(photo => photoUrls[photo.fileId]).filter(Boolean))
|
||
let photoGeneration = 0, detailGeneration = 0, loadGeneration = 0, disposed = false
|
||
function clearPhotos() { photoGeneration++; Object.values(photoUrls).forEach(url => URL.revokeObjectURL(url)); for (const map of [photoUrls, photoLoading, photoErrors]) Object.keys(map).forEach(key => delete map[key]) }
|
||
async function loadPhoto(fileId: string) {
|
||
const current = detail.value; const generation = photoGeneration
|
||
if (!current || photoLoading[fileId]) return
|
||
if (!current.summary.customerId) { photoErrors[fileId] = { message: '照片缺少客户范围' }; return }
|
||
photoLoading[fileId] = true; photoErrors[fileId] = null
|
||
try { const blob = await downloadCargoDiscardPhoto(current.summary.discardNo, fileId, current.summary.customerId); if (disposed || photoGeneration !== generation || detail.value !== current || !detailOpen.value) return; if (photoUrls[fileId]) URL.revokeObjectURL(photoUrls[fileId]); photoUrls[fileId] = URL.createObjectURL(blob) }
|
||
catch (error) { if (!disposed && generation === photoGeneration) photoErrors[fileId] = getApiErrorDetails(error, '废弃照片加载失败') }
|
||
finally { if (generation === photoGeneration) photoLoading[fileId] = false }
|
||
}
|
||
watch(detailOpen, value => { if (!value) { detailGeneration++; clearPhotos() } })
|
||
onBeforeUnmount(() => { disposed = true; loadGeneration++; detailGeneration++; clearPhotos() })
|
||
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
|
||
/** 过期响应不得覆盖新的查询。 */
|
||
async function load(model: QueryModel = filters.value) {
|
||
const generation = ++loadGeneration; Object.assign(filters.value, model); loading.value = true; listError.value = null
|
||
const query = { page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }
|
||
try {
|
||
const result = await listCargoDiscards(query)
|
||
if (disposed || generation !== loadGeneration) return
|
||
rows.value = result.records; total.value = result.total
|
||
} catch (error) {
|
||
if (disposed || generation !== loadGeneration) return
|
||
rows.value = []; total.value = 0; listError.value = getApiErrorDetails(error, '废弃单列表加载失败')
|
||
} finally {
|
||
if (!disposed && generation === loadGeneration) loading.value = false
|
||
}
|
||
}
|
||
async function loadCustomers() {
|
||
customerError.value = null
|
||
try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); if (disposed) return; customers.value = result.records; const field = fields[0]; if (field.type === 'select') field.options = [{ label: '全部客户', value: '' }, ...customers.value.map(item => ({ label: item.tenantName, value: String(item.id) }))] }
|
||
catch (error) { if (!disposed) customerError.value = getApiErrorDetails(error, '客户列表加载失败') }
|
||
}
|
||
async function readDetail() {
|
||
const target = detailTarget.value; if (!target) return
|
||
const generation = ++detailGeneration; detailLoading.value = true; detailError.value = null
|
||
try { const result = await getCargoDiscard(target.discardNo, target.customerId || undefined); if (disposed || generation !== detailGeneration || !detailOpen.value) return; detail.value = result; void Promise.all(result.photos.map(photo => loadPhoto(photo.fileId))) }
|
||
catch (error) { if (!disposed && generation === detailGeneration) detailError.value = getApiErrorDetails(error, '废弃单详情加载失败') }
|
||
finally { if (generation === detailGeneration) detailLoading.value = false }
|
||
}
|
||
async function openDetail(row: CargoDiscardSummary) { if (reviewing.value) return; clearPhotos(); detail.value = null; detailTarget.value = row; detailOpen.value = true; await readDetail() }
|
||
async function audit(row: CargoDiscardSummary) {
|
||
if (reviewing.value || row.status !== 'PENDING_REVIEW' || !access.can(permission.discardApprove)) return
|
||
reviewing.value = row.discardNo; reviewError.value = null
|
||
try { await ElMessageBox.confirm('确认审核废弃单 ' + row.discardNo + ' 并推送 PDA 执行废弃?', '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消', customClass: 'admin-density-confirm admin-workbench is-dialog' }); if (!access.can(permission.discardApprove)) return; await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined); ElMessage.success('已审核通过并推送 PDA'); await load() }
|
||
catch (error) { if (error !== 'cancel' && error !== 'close') reviewError.value = getApiErrorDetails(error, '审核失败') }
|
||
finally { reviewing.value = '' }
|
||
}
|
||
onMounted(() => { void loadCustomers(); void load() })
|
||
</script>
|
||
<template>
|
||
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="废弃单列表"><section><LoadErrorState v-if="customerError" title="客户列表加载失败" :message="customerError.message" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState><LoadErrorState v-if="listError" title="废弃单列表加载失败" :message="listError.message" @retry="load()"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState><LoadErrorState v-if="reviewError" title="审核失败" :message="reviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="reviewError" /></template></LoadErrorState><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!!reviewing" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button class="density-action" v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" :disabled="!!reviewing" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || (row as CargoDiscardSummary).status || '未记录'" /></template></DataTable></div></section></ListPageLayout></AdminPage>
|
||
<AppDialog v-model="detailOpen" class="admin-density-dialog admin-workbench is-dialog" :title="'废弃单详情 · ' + (detailTarget?.discardNo || '')" :confirm-loading="detailLoading" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))" fullscreenable>
|
||
<p v-if="detailLoading" role="status">正在读取废弃单…</p>
|
||
<LoadErrorState v-else-if="detailError" title="废弃单详情加载失败" :message="detailError.message" @retry="readDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||
<template v-else-if="detail">
|
||
<table class="density-table" aria-label="废弃单基础资料"><tbody>
|
||
<tr data-tone="summary"><th>废弃单号</th><td>{{ detail.summary.discardNo }}</td><th>出库单号</th><td>{{ detail.summary.outboundNo || '—' }}</td></tr>
|
||
<tr data-tone="source"><th>仓库</th><td>{{ detail.summary.overseasWarehouseName || '—' }}</td><th>入仓码</th><td>{{ detail.summary.inboundCodes || '—' }}</td></tr>
|
||
<tr data-tone="work"><th>状态</th><td>{{ statusLabels[detail.summary.status || ''] || detail.summary.status || '未记录' }}</td><th>备注</th><td>{{ detail.remark || '—' }}</td></tr>
|
||
</tbody></table>
|
||
<section v-if="detailOpen" aria-label="废弃单费用明细">
|
||
<h3>费用明细</h3>
|
||
<BusinessDocumentFees :key="detail.summary.id" business-type="DISCARD_ORDER" :business-id="detail.summary.id" :business-no="detail.summary.discardNo" :customer-id="detail.summary.customerId || undefined" />
|
||
</section>
|
||
<h3>废弃明细</h3><el-table :data="detail.items" border>
|
||
<el-table-column label="入仓码"><template #default="{ row }"><LineClampText :content="row.inboundCode || ''" /></template></el-table-column>
|
||
<el-table-column label="SKU"><template #default="{ row }"><LineClampText :content="row.skuCode || ''" /></template></el-table-column>
|
||
<el-table-column label="品名"><template #default="{ row }"><LineClampText :content="row.productName || ''" /></template></el-table-column>
|
||
<el-table-column label="申请数量" width="120"><template #default="{ row }">{{ quantity(row.discardPackages, row.quantityUnit || '单位未记录') }}</template></el-table-column>
|
||
<el-table-column label="实际废弃" width="150"><template #default="{ row }">{{ quantity(row.actualDiscardPackages, row.quantityUnit || '单位未记录') }}</template></el-table-column>
|
||
<el-table-column label="所选箱唛" min-width="180"><template #default="{ row }"><LineClampText :content="row.cartonMarks?.join('、') || ''" /></template></el-table-column>
|
||
<el-table-column prop="palletNo" label="废弃托盘" min-width="160" />
|
||
<el-table-column label="实际废弃箱唛" min-width="180"><template #default="{ row }"><LineClampText :content="row.actualCartonMarks?.join('、') || ''" /></template></el-table-column>
|
||
</el-table>
|
||
<h3>废弃现场照片</h3><div v-if="detail.photos.length" class="discard-photo-grid"><div v-for="photo in detail.photos" :key="photo.fileId" class="discard-photo-item">
|
||
<el-image v-if="photoUrls[photo.fileId]" :src="photoUrls[photo.fileId]" :preview-src-list="photoPreviewUrls" :initial-index="photoPreviewUrls.indexOf(photoUrls[photo.fileId])" fit="cover" preview-teleported />
|
||
<span v-else-if="photoLoading[photo.fileId]">照片加载中…</span>
|
||
<LoadErrorState v-else-if="photoErrors[photo.fileId]" title="废弃照片加载失败" :message="photoErrors[photo.fileId]?.message" @retry="loadPhoto(photo.fileId)"><template #details><ApiErrorMeta :details="photoErrors[photo.fileId]!" /></template></LoadErrorState>
|
||
<el-button v-else @click="loadPhoto(photo.fileId)">加载照片</el-button><small>托盘:{{ photo.palletNo }} · {{ photo.submittedAt }}</small>
|
||
</div></div><p v-else>暂无已提交的废弃现场照片</p>
|
||
<h3>状态流转</h3><table v-if="detail.histories.length" class="density-table" aria-label="废弃状态流转"><tbody><tr v-for="(item, index) in detail.histories" :key="index"><td>{{ item.createdAt || '—' }}</td><td>{{ statusLabels[item.toStatus || ''] || item.toStatus || '未记录' }}</td><td>{{ item.remark || '—' }}</td></tr></tbody></table><p v-else>暂无状态流转记录</p>
|
||
</template>
|
||
</AppDialog>
|
||
</template>
|
||
|
||
|
||
|
||
|
||
<style scoped>
|
||
.discard-photo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.discard-photo-item{display:flex;flex-direction:column;gap:6px}.discard-photo-item .el-image{width:100%;height: 150px}.discard-photo-item small{color:var(--cc-text-secondary)}
|
||
</style>
|