This commit is contained in:
2026-09-14 10:02:39 +08:00
parent 8bc86c8567
commit 89dc3bf671
11 changed files with 115 additions and 33 deletions
@@ -1,4 +1,6 @@
import ElementPlus from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -6,11 +8,12 @@ import { ApiResponseError } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { listPlatformTenants } from '@/modules/iam'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder, OverseasPurchaseDetail } from '../types'
import {
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder,
listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchaseWarehouseOptions,
listOverseasPurchases, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase, updateUsTruckOrder,
listOverseasPurchases, listUsTruckOrders, reviewOverseasPurchase, updateLocalLooseboardOrder, updateOverseasPurchase, updateUsTruckOrder,
} from '../api/platformOverseasBusiness'
import PurchaseOrdersView from './PurchaseOrdersView.vue'
import PurchaseOrderDetailView from './PurchaseOrderDetailView.vue'
@@ -18,11 +21,14 @@ import PurchaseOrderEditView from './PurchaseOrderEditView.vue'
import TransportOrdersView from './TransportOrdersView.vue'
import TransportOrderFormView from './TransportOrderFormView.vue'
const purchaseListSource = readFileSync(resolve(process.cwd(), 'src/modules/overseas-business/pages/PurchaseOrdersView.vue'), 'utf8')
const purchaseDetailSource = readFileSync(resolve(process.cwd(), 'src/modules/overseas-business/pages/PurchaseOrderDetailView.vue'), 'utf8')
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/platformOverseasBusiness', () => ({
listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), updateOverseasPurchase: vi.fn(), uploadOverseasPurchaseFile: vi.fn(), createOverseasPurchaseFee: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(),
listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), updateOverseasPurchase: vi.fn(), reviewOverseasPurchase: vi.fn(), uploadOverseasPurchaseFile: vi.fn(), createOverseasPurchaseFee: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(),
listUsTruckOrders: vi.fn(), getUsTruckOrder: vi.fn(), createUsTruckOrder: vi.fn(), updateUsTruckOrder: vi.fn(),
listLocalLooseboardOrders: vi.fn(), getLocalLooseboardOrder: vi.fn(), createLocalLooseboardOrder: vi.fn(), updateLocalLooseboardOrder: vi.fn(),
}))
@@ -43,7 +49,7 @@ const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', d
const looseboard: LocalLooseboardOrder = { id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: null, packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia }
function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:purchase:review', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia }
function mountPage(component: Parameters<typeof mount>[0], props?: Record<string, unknown>) { const pinia = activatePermissions(); return mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } }) }
enableAutoUnmount(afterEach); afterAll(() => vi.unstubAllGlobals())
@@ -51,7 +57,7 @@ describe('overseas business mounted pages', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()); document.body.innerHTML = ''
routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(purchase); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 })
vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(purchase); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 }); vi.mocked(reviewOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, status: 'APPROVED', version: 4 })
vi.mocked(listOverseasPurchaseCustomerOptions).mockReset().mockResolvedValue([{ customerId: '10001', customerName: '客户甲' }]); vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶采购仓', countryCode: 'US' }])
vi.mocked(listUsTruckOrders).mockReset().mockResolvedValue({ records: [truck], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getUsTruckOrder).mockReset().mockResolvedValue(truckDetail); vi.mocked(createUsTruckOrder).mockReset().mockResolvedValue(truckDetail); vi.mocked(updateUsTruckOrder).mockReset().mockResolvedValue(truckDetail)
vi.mocked(listLocalLooseboardOrders).mockReset().mockResolvedValue({ records: [looseboard], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail); vi.mocked(createLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail); vi.mocked(updateLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail)
@@ -59,9 +65,12 @@ describe('overseas business mounted pages', () => {
})
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('待出库'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
it('refreshes the cached procurement list after returning from detail or edit', () => { expect(purchaseListSource).toContain('onActivated'); expect(purchaseListSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) })
it('refreshes the cached procurement detail after an edit completes', () => { expect(purchaseDetailSource).toContain('onActivated'); expect(purchaseDetailSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) })
it('paginates procurement results', async () => { vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 40, pages: 2 }); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); await wrapper.find('button[aria-label="第 2 页"]').trigger('click'); await flushPromises(); expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) })
it('renders procurement empty and traceable 403 states', async () => { vi.mocked(listOverseasPurchases).mockResolvedValueOnce({ records: [], page: 1, size: 20, total: 0, pages: 0 }); let wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('暂无符合筛选条件的海外采购订单'); wrapper.unmount(); vi.mocked(listOverseasPurchases).mockRejectedValue(new ApiResponseError('forbidden', 'A0403', 403, null, 'REQ-403')); wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('当前账号无权访问该数据'); expect(wrapper.text()).toContain('REQ-403') })
it('mounts procurement detail operation history without exposing internal operator ids', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('操作流水'); expect(wrapper.text()).toContain('编辑'); expect(wrapper.text()).not.toContain('INTERNAL-OPERATOR-7') })
it('reviews a pending procurement from its detail page', async () => { const pending = { ...purchase, status: 'PENDING_REVIEW' as const, inboundQuantity: 0, inventoryQuantity: 0, version: 2 }; routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(getOverseasPurchase).mockResolvedValue(pending); vi.mocked(reviewOverseasPurchase).mockResolvedValue({ ...pending, status: 'APPROVED', version: 3 }); const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('通过'))?.trigger('click'); await wrapper.vm.$nextTick(); const reviewDialog = wrapper.findAllComponents(AppDialog)[0]; expect(reviewDialog.props('modelValue')).toBe(true); reviewDialog.vm.$emit('confirm'); await flushPromises(); expect(reviewOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ version: 2, decision: 'APPROVE', idempotencyKey: expect.any(String) })); expect(wrapper.text()).toContain('已通过'); expect(wrapper.find('.header-actions').text()).toBe('返回') })
it('submits procurement explicit null clears and keeps a 409 visible without local overwrite', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(updateOverseasPurchase).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新后重试', 'B0409', 409)); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); const productUrl = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('商品链接'))!.find('input'); const domesticCarrier = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('国内承运商'))!.find('input'); await productUrl.setValue(''); await domesticCarrier.setValue(''); wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', null); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', null); await wrapper.vm.$nextTick(); await wrapper.findAll('button').find((button) => button.text().trim() === '保存')?.trigger('click'); await flushPromises(); expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ version: 3, productUrl: null, domesticCarrier: null, estimatedArrivalAt: null, purchaseWarehouseId: null })); expect(document.body.textContent).toContain('订单版本冲突,请刷新后重试'); expect((productUrl.element as HTMLInputElement).value).toBe(''); expect((domesticCarrier.element as HTMLInputElement).value).toBe(''); expect(routing.push).not.toHaveBeenCalled() })
it('keeps procurement list usable when customer candidates fail', async () => { vi.mocked(listOverseasPurchaseCustomerOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchases).toHaveBeenCalled(); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).not.toContain('candidate unavailable') })
it('loads procurement edit data but blocks unsafe warehouse selection when candidates fail', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(listOverseasPurchaseWarehouseOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('采购仓候选加载失败'); expect(wrapper.text()).toContain('OP-100') })
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { computed, onActivated, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Upload } from '@lucide/vue'
import { ArrowLeft, Check, Upload, X } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -11,7 +11,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { createOverseasPurchaseFee, getOverseasPurchase, uploadOverseasPurchaseFile } from '../api/platformOverseasBusiness'
import { createOverseasPurchaseFee, getOverseasPurchase, reviewOverseasPurchase, uploadOverseasPurchaseFile } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasBusinessHistory, OverseasPurchaseDetail, OverseasPurchaseFile, OverseasPurchaseFee, OverseasPurchaseStatus } from '../types'
@@ -29,6 +29,7 @@ const outboundDetails = computed(() => detail.value?.outboundDetails || [])
const receiptInput = ref<HTMLInputElement | null>(null); const voucherInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false); const feeDialogOpen = ref(false); const savingFee = ref(false); const voucher = ref<OverseasPurchaseFile | null>(null)
const feeForm = reactive({ feeItemCode: 'COURIER', feeItemName: '快递费', amount: 0, remark: '' })
const reviewDialogOpen = ref(false); const reviewing = ref(false); const reviewDecision = ref<'APPROVE' | 'REJECT'>('APPROVE'); const reviewRemark = ref(''); const reviewKey = ref('')
const fileColumns: DataTableColumn[] = [{ key: 'category', label: '类型', width: 100, formatter: (row: OverseasPurchaseFile) => row.category === 'RECEIPT' ? '签收文件' : '费用凭证' }, { key: 'fileName', prop: 'fileName', label: '文件名', minWidth: 220 }, { key: 'createdAt', label: '上传时间', minWidth: 155, formatter: (row: OverseasPurchaseFile) => formatDateTime(row.createdAt) }]
const feeColumns: DataTableColumn[] = [{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'feeItemName', prop: 'feeItemName', label: '费用项', minWidth: 120 }, { key: 'amount', label: '金额', width: 120, formatter: (row: OverseasPurchaseFee) => formatAmount(row.amount, row.currency) }, { key: 'voucherFileName', prop: 'voucherFileName', label: '凭证', minWidth: 180 }, { key: 'walletStatus', label: '资金状态', minWidth: 130, formatter: (row: OverseasPurchaseFee) => walletStatus(row.walletStatus) }, { key: 'walletErrorMessage', label: '异常说明', minWidth: 180, formatter: (row: OverseasPurchaseFee) => row.walletErrorMessage || '—' }]
const historyColumns: DataTableColumn[] = [
@@ -45,6 +46,29 @@ function display(value: unknown) { return value === null || value === undefined
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function formatAmount(value: string | null, currency: string) { return value == null ? '—' : `${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}` }
function walletStatus(value: string) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[value] || value }
function idempotencyKey() { return globalThis.crypto?.randomUUID?.() || `purchase-review-${Date.now()}-${Math.random().toString(16).slice(2)}` }
function openReview(decision: 'APPROVE' | 'REJECT') {
reviewDecision.value = decision; reviewRemark.value = ''; reviewKey.value = idempotencyKey(); reviewDialogOpen.value = true
}
function closeReview() {
if (reviewing.value) return
reviewDialogOpen.value = false; reviewRemark.value = ''; reviewKey.value = ''
}
async function submitReview() {
if (!detail.value) return
reviewing.value = true
try {
detail.value = await reviewOverseasPurchase(detail.value.orderNo, customerId.value, {
version: detail.value.version,
decision: reviewDecision.value,
idempotencyKey: reviewKey.value || (reviewKey.value = idempotencyKey()),
remark: reviewRemark.value.trim() || undefined,
})
reviewDialogOpen.value = false; reviewRemark.value = ''; reviewKey.value = ''
ElMessage.success(reviewDecision.value === 'APPROVE' ? '采购订单审核已通过' : '采购订单已拒绝')
} catch (error) { ElMessage.error(getApiErrorDetails(error, '采购订单审核失败').message) }
finally { reviewing.value = false }
}
async function upload(category: 'RECEIPT' | 'VOUCHER', event: Event) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
if (!file || !detail.value) return
@@ -64,12 +88,14 @@ async function load() {
loading.value = true
try { detail.value = await getOverseasPurchase(purchaseId.value, customerId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '海外采购订单详情加载失败') } finally { loading.value = false }
}
let activatedOnce = false
watch([purchaseId, customerId], () => { void load() }, { immediate: true })
onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
</script>
<template>
<AdminPage><DetailPageLayout aria-label="海外采购订单详情">
<template #header><div class="detail-header"><div><h2>海外采购订单详情</h2><span>订单编号:{{ detail?.orderNo || purchaseId || '—' }}</span></div><el-button @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回</el-button></div></template>
<template #header><div class="detail-header"><div><h2>海外采购订单详情</h2><span>订单编号:{{ detail?.orderNo || purchaseId || '—' }}</span></div><div class="header-actions"><template v-if="detail?.status === 'PENDING_REVIEW' && access.can(overseasBusinessPermissions.purchaseReview)"><el-button type="danger" plain @click="openReview('REJECT')"><X :size="16" />拒绝</el-button><el-button type="primary" @click="openReview('APPROVE')"><Check :size="16" />通过</el-button></template><el-button @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回</el-button></div></div></template>
<LoadErrorState v-if="loadError" title="采购订单详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="detail-surface surface-panel">
<div class="detail-grid">
@@ -83,9 +109,9 @@ watch([purchaseId, customerId], () => { void load() }, { immediate: true })
<section class="full"><div class="section-heading"><h3>操作流水</h3><span>展示创建、审核、入库与出库明细变更。</span></div><DataTable :data="detail?.histories || []" :columns="historyColumns" row-key="createdAt" empty-text="暂无操作流水" /></section>
</div>
</div>
</DetailPageLayout><AppDialog v-model="feeDialogOpen" title="登记采购费用" width="min(560px, calc(100vw - 28px))" confirm-text="确认登记" :confirm-loading="savingFee" @confirm="saveFee"><el-form label-position="top"><el-form-item label="费用项" required><el-input v-model="feeForm.feeItemName" maxlength="64" placeholder="请输入费用名称" /></el-form-item><el-form-item label="费用代码" required><el-input v-model="feeForm.feeItemCode" maxlength="64" placeholder="请输入费用代码" /></el-form-item><el-form-item label="费用金额(CNY)" required><el-input-number v-model="feeForm.amount" :min="0.01" :precision="2" controls-position="right" placeholder="请输入费用金额" /></el-form-item><el-form-item label="费用凭证"><el-input :model-value="voucher?.fileName" disabled /></el-form-item><el-form-item label="备注"><el-input v-model="feeForm.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入费用说明(选填)" /></el-form-item></el-form></AppDialog></AdminPage>
</DetailPageLayout><AppDialog v-model="reviewDialogOpen" :title="reviewDecision === 'APPROVE' ? '通过采购订单' : '拒绝采购订单'" width="min(520px, calc(100vw - 28px))" :confirm-text="reviewDecision === 'APPROVE' ? '确认通过' : '确认拒绝'" :confirm-loading="reviewing" @confirm="submitReview" @cancel="closeReview"><el-alert :title="reviewDecision === 'APPROVE' ? '通过后平台可维护采购入库、出库和费用信息。' : '拒绝后该采购订单将结束审核流程。'" :type="reviewDecision === 'APPROVE' ? 'success' : 'warning'" show-icon :closable="false" /><el-form class="review-form" label-position="top"><el-form-item label="审核备注"><el-input v-model="reviewRemark" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="reviewDecision === 'APPROVE' ? '请输入通过说明(选填)' : '请输入拒绝原因(选填)'" /></el-form-item></el-form></AppDialog><AppDialog v-model="feeDialogOpen" title="登记采购费用" width="min(560px, calc(100vw - 28px))" confirm-text="确认登记" :confirm-loading="savingFee" @confirm="saveFee"><el-form label-position="top"><el-form-item label="费用项" required><el-input v-model="feeForm.feeItemName" maxlength="64" placeholder="请输入费用名称" /></el-form-item><el-form-item label="费用代码" required><el-input v-model="feeForm.feeItemCode" maxlength="64" placeholder="请输入费用代码" /></el-form-item><el-form-item label="费用金额(CNY)" required><el-input-number v-model="feeForm.amount" :min="0.01" :precision="2" controls-position="right" placeholder="请输入费用金额" /></el-form-item><el-form-item label="费用凭证"><el-input :model-value="voucher?.fileName" disabled /></el-form-item><el-form-item label="备注"><el-input v-model="feeForm.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入费用说明(选填)" /></el-form-item></el-form></AppDialog></AdminPage>
</template>
<style scoped>
.detail-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.detail-header h2 { margin:0; font-size:16px; }.detail-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.detail-surface { min-height:240px; padding:20px; }.detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:0 24px; }.detail-grid section { padding:0 0 18px; border-bottom:1px solid var(--cc-border); }.detail-grid section:nth-child(3), .detail-grid section:nth-child(4) { padding-top:18px; border-bottom:0; }.detail-grid h3 { margin:0 0 12px; font-size:14px; }.detail-grid .full { grid-column:1 / -1; padding-top:18px; border-top:1px solid var(--cc-border); border-bottom:0; }.detail-grid dl { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px 18px; margin:0; }.detail-grid dl div { min-width:0; }.detail-grid dt { color:var(--cc-muted); font-size:12px; }.detail-grid dd { margin:4px 0 0; overflow-wrap:anywhere; color:var(--cc-text); font-size:13px; }.section-heading,.section-actions { display:flex; align-items:center; justify-content:space-between; gap:10px; }.section-heading span { color:var(--cc-muted); font-size:12px; }:deep(.el-input-number){width:100%}
.detail-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.detail-header h2 { margin:0; font-size:16px; }.detail-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.header-actions { display:flex; align-items:center; gap:10px; }.detail-surface { min-height:240px; padding:20px; }.detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:0 24px; }.detail-grid section { padding:0 0 18px; border-bottom:1px solid var(--cc-border); }.detail-grid section:nth-child(3), .detail-grid section:nth-child(4) { padding-top:18px; border-bottom:0; }.detail-grid h3 { margin:0 0 12px; font-size:14px; }.detail-grid .full { grid-column:1 / -1; padding-top:18px; border-top:1px solid var(--cc-border); border-bottom:0; }.detail-grid dl { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px 18px; margin:0; }.detail-grid dl div { min-width:0; }.detail-grid dt { color:var(--cc-muted); font-size:12px; }.detail-grid dd { margin:4px 0 0; overflow-wrap:anywhere; color:var(--cc-text); font-size:13px; }.section-heading,.section-actions { display:flex; align-items:center; justify-content:space-between; gap:10px; }.section-heading span { color:var(--cc-muted); font-size:12px; }.review-form { margin-top:16px; }:deep(.el-input-number){width:100%}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onActivated, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Eye, Pencil } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -54,7 +54,9 @@ async function load() {
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
function open(row: OverseasPurchaseSummary, edit = false) { void router.push({ name: edit ? 'overseas-business-purchase-edit' : 'overseas-business-purchase-detail', params: { purchaseId: row.orderNo }, query: { customerId: row.customerId } }) }
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>