This commit is contained in:
2026-09-15 09:58:15 +08:00
parent 3f998bdb88
commit 4bf66d9571
90 changed files with 1599 additions and 1388 deletions
@@ -0,0 +1,11 @@
/* Shared business form layout for purchase and local transport editors. */
.admin-density.overseas-business-editor { width: 100%; max-width: 1120px; }
.overseas-business-editor .overseas-business-form-layout { gap: 0; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); }
.overseas-business-editor .form-reference { min-width: 0; color: var(--cc-muted); font-size: var(--admin-font-body); overflow-wrap: anywhere; }
.overseas-business-editor .form-panel { min-width: 0; padding: var(--admin-space-5); }
.overseas-business-editor .form-grid { display: grid; min-width: 0; gap: 0 var(--admin-space-5); }
.overseas-business-editor .form-grid :is(.el-select, .el-date-editor, .el-input-number) { width: 100%; }
.overseas-business-editor .form-grid small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.overseas-business-editor .field-error { display: block; margin-top: var(--admin-space-1); color: var(--el-color-danger); font-size: var(--admin-font-caption); }
.overseas-business-editor .form-grid .field-error { color: var(--el-color-danger); }
.overseas-business-editor .form-actions { display: flex; justify-content: flex-end; gap: var(--admin-space-3); padding-top: var(--admin-space-5); border-top: 1px solid var(--cc-border); }
@@ -11,7 +11,7 @@ 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,
createOverseasPurchaseFee, uploadOverseasPurchaseFile, createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder,
listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchaseWarehouseOptions,
listOverseasPurchases, listUsTruckOrders, reviewOverseasPurchase, updateLocalLooseboardOrder, updateOverseasPurchase, updateUsTruckOrder,
} from '../api/platformOverseasBusiness'
@@ -64,13 +64,50 @@ describe('overseas business mounted pages', () => {
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '可选客户', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
})
it('keeps all procurement facts in dense groups and business references in the heading', async () => {
routing.route.params = { purchaseId: 'internal-purchase-route' }; routing.route.query = { customerId: '10001' }
const wrapper = mountPage(PurchaseOrderDetailView)
await flushPromises()
expect(wrapper.get('.density-detail-title').text()).toBe('OP-100')
expect(wrapper.get('[aria-label="采购订单概览"]').text()).toContain('洛杉矶采购仓')
expect(wrapper.findAll('.density-fields')).toHaveLength(4)
expect(wrapper.findAll('.density-fields dt')).toHaveLength(23)
expect(wrapper.findAll('.density-fields .wide')).toHaveLength(4)
expect(wrapper.text()).not.toContain('internal-purchase-route')
await wrapper.get('.density-back').trigger('click')
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-purchases' })
})
it('retains the voucher upload and fee dialog workflow with teleported density', async () => {
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
const voucher = { id: 'voucher-1', fileId: 'file-1', fileName: 'voucher.pdf', category: 'VOUCHER' as const, contentType: 'application/pdf', sizeBytes: 3, createdAt: '2026-09-14T10:00:00' }
vi.mocked(uploadOverseasPurchaseFile).mockReset().mockResolvedValue(voucher)
vi.mocked(createOverseasPurchaseFee).mockReset().mockResolvedValue({ id: 'fee-1', feeNo: 'FEE-1', feeItemCode: 'COURIER', feeItemName: '快递费', amount: '25', currency: 'CNY', voucherFileId: 'file-1', voucherFileName: 'voucher.pdf', remark: null, walletStatus: 'FREEZE_PENDING', walletErrorMessage: null, createdAt: '2026-09-14T10:00:00' })
const wrapper = mountPage(PurchaseOrderDetailView)
await flushPromises()
const input = wrapper.findAll('input[type="file"]')[1]!
const file = new File(['pdf'], 'voucher.pdf', { type: 'application/pdf' })
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
await input.trigger('change'); await flushPromises()
expect(uploadOverseasPurchaseFile).toHaveBeenCalledWith('OP-100', '10001', 'VOUCHER', file)
const dialog = wrapper.findAllComponents(AppDialog)[1]!
expect(dialog.props('modelValue')).toBe(true)
expect(document.querySelector('.admin-density-dialog .density-form')).not.toBeNull()
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 25)
await wrapper.vm.$nextTick()
dialog.vm.$emit('confirm'); await flushPromises()
expect(createOverseasPurchaseFee).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ amount: 25, voucherFileId: 'file-1' }))
expect(dialog.props('modelValue')).toBe(false)
})
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('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').exists()).toBe(false); expect(wrapper.get('.density-back').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,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onActivated, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Check, Upload, X } from '@lucide/vue'
@@ -8,6 +9,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -94,24 +96,41 @@ 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><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>
<AdminPage class="admin-density"><DetailPageLayout class="purchase-detail" aria-label="海外采购订单详情">
<template #header>
<div class="density-detail-header">
<div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.orderNo || '—' }}</h1><StatusBadge v-if="detail" :status="statusLabels[detail.status]" /></div>
<div v-if="detail?.status === 'PENDING_REVIEW' && access.can(overseasBusinessPermissions.purchaseReview)" class="header-actions"><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></div>
</div>
</template>
<template v-if="detail" #summary><dl class="density-detail-overview" aria-label="采购订单概览"><div><dt>客户</dt><dd>{{ display(detail.customerName) }}</dd></div><div><dt>采购仓库</dt><dd>{{ display(detail.purchaseWarehouseName) }}</dd></div><div><dt>采购数量</dt><dd>{{ display(detail.purchaseQuantity) }}</dd></div><div><dt>入库量</dt><dd>{{ display(detail.inboundQuantity) }}</dd></div><div><dt>出库量</dt><dd>{{ display(detail.outboundQuantity) }}</dd></div><div><dt>库存量</dt><dd>{{ display(detail.inventoryQuantity) }}</dd></div></dl></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 v-else v-loading="loading" class="detail-surface">
<div class="detail-grid">
<section><h3>商品信息</h3><dl><div v-for="field in productFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>库存信息</h3><dl><div v-for="field in stockFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>订单信息</h3><dl><div v-for="field in orderFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>快递信息</h3><dl><div v-for="field in courierFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>商品信息</h3><dl class="density-fields"><div v-for="field in productFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>库存信息</h3><dl class="density-fields"><div v-for="field in stockFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>订单信息</h3><dl class="density-fields"><div v-for="field in orderFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>快递信息</h3><dl class="density-fields"><div v-for="field in courierFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section class="full"><div class="section-heading"><h3>出库明细</h3><span>共 {{ outboundDetails.length }} 条,出库量 {{ outboundDetails.reduce((sum, item) => sum + item.quantity, 0) }}</span></div><DataTable :data="outboundDetails" :columns="[{ key: 'quantity', label: '出库数量', width: 110 }, { key: 'carrier', label: '承运商', minWidth: 140 }, { key: 'trackingNo', label: '运单号', minWidth: 160 }, { key: 'shippedAt', label: '出库时间', minWidth: 155, formatter: (row: any) => formatDateTime(row.shippedAt) }, { key: 'remark', label: '备注', minWidth: 180 }]" row-key="id" empty-text="暂无出库明细" /></section>
<section class="full"><div class="section-heading"><h3>签收文件与费用凭证</h3><div v-if="access.can(overseasBusinessPermissions.purchaseEdit)" class="section-actions"><input ref="receiptInput" hidden type="file" @change="upload('RECEIPT', $event)"><input ref="voucherInput" hidden type="file" @change="upload('VOUCHER', $event)"><el-button :loading="uploading" @click="receiptInput?.click()"><Upload :size="15" />上传签收文件</el-button><el-button type="primary" :loading="uploading" :disabled="!detail || ['PENDING_REVIEW', 'REJECTED'].includes(detail.status)" @click="voucherInput?.click()"><Upload :size="15" />登记费用</el-button></div></div><DataTable :data="detail?.files || []" :columns="fileColumns" row-key="id" empty-text="暂无签收文件或费用凭证" /></section>
<section class="full"><div class="section-heading"><h3>费用明细</h3><span>每条费用独立冻结,订单全部入库后结算。</span></div><DataTable :data="detail?.fees || []" :columns="feeColumns" row-key="id" empty-text="暂无费用明细" /></section>
<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="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>
</DetailPageLayout><AppDialog class="admin-density-dialog" 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" /><p class="review-reference">订单编号:{{ detail?.orderNo || '—' }}</p><el-form class="density-form 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 class="admin-density-dialog" v-model="feeDialogOpen" title="登记采购费用" width="min(560px, calc(100vw - 28px))" confirm-text="确认登记" :confirm-loading="savingFee" @confirm="saveFee"><el-form class="density-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; }.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%}
.purchase-detail { gap: 0; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); }
.header-actions, .section-actions { display: flex; align-items: center; gap: var(--admin-space-3); }
.detail-surface { min-width: 0; padding: var(--admin-space-5); }
.detail-grid { display: grid; min-width: 0; gap: var(--admin-space-5); }
.detail-grid section { min-width: 0; }
.detail-grid h3 { margin: 0 0 var(--admin-space-3); font-size: var(--admin-font-section); }
.detail-grid .density-fields > .wide { grid-column: span 2; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); margin-bottom: var(--admin-space-3); }
.section-heading h3 { margin: 0; }
.section-heading span, .review-reference { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.review-reference { margin: var(--admin-space-3) 0; }
:deep(.el-input-number) { width: 100%; }
</style>
@@ -1,7 +1,9 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '../overseasBusinessDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Save, Plus, Trash2 } from '@lucide/vue'
import { ArrowLeft, Save } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -72,18 +74,26 @@ watch([purchaseId, customerId], () => { void loadWarehouses().then(load) }, { im
</script>
<template>
<AdminPage width="comfortable"><OperationErrorToast :details="submitError" /><DetailPageLayout aria-label="编辑海外采购订单">
<template #header><div class="form-header"><div><h2>编辑海外采购订单</h2><span>订单编号:{{ form.orderNo || purchaseId || '—' }}</span></div><el-button @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId }, query: { customerId } })"><ArrowLeft :size="16" />返回</el-button></div></template>
<AdminPage class="admin-density overseas-business-editor"><OperationErrorToast :details="submitError" /><DetailPageLayout class="overseas-business-form-layout" aria-label="编辑海外采购订单">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId }, query: { customerId } })"><ArrowLeft :size="16" />返回详情</el-button><h1 class="density-detail-title">编辑海外采购订单</h1><span class="form-reference">{{ form.orderNo || '—' }}</span></div></div></template>
<LoadErrorState v-if="loadError" title="采购订单暂时无法编辑" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<section v-else v-loading="loading" class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving">
<FormSection title="商品信息"><div class="form-grid three"><el-form-item label="订单编号"><el-input v-model="form.orderNo" disabled /></el-form-item><el-form-item label="采购品类" prop="purchaseCategory"><el-input v-model="form.purchaseCategory" maxlength="128" placeholder="请输入采购品类" /></el-form-item><el-form-item label="商品名称" prop="productName"><el-input v-model="form.productName" maxlength="255" placeholder="请输入商品名称" /></el-form-item><el-form-item class="span-2" label="商品规格"><el-input v-model="form.productSpecification" maxlength="500" placeholder="请输入商品规格" /></el-form-item><el-form-item class="span-3" label="商品链接"><el-input v-model="form.productUrl" maxlength="2000" placeholder="请输入商品链接" /></el-form-item></div></FormSection>
<FormSection title="库存与快递信息"><div class="form-grid three"><el-form-item label="入库量"><el-input-number v-model="form.inboundQuantity" :min="0" :disabled="!inboundEditable" controls-position="right" placeholder="请输入入库数量" /><small v-if="!inboundEditable">服务端仅允许已通过订单修改入库量</small></el-form-item><el-form-item label="出库量"><el-input :model-value="outboundQuantity" disabled /></el-form-item><el-form-item label="状态"><el-input :model-value="statusLabels[form.status]" disabled /></el-form-item><el-form-item label="采购仓库"><el-select v-model="form.purchaseWarehouseId" clearable filterable :loading="warehousesLoading" :disabled="Boolean(warehousesError)" placeholder="请选择采购仓"><el-option v-for="item in warehouses" :key="item.warehouseId" :label="`${item.warehouseName}${item.warehouseCode ? ` · ${item.warehouseCode}` : ''}`" :value="item.warehouseId" /></el-select><small v-if="warehousesError" class="field-error">{{ warehousesError }}</small></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></el-form-item><el-form-item label="国内承运商"><el-input v-model="form.domesticCarrier" maxlength="128" placeholder="请输入国内承运商" /></el-form-item><el-form-item label="国内运单号"><el-input v-model="form.domesticTrackingNo" maxlength="128" placeholder="请输入国内物流单号" /></el-form-item><el-form-item label="海外承运商"><el-input v-model="form.overseasCarrier" maxlength="128" placeholder="请输入海外承运商" /></el-form-item><el-form-item label="海外运单号"><el-input v-model="form.overseasTrackingNo" maxlength="128" placeholder="请输入海外物流单号" /></el-form-item><el-form-item class="span-3" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></div></FormSection>
<FormSection title="出库明细"><div class="outbound-head"><span>出库总量:<b>{{ outboundQuantity }}</b> / {{ form.inboundQuantity }}</span><el-button type="primary" plain size="small" @click="addOutbound"><Plus :size="14" />添加出库明细</el-button></div><div v-if="!form.outboundDetails.length" class="empty-outbound">暂无出库明细,可添加多条出库记录</div><div v-for="(item, index) in form.outboundDetails" :key="item.id" class="outbound-row"><el-form-item label="出库数量"><el-input-number v-model="item.quantity" :min="0" :max="form.inboundQuantity" controls-position="right" placeholder="请输入出库数量" /></el-form-item><el-form-item label="承运商"><el-input v-model="item.carrier" maxlength="128" placeholder="请输入快递或物流名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="item.trackingNo" maxlength="128" placeholder="请输入物流单号" /></el-form-item><el-form-item label="出库时间"><el-date-picker v-model="item.shippedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></el-form-item><el-form-item class="outbound-remark" label="备注"><el-input v-model="item.remark" maxlength="500" placeholder="请输入本次出库备注(选填)" /></el-form-item><el-button class="remove-outbound" type="danger" link @click="removeOutbound(index)" :aria-label="`删除第 ${index + 1} 条出库明细`"><Trash2 :size="16" /></el-button></div></FormSection>
<section v-else v-loading="loading" class="form-panel"><el-form class="density-form" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving">
<FormSection class="density-form-section" title="商品信息"><div class="form-grid three"><el-form-item label="订单编号"><el-input v-model="form.orderNo" disabled /></el-form-item><el-form-item label="采购品类" prop="purchaseCategory"><el-input v-model="form.purchaseCategory" maxlength="128" placeholder="请输入采购品类" /></el-form-item><el-form-item label="商品名称" prop="productName"><el-input v-model="form.productName" maxlength="255" placeholder="请输入商品名称" /></el-form-item><el-form-item class="span-2" label="商品规格"><el-input v-model="form.productSpecification" maxlength="500" placeholder="请输入商品规格" /></el-form-item><el-form-item class="span-3" label="商品链接"><el-input v-model="form.productUrl" maxlength="2000" placeholder="请输入商品链接" /></el-form-item></div></FormSection>
<FormSection class="density-form-section" title="库存与快递信息"><div class="form-grid three"><el-form-item label="入库量"><el-input-number v-model="form.inboundQuantity" :min="0" :disabled="!inboundEditable" controls-position="right" placeholder="请输入入库数量" /><small v-if="!inboundEditable">服务端仅允许已通过订单修改入库量</small></el-form-item><el-form-item label="出库量"><el-input :model-value="outboundQuantity" disabled /></el-form-item><el-form-item label="状态"><el-input :model-value="statusLabels[form.status]" disabled /></el-form-item><el-form-item label="采购仓库"><el-select v-model="form.purchaseWarehouseId" clearable filterable :loading="warehousesLoading" :disabled="Boolean(warehousesError)" placeholder="请选择采购仓"><el-option v-for="item in warehouses" :key="item.warehouseId" :label="`${item.warehouseName}${item.warehouseCode ? ` · ${item.warehouseCode}` : ''}`" :value="item.warehouseId" /></el-select><small v-if="warehousesError" class="field-error">{{ warehousesError }}</small></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></el-form-item><el-form-item label="国内承运商"><el-input v-model="form.domesticCarrier" maxlength="128" placeholder="请输入国内承运商" /></el-form-item><el-form-item label="国内运单号"><el-input v-model="form.domesticTrackingNo" maxlength="128" placeholder="请输入国内物流单号" /></el-form-item><el-form-item label="海外承运商"><el-input v-model="form.overseasCarrier" maxlength="128" placeholder="请输入海外承运商" /></el-form-item><el-form-item label="海外运单号"><el-input v-model="form.overseasTrackingNo" maxlength="128" placeholder="请输入海外物流单号" /></el-form-item><el-form-item class="span-3" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></div></FormSection>
<FormSection class="density-form-section" title="出库明细"><div class="outbound-head"><span>出库总量:<b>{{ outboundQuantity }}</b> / {{ form.inboundQuantity }}</span><el-button class="density-action" data-tone="primary" size="small" @click="addOutbound">添加出库明细</el-button></div><div v-if="!form.outboundDetails.length" class="empty-outbound">暂无出库明细,可添加多条出库记录</div><div v-for="(item, index) in form.outboundDetails" :key="item.id" class="outbound-row"><el-form-item label="出库数量"><el-input-number v-model="item.quantity" :min="0" :max="form.inboundQuantity" controls-position="right" placeholder="请输入出库数量" /></el-form-item><el-form-item label="承运商"><el-input v-model="item.carrier" maxlength="128" placeholder="请输入快递或物流名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="item.trackingNo" maxlength="128" placeholder="请输入物流单号" /></el-form-item><el-form-item label="出库时间"><el-date-picker v-model="item.shippedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></el-form-item><el-form-item class="outbound-remark" label="备注"><el-input v-model="item.remark" maxlength="500" placeholder="请输入本次出库备注(选填)" /></el-form-item><el-button class="remove-outbound density-action" data-tone="danger" @click="removeOutbound(index)" :aria-label="`删除第 ${index + 1} 条出库明细`">删除</el-button></div></FormSection>
</el-form><footer class="form-actions"><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存</el-button><el-button :disabled="saving" @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId }, query: { customerId } })">取消</el-button></footer></section>
</DetailPageLayout></AdminPage>
</template>
<style scoped>
.form-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; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { padding:20px 24px; }.form-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:0 20px; }.span-2 { grid-column:span 2; }.span-3 { grid-column:1 / -1; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.form-grid small { display:block; margin-top:5px; color:var(--cc-muted); font-size:12px; }.form-grid .field-error { color:var(--el-color-danger); }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
.outbound-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; color:var(--cc-muted); font-size:13px; }.outbound-head b { color:var(--cc-text); }.empty-outbound { padding:18px; color:var(--cc-muted); text-align:center; background:var(--cc-surface-muted); }.outbound-row { display:grid; grid-template-columns:110px 1fr 1fr 190px 1fr 32px; gap:12px; align-items:start; padding:12px; margin-bottom:10px; border:1px solid var(--cc-border); border-radius:6px; }.outbound-row :deep(.el-form-item) { margin-bottom:0; }.outbound-row :deep(.el-input-number), .outbound-row :deep(.el-date-editor) { width:100%; }.remove-outbound { margin-top:30px; }
.form-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.span-2 { grid-column: span 2; }
.span-3 { grid-column: 1 / -1; }
.outbound-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--admin-space-3); color: var(--cc-muted); font-size: var(--admin-font-body); }
.outbound-head b { color: var(--cc-text); }
.empty-outbound { padding: var(--admin-space-5); color: var(--cc-muted); text-align: center; }
.outbound-row { display: grid; grid-template-columns: 110px minmax(0,1fr) minmax(0,1fr) 190px minmax(0,1fr) 60px; gap: var(--admin-space-3); align-items: start; padding: var(--admin-space-3) 0; border-bottom: 1px solid var(--cc-border); }
.outbound-row :deep(.el-form-item) { margin-bottom: 0; }
.outbound-row :deep(.el-input-number), .outbound-row :deep(.el-date-editor) { width: 100%; }
.remove-outbound { align-self: end; }
</style>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
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'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -60,16 +60,16 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
</script>
<template>
<AdminPage><ListPageLayout aria-label="海外采购订单列表">
<AdminPage class="admin-density"><ListPageLayout aria-label="海外采购订单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="海外采购订单筛选" preference-key="overseas-business-purchases-query" @search="search" /></template>
<template v-if="loadError" #state><LoadErrorState title="海外采购订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" 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-business-purchases" row-key="id" empty-text="暂无符合筛选条件的海外采购订单">
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row as OverseasPurchaseSummary)"><Eye :size="15" />详情</el-button><el-button v-if="access.can(overseasBusinessPermissions.purchaseEdit)" size="small" @click="open(row as OverseasPurchaseSummary, true)"><Pencil :size="15" />编辑</el-button></div></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as OverseasPurchaseSummary)">详情</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(overseasBusinessPermissions.purchaseEdit)" size="small" @click="open(row as OverseasPurchaseSummary, true)">编辑</el-button></div></template>
<template #pagination-left>共 {{ total }} 条海外采购订单</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
<style scoped>
.table-panel { overflow:hidden; }.table-actions { display:flex; gap:6px; }.table-actions :deep(.el-button) { margin-left:0; }
.table-panel { overflow: hidden; }
</style>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '../overseasBusinessDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Save } from '@lucide/vue'
@@ -104,20 +106,24 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
</script>
<template>
<AdminPage width="comfortable"><OperationErrorToast :details="submitError" /><DetailPageLayout :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
<template #header><div class="form-header"><div><h2>{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h2><span v-if="editing">订单编号:{{ form.orderNo || orderId }}</span></div><el-button @click="back"><ArrowLeft :size="16" />返回列表</el-button></div></template>
<AdminPage class="admin-density overseas-business-editor"><OperationErrorToast :details="submitError" /><DetailPageLayout class="overseas-business-form-layout" :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h1><span v-if="editing" class="form-reference">{{ form.orderNo || '—' }}</span></div></div></template>
<LoadErrorState v-if="loadError" :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<section v-else v-loading="loading" class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || !editableStatus">
<FormSection v-if="!editing" title="客户信息"><el-form-item label="客户"><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户" style="width:100%"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
<section v-else v-loading="loading" class="form-panel"><el-form class="density-form" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || !editableStatus">
<FormSection class="density-form-section" v-if="!editing" title="客户信息"><el-form-item label="客户"><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户" style="width:100%"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
<el-alert v-if="editing && !editableStatus" type="warning" :closable="false" show-icon title="当前状态不可普通编辑,请返回列表查看" />
<FormSection v-if="isLooseboard" title="散板方式"><div class="choice-block"><el-form-item label="运输方式" prop="transportMode"><el-radio-group v-model="form.transportMode"><el-radio-button v-for="item in transportModes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><el-form-item label="是否打盘" prop="palletRequired"><el-radio-group v-model="form.palletRequired"><el-radio-button value="YES">是</el-radio-button><el-radio-button value="NO">否</el-radio-button></el-radio-group></el-form-item></div></FormSection>
<FormSection title="运输信息"><el-form-item label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="500" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="500" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人" prop="contactName"><el-input v-model="form.contactName" maxlength="128" placeholder="请输入联系人姓名" /></el-form-item><el-form-item label="联系方式" prop="contactInfo"><el-input v-model="form.contactInfo" maxlength="128" placeholder="请输入联系方式" /></el-form-item></div></FormSection>
<FormSection title="📦 货物信息"><div class="form-grid" :class="isLooseboard ? 'four' : 'five'"><el-form-item label="柜型"><el-select v-model="form.containerType" clearable><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item><el-form-item v-if="!isLooseboard" label="托数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" controls-position="right" placeholder="请输入托盘数" /></el-form-item><el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.packageCount" :min="0" controls-position="right" placeholder="请输入件数" /></el-form-item><el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input v-model="form.totalWeightKg" inputmode="decimal" placeholder="请输入总重量" /></el-form-item><el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input v-model="form.totalVolumeCbm" inputmode="decimal" placeholder="请输入总体积" /></el-form-item></div></FormSection>
<FormSection title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价" prop="budgetAmount"><el-input v-model="form.budgetAmount" inputmode="decimal" placeholder="请输入预算金额" ><template #append>{{ form.currency }}</template></el-input></el-form-item><el-form-item label="要求时间" prop="requestedDate"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" clearable /></el-form-item><el-form-item v-if="editing" label="状态"><el-input :model-value="statusLabel" disabled /></el-form-item><el-form-item class="span-2" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></div></FormSection>
<FormSection class="density-form-section" v-if="isLooseboard" title="散板方式"><div class="choice-block"><el-form-item label="运输方式" prop="transportMode"><el-radio-group v-model="form.transportMode"><el-radio-button v-for="item in transportModes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><el-form-item label="是否打盘" prop="palletRequired"><el-radio-group v-model="form.palletRequired"><el-radio-button value="YES">是</el-radio-button><el-radio-button value="NO">否</el-radio-button></el-radio-group></el-form-item></div></FormSection>
<FormSection class="density-form-section" title="运输信息"><el-form-item label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="500" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="500" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人" prop="contactName"><el-input v-model="form.contactName" maxlength="128" placeholder="请输入联系人姓名" /></el-form-item><el-form-item label="联系方式" prop="contactInfo"><el-input v-model="form.contactInfo" maxlength="128" placeholder="请输入联系方式" /></el-form-item></div></FormSection>
<FormSection class="density-form-section" title="📦 货物信息"><div class="form-grid" :class="isLooseboard ? 'four' : 'five'"><el-form-item label="柜型"><el-select v-model="form.containerType" clearable><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item><el-form-item v-if="!isLooseboard" label="托数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" controls-position="right" placeholder="请输入托盘数" /></el-form-item><el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.packageCount" :min="0" controls-position="right" placeholder="请输入件数" /></el-form-item><el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input v-model="form.totalWeightKg" inputmode="decimal" placeholder="请输入总重量" /></el-form-item><el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input v-model="form.totalVolumeCbm" inputmode="decimal" placeholder="请输入总体积" /></el-form-item></div></FormSection>
<FormSection class="density-form-section" title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价" prop="budgetAmount"><el-input v-model="form.budgetAmount" inputmode="decimal" placeholder="请输入预算金额" ><template #append>{{ form.currency }}</template></el-input></el-form-item><el-form-item label="要求时间" prop="requestedDate"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" clearable /></el-form-item><el-form-item v-if="editing" label="状态"><el-input :model-value="statusLabel" disabled /></el-form-item><el-form-item class="span-2" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></div></FormSection>
</el-form><footer class="form-actions"><el-button @click="back">取消</el-button><el-button type="primary" :loading="saving" :disabled="!editableStatus" @click="save"><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></footer></section>
</DetailPageLayout></AdminPage>
</template>
<style scoped>
.form-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; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { max-width:900px; padding:20px 24px; }.form-grid { display:grid; gap:0 24px; }.form-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }.form-grid.four { grid-template-columns:repeat(4,minmax(0,1fr)); gap:0 16px; }.form-grid.five { grid-template-columns:repeat(5,minmax(0,1fr)); gap:0 16px; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.choice-block { display:grid; grid-template-columns:1fr 1fr; gap:24px; }.span-2 { grid-column:1 / -1; }.field-error { display:block; margin-top:5px; color:var(--el-color-danger); font-size:12px; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
.form-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.form-grid.four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.form-grid.five { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.choice-block { display: grid; grid-template-columns: 1fr 1fr; gap: var(--admin-space-5); }
.span-2 { grid-column: 1 / -1; }
</style>
@@ -1,7 +1,8 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Pencil, Plus } from '@lucide/vue'
import { Plus } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -74,12 +75,12 @@ watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page
</script>
<template>
<AdminPage><ListPageLayout :aria-label="`${domainLabel}订单列表`">
<AdminPage class="admin-density"><ListPageLayout :aria-label="`${domainLabel}订单列表`">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :ariaLabel="`${domainLabel}订单筛选`" :preference-key="queryPreferenceKey" @search="search" /></template>
<template v-if="loadError" #state><LoadErrorState :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" 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-business-${domain}`" row-key="id" :empty-text="`暂无符合筛选条件的${domainLabel}订单`">
<template #toolbar-actions><el-button v-if="access.can(createPermission)" type="primary" @click="openCreate"><Plus :size="16" />新增订单</el-button></template>
<template #actions="{ row }"><el-tooltip v-if="access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)"><Pencil :size="15" />编辑</el-button></span></el-tooltip></template>
<template #actions="{ row }"><el-tooltip v-if="access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></template>
<template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template>
</DataTable></section>
</ListPageLayout></AdminPage>