Files
admin-web/src/modules/overseas-business/pages/PurchaseOrdersView.vue
T

139 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onActivated, onMounted, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/core/auth/auth'
import { procurementStatuses, statusLabel, statusTone, money } from '../procurementPresentation'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listOverseasPurchaseCustomerOptions, listOverseasPurchaseWarehouseOptions, listOverseasPurchases } from '../api/platformOverseasBusiness'
import { submitProcurementCommand } from '../api/procurementCommands'
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasPurchaseCustomerOption, OverseasPurchaseQuery, OverseasPurchaseStatus, OverseasPurchaseSummary } from '../types'
import { directionLabel } from '../procurementDirection'
const router = useRouter(); const access = useAccessPreviewStore(); const auth = useAuthStore()
const rows = ref<OverseasPurchaseSummary[]>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1); const pageSize = ref(20); const total = ref(0)
const filters = ref<QueryModel>({ orderNo: '', customerId: '', purchaseCategory: '', productName: '', status: '' }); const applied = ref({ ...filters.value })
const customerOptions = ref<OverseasPurchaseCustomerOption[]>([]); const customersLoading = ref(false)
const reviewOpen = ref(false), reviewSaving = ref(false), reviewLoading = ref(false)
const reviewRow = ref<OverseasPurchaseSummary | null>(null), reviewWarehouses = ref<Awaited<ReturnType<typeof listOverseasPurchaseWarehouseOptions>>>([])
const reviewRequestKey = ref('')
const reviewForm = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', purchaseWarehouseId: '', remark: '' })
let reviewGeneration = 0
watch(reviewOpen, value => { if (!value) invalidateReviewRequest() }, { flush: 'sync' })
const fields = computed<QueryField[]>(() => [
{ key: 'orderNo', type: 'input', label: '订单编号', placeholder: '请输入订单编号', width: 210 },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 210, options: customerOptions.value.map((item) => ({ label: item.customerName, value: item.customerId })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'purchaseCategory', type: 'input', label: '采购品类', placeholder: '请输入采购品类', width: 190 },
{ key: 'productName', type: 'input', label: '商品名称', placeholder: '请输入商品名称', width: 210 },
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label })) },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 220, singleLine: false },
{ key: 'status', label: '状态', minWidth: 145, slotName: 'status', afterColumn: 'orderNo' },
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 220, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.purchaseWarehouseName || '—' },
{ key: 'purchaseCategory', prop: 'purchaseCategory', label: '采购品类', minWidth: 130, singleLine: false },
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 220, singleLine: false },
{ key: 'productSpecification', prop: 'productSpecification', label: '商品规格', minWidth: 180, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.productSpecification || '—' },
{ key: 'budgetUnitPrice', label: '预算单价', width: 120, align: 'right', formatter: (row: OverseasPurchaseSummary) => money(row.budgetUnitPrice, row.currency) },
{ key: 'purchaseQuantity', prop: 'purchaseQuantity', label: '采购数量', width: 100, align: 'right' },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: OverseasPurchaseSummary) => formatDateTime(row.createdAt) },
{ key: 'procurementDirection', label: '采购方向', minWidth: 170, formatter: (row: OverseasPurchaseSummary) => directionLabel(row.procurementDirection) },
]
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
let generation = 0; let inFlight: Promise<void> | null = null; let requestKey = ''
onBeforeUnmount(() => { generation++; invalidateReviewRequest() })
function load() {
const query: OverseasPurchaseQuery = { page: page.value, size: pageSize.value, customerId: String(applied.value.customerId || ''), orderNo: String(applied.value.orderNo || ''), purchaseCategory: String(applied.value.purchaseCategory || ''), productName: String(applied.value.productName || ''), status: String(applied.value.status || '') as OverseasPurchaseStatus | '' }
const key = JSON.stringify([auth.user?.id, query])
if (inFlight && key === requestKey) return inFlight
requestKey = key; const current = ++generation
loading.value = true; loadError.value = null
const promise = listOverseasPurchases(query).then(result => {
if (current !== generation) return
rows.value = result.records; total.value = result.total
}).catch(error => { if (current === generation) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '采购订单加载失败,请稍后重试') } }).finally(() => {
if (current === generation) loading.value = false
if (inFlight === promise) inFlight = null
})
inFlight = promise; return promise
}
watch(() => auth.user?.id, () => { reviewOpen.value = false; rows.value = []; total.value = 0; void load() })
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
function open(row: OverseasPurchaseSummary) { void router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId: row.orderNo }, query: { customerId: row.customerId } }) }
function invalidateReviewRequest() {
reviewGeneration++
reviewLoading.value = false; reviewWarehouses.value = []; reviewRow.value = null
}
async function beginReview(row: OverseasPurchaseSummary) {
if (reviewSaving.value) return
const current = ++reviewGeneration; const owner = auth.user?.id
reviewRow.value = row; reviewWarehouses.value = []; reviewRequestKey.value = crypto.randomUUID(); Object.assign(reviewForm, { decision: 'APPROVE', purchaseWarehouseId: '', remark: '' }); reviewOpen.value = true
reviewLoading.value = true
// 关闭、切换订单或操作员后,旧请求的结果、提示和结束回调均不得影响当前审核。
const isCurrent = () => current === reviewGeneration && reviewOpen.value && owner === auth.user?.id
try {
const warehouses = await listOverseasPurchaseWarehouseOptions(row.procurementDirection)
if (isCurrent()) reviewWarehouses.value = warehouses
} catch (error) { if (isCurrent()) ElMessage.error(getApiErrorDetails(error, '仓库加载失败').message) }
finally { if (isCurrent()) reviewLoading.value = false }
}
async function submitReview() {
const row = reviewRow.value
if (!row || reviewSaving.value || reviewLoading.value || !reviewOpen.value) return
if (reviewForm.decision === 'APPROVE' && !reviewForm.purchaseWarehouseId) { ElMessage.warning('请选择采购仓库'); return }
if (reviewForm.decision === 'REJECT' && !reviewForm.remark.trim()) { ElMessage.warning('请填写驳回原因'); return }
reviewSaving.value = true
try {
await submitProcurementCommand(row.orderNo, row.customerId, 'review', { version: row.version, idempotencyKey: reviewRequestKey.value, decision: reviewForm.decision, ...(reviewForm.decision === 'APPROVE' ? { purchaseWarehouseId: reviewForm.purchaseWarehouseId } : { remark: reviewForm.remark.trim() }) })
reviewOpen.value = false; ElMessage.success('审核完成'); await load()
} catch (error) { ElMessage.error(getApiErrorDetails(error, '审核失败').message) }
finally { reviewSaving.value = false }
}
async function loadCustomerOptions() { customersLoading.value = true; try { customerOptions.value = await listOverseasPurchaseCustomerOptions() } catch { customerOptions.value = [] } finally { customersLoading.value = false } }
let activatedOnce = false
watch([page, pageSize], () => { void load() }); onMounted(() => { void load(); void loadCustomerOptions() })
onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
</script>
<template>
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="海外采购订单列表">
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-business-purchases" row-key="id" empty-text="暂无符合筛选条件的海外采购订单">
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="海外采购订单筛选" preference-key="overseas-business-purchases-query" @search="search" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="海外采购订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as OverseasPurchaseSummary)">详情</el-button><el-button v-if="(row as OverseasPurchaseSummary).status === 'PENDING_REVIEW' && access.can(overseasBusinessPermissions.purchaseReview)" class="density-action" type="primary" size="small" @click="beginReview(row as OverseasPurchaseSummary)">审核</el-button></div></template>
<template #status="{ row }"><el-tag :type="statusTone(row.status)" size="small" effect="light">{{ statusLabel(row.status, row.procurementDirection) }}</el-tag></template>
<template #pagination-left>共 {{ total }} 条海外采购订单</template>
</DataTable></div></section>
<AppDialog v-model="reviewOpen" class="admin-density-dialog admin-workbench is-dialog" title="采购审核" width="560px" confirm-text="提交审核" :confirm-loading="reviewSaving" :confirm-disabled="reviewLoading" @confirm="submitReview">
<el-form :model="reviewForm" label-position="top" class="density-form density-form--outlined">
<el-form-item label="审核结果"><el-radio-group v-model="reviewForm.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">驳回</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="reviewForm.decision === 'APPROVE'" label="采购仓库" required><el-select v-model="reviewForm.purchaseWarehouseId" filterable :loading="reviewLoading" placeholder="请选择采购仓库"><el-option v-for="warehouse in reviewWarehouses" :key="warehouse.warehouseId" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseId" /></el-select></el-form-item>
<el-form-item v-else label="驳回原因" required><el-input v-model="reviewForm.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit /></el-form-item>
</el-form>
</AppDialog>
</ListPageLayout></AdminPage>
</template>
<style scoped>
.table-panel { overflow: hidden; }
</style>