This commit is contained in:
2026-08-19 16:45:51 +08:00
parent d1de19dc82
commit 65cd9ecefa
79 changed files with 3397 additions and 38 deletions
@@ -0,0 +1,14 @@
# 海外业务待补契约
当前仓库尚未提供海外区域 API Base、海外 Token 选择器或海外采购/本土运输正式端点。模块因此只冻结 TypeScript 请求/响应模型和页面结构,不创建临时 URL,不使用示例数据,也不模拟提交成功。
后端接入前需冻结:
- 海外 Gateway Base 与统一认证层的海外 Token 选择方式;
- 采购订单、卡车订单、散板订单的列表、详情、创建与更新端点;
- 分页信封、长整型 ID 字符串透传、金额/币种精度和时间格式;
- 状态机、允许编辑状态、乐观锁版本、幂等键及错误码;
- 客户、采购仓库等只读候选接口与权限;
- 对应 `list/create/edit` 权限资源在海外服务端的鉴权落点。
接入时应在统一双区域 HTTP/认证层完成 Base 与 Token 选择,业务页面不得自行读写 Token 或创建第二套登录。
@@ -0,0 +1,20 @@
# 四期海外业务原型核对矩阵
原型来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/07-overseas-business.html`;按钮函数来源:同原型目录 `prototype-src/app.js`。
| 页面 | 操作 | 原型证据 | 原型承载方式 | 冻结结论 |
|---|---|---|---|---|
| 海外采购订单 | 列表/筛选 | `page-overseas-purchase` 2–12 行;海外面板 9–11 行;`renderOverseasPurchaseList()` 13531–13534 行 | 独立列表页,订单编号/采购品类/商品名称筛选 | 仅实现海外面板;国内页与 `domestic-*` 全部排除;操作列第一列 |
| 海外采购订单 | 详情 | `page-overseas-purchase-detail` 204–206 行;`showOverseasPurchaseDetail()` 13538–13540 行 | 独立详情页,商品/库存/订单/快递/进销存五分区 | 建立正式详情路由,详情复用 `purchase:list` |
| 海外采购订单 | 编辑 | `page-overseas-purchase-edit` 143–150 行;`editOverseasPurchase()`、`saveOverseasPurchaseEdit()` 13536–13537 行 | 独立编辑页 | 建立正式编辑路由,使用独立 `purchase:edit` 权限 |
| 海外采购订单 | 新增 | 14–15 行明确“历史新增表单仅保留为不可达模板” | 不可达 | 不建立新增入口或路由 |
| 本土卡车 | 列表 | `page-overseas-truck` 58–80 行;`renderTruckOrderList()` 13550–13566 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 |
| 本土卡车 | 新增 | `page-overseas-truck-add` 83–130 行;`saveTruckOrder()` 13567–13579 行 | 独立页面 | 建立正式新增路由;端点未冻结前提交禁用 |
| 本土卡车 | 编辑 | `page-overseas-truck-edit` 313–362 行;`editTruckOrder()`、`saveTruckOrderEdit()` 13630–13674 行 | 独立页面 | 建立正式编辑路由;端点未冻结前提交禁用 |
| 本土卡车 | 详情 | 列表渲染函数 13563 行仅提示“详情开发中” | 原型未定义 | 不建立详情路由 |
| 本土散板 | 列表 | `page-overseas-looseboard` 222–244 行;`renderLooseboardList()` 13589–13605 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 |
| 本土散板 | 新增 | `page-overseas-looseboard-add` 247–310 行;`saveLooseboardOrder()` 13606–13622 行 | 独立页面 | 建立正式新增路由;端点未冻结前提交禁用 |
| 本土散板 | 编辑 | `page-overseas-looseboard-edit` 365–430 行;`editLooseboardOrder()`、`saveLooseboardOrderEdit()` 13676–13726 行 | 独立页面 | 建立正式编辑路由;端点未冻结前提交禁用 |
| 本土散板 | 详情 | 列表渲染函数 13602 行仅提示“详情开发中” | 原型未定义 | 不建立详情路由 |
删除、审核/确认和文件预览在三类正式页面中均未定义,本批不自行补充承载方式。
@@ -0,0 +1,31 @@
import type {
LocalLooseboardOrder,
LocalTransportOrder,
LocalTransportQuery,
OverseasBusinessPage,
OverseasPurchaseOrder,
OverseasPurchaseQuery,
SaveLocalLooseboardOrderRequest,
SaveLocalTransportOrderRequest,
UpdateOverseasPurchaseRequest,
} from '../types'
/**
* 海外区域 API 的前端边界。
*
* 当前仓库尚未冻结海外 API Base、Token 选择和端点路径,因此这里只保存
* 请求/响应形状,不创建 Axios 实例、不拼接临时 URL,也不提供模拟实现。
*/
export interface OverseasBusinessApi {
listPurchases(query: OverseasPurchaseQuery): Promise<OverseasBusinessPage<OverseasPurchaseOrder>>
getPurchase(purchaseId: string): Promise<OverseasPurchaseOrder>
updatePurchase(purchaseId: string, payload: UpdateOverseasPurchaseRequest): Promise<OverseasPurchaseOrder>
listTrucks(query: LocalTransportQuery): Promise<OverseasBusinessPage<LocalTransportOrder>>
getTruck(orderId: string): Promise<LocalTransportOrder>
createTruck(payload: SaveLocalTransportOrderRequest): Promise<LocalTransportOrder>
updateTruck(orderId: string, payload: SaveLocalTransportOrderRequest): Promise<LocalTransportOrder>
listLooseboards(query: LocalTransportQuery): Promise<OverseasBusinessPage<LocalLooseboardOrder>>
getLooseboard(orderId: string): Promise<LocalLooseboardOrder>
createLooseboard(payload: SaveLocalLooseboardOrderRequest): Promise<LocalLooseboardOrder>
updateLooseboard(orderId: string, payload: SaveLocalLooseboardOrderRequest): Promise<LocalLooseboardOrder>
}
@@ -0,0 +1,14 @@
<script setup lang="ts">
withDefaults(defineProps<{ domain: string; compact?: boolean }>(), { compact: false })
</script>
<template>
<el-alert class="contract-pending" type="warning" :closable="false" show-icon>
<template #title>{{ domain }}海外区域接口待接入</template>
<p v-if="!compact">页面已按四期原型冻结字段与承载方式;海外 API Base、Token 选择和正式端点补齐前,不展示演示数据,也不会模拟查询或写入成功。</p>
</el-alert>
</template>
<style scoped>
.contract-pending p { margin: 5px 0 0; color: var(--el-text-color-regular); font-size: 13px; line-height: 1.55; }
</style>
@@ -0,0 +1,53 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const read = (path: string) => readFileSync(resolve(process.cwd(), `src/modules/overseas-business/${path}`), 'utf8')
describe('overseas business pending contract pages', () => {
it('keeps operation columns first and renders no sample records', () => {
const purchase = read('pages/PurchaseOrdersView.vue')
const transport = read('pages/TransportOrdersView.vue')
const purchaseColumns = purchase.slice(purchase.indexOf('const columns'))
const transportColumns = transport.slice(transport.indexOf('const baseColumns'))
expect(purchaseColumns.indexOf("key: 'actions'")).toBeLessThan(purchaseColumns.indexOf("key: 'orderNo'"))
expect(transportColumns.indexOf("key: 'actions'")).toBeLessThan(transportColumns.indexOf("key: 'orderNo'"))
expect(purchase).toContain('const rows = ref<OverseasPurchaseOrder[]>([])')
expect(transport).toContain('const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([])')
expect(`${purchase}${transport}`).not.toMatch(/OPO\d|TK\d|LB\d/)
})
it('gives every formal list a stable query preference key without sharing transport preferences', () => {
const purchase = read('pages/PurchaseOrdersView.vue')
const transport = read('pages/TransportOrdersView.vue')
const purchaseToolbar = purchase.match(/<QueryToolbar[^>]*\/>/)?.[0]
const transportToolbar = transport.match(/<QueryToolbar[^>]*\/>/)?.[0]
expect(purchaseToolbar).toContain('preference-key="overseas-business-purchases-query"')
expect(transportToolbar).toContain(':preference-key="queryPreferenceKey"')
expect(transport).toContain("'overseas-business-truck-query'")
expect(transport).toContain("'overseas-business-looseboard-query'")
})
it('does not invent an overseas endpoint or success fallback', () => {
const apiContract = read('api/contracts.ts')
const allPages = ['PurchaseOrdersView.vue', 'PurchaseOrderDetailView.vue', 'PurchaseOrderEditView.vue', 'TransportOrdersView.vue', 'TransportOrderFormView.vue']
.map((file) => read(`pages/${file}`)).join('\n')
expect(apiContract).not.toContain("from '@/shared/api/http'")
expect(apiContract).not.toMatch(/\/platform\/v1|\/overseas\/v1|axios\.create/)
expect(allPages).not.toMatch(/ElMessage\.success|模拟成功|提交成功/)
expect(allPages).toContain('海外区域写端点待冻结')
expect(allPages).toContain('海外区域更新端点待冻结')
})
it('keeps prototype-backed forms as routes with validation and unsaved-work registration', () => {
const purchaseEdit = read('pages/PurchaseOrderEditView.vue')
const transportForm = read('pages/TransportOrderFormView.vue')
expect(purchaseEdit).toContain('useUnsavedWorkRegistration')
expect(purchaseEdit).toContain('出库量不能大于入库量')
expect(transportForm).toContain('useUnsavedWorkRegistration')
expect(transportForm).toContain("message: '请输入起运地'")
expect(transportForm).toContain("message: '请输入目的地'")
expect(transportForm).toContain('disabled><Save')
})
})
+3
View File
@@ -0,0 +1,3 @@
export { overseasBusinessPermissions } from './permissions'
export { overseasBusinessRoutes } from './routes'
export type * from './types'
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import type { InventoryTransaction } from '../types'
const route = useRoute()
const router = useRouter()
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
const fields = ['客户名称', '采购仓库', '采购品类', '商品名称', '商品规格', '商品链接']
const stockFields = ['入库量', '出库量', '库存量', '库存状态']
const orderFields = ['订单编号', '采购数量', '预算单价', '预算总价', '订单状态', '联系人', '联系电话', '收货地址', '订单备注']
const courierFields = ['国内承运商', '国内运单号', '海外承运商', '海外运单号', '预计到达时间']
const transactionColumns: DataTableColumn[] = [
{ key: 'occurredAt', prop: 'occurredAt', label: '时间', minWidth: 155 },
{ key: 'documentType', prop: 'documentType', label: '单据类型', minWidth: 120 },
{ key: 'documentNo', prop: 'documentNo', label: '关联单号', minWidth: 160 },
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库量', width: 95, align: 'right' },
{ key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库量', width: 95, align: 'right' },
{ key: 'balanceQuantity', prop: 'balanceQuantity', label: '结存量', width: 95, align: 'right' },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
]
const transactions: InventoryTransaction[] = []
</script>
<template>
<AdminPage><DetailPageLayout aria-label="海外采购订单详情">
<template #header><div class="detail-header"><div><h2>海外采购订单详情</h2><span>订单标识:{{ purchaseId || '—' }}</span></div><el-button @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回</el-button></div></template>
<ContractPendingNotice domain="海外采购订单详情" />
<div class="detail-surface surface-panel">
<div class="detail-grid">
<section><h3>商品信息</h3><dl><div v-for="label in fields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl></section>
<section><h3>库存信息</h3><dl><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl></section>
<section><h3>订单信息</h3><dl><div v-for="label in orderFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl></section>
<section><h3>快递信息</h3><dl><div v-for="label in courierFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl></section>
<section class="full"><h3>进销存明细</h3><DataTable :data="transactions" :columns="transactionColumns" row-key="id" empty-text="进销存明细接口待接入" /></section>
</div>
</div>
</DetailPageLayout></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 { 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-last-child(-n+2) { border-bottom:0; }.detail-grid h3 { margin:0 0 12px; font-size:14px; }.detail-grid .full { grid-column:1 / -1; padding-top:18px; }.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; color:var(--cc-text); font-size:13px; }
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Save } from '@lucide/vue'
import type { FormInstance, FormRules } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasPurchaseStatus, UpdateOverseasPurchaseRequest } from '../types'
interface PurchaseEditModel extends Omit<UpdateOverseasPurchaseRequest, 'expectedVersion'> { orderNo: string }
const route = useRoute()
const router = useRouter()
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
const formRef = ref<FormInstance>()
const form = reactive<PurchaseEditModel>({ orderNo: '', category: '', productName: '', specification: null, productUrl: null, inboundQuantity: 0, outboundQuantity: 0, status: 'PENDING_REVIEW', domesticCarrier: null, domesticTrackingNo: null, overseasCarrier: null, overseasTrackingNo: null, estimatedArrivalAt: null, remark: null })
const validateOutbound = (_rule: unknown, value: number, callback: (error?: Error) => void) => value > form.inboundQuantity ? callback(new Error('出库量不能大于入库量')) : callback()
const rules: FormRules<PurchaseEditModel> = {
category: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
productName: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
inboundQuantity: [{ required: true, type: 'number', min: 0, message: '入库量不能小于 0', trigger: 'change' }],
outboundQuantity: [{ required: true, type: 'number', min: 0, message: '出库量不能小于 0', trigger: 'change' }, { validator: validateOutbound, trigger: 'change' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
const statusOptions: Array<{ value: OverseasPurchaseStatus; label: string }> = [
{ value: 'PENDING_REVIEW', label: '待审核' }, { value: 'CONFIRMED', label: '已确认' }, { value: 'PURCHASING', label: '采购中' }, { value: 'COMPLETED', label: '已完成' },
]
useUnsavedWorkRegistration({ key: 'overseas-purchase-editor', label: computed(() => '编辑海外采购订单'), isOpen: computed(() => true), model: form, requiredPermission: computed(() => overseasBusinessPermissions.purchaseEdit) })
</script>
<template>
<AdminPage width="comfortable"><DetailPageLayout aria-label="编辑海外采购订单">
<template #header><div class="form-header"><div><h2>编辑海外采购订单</h2><span>订单标识:{{ purchaseId || '—' }}</span></div><el-button @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId } })"><ArrowLeft :size="16" />返回</el-button></div></template>
<ContractPendingNotice domain="海外采购订单编辑" />
<section class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<FormSection title="商品信息"><div class="form-grid three"><el-form-item label="订单编号"><el-input v-model="form.orderNo" disabled placeholder="接口接入后回填" /></el-form-item><el-form-item label="采购品类" prop="category"><el-input v-model="form.category" maxlength="100" /></el-form-item><el-form-item label="商品名称" prop="productName"><el-input v-model="form.productName" maxlength="160" /></el-form-item><el-form-item class="span-2" label="商品规格" prop="specification"><el-input v-model="form.specification" maxlength="255" /></el-form-item><el-form-item class="span-3" label="商品链接" prop="productUrl"><el-input v-model="form.productUrl" maxlength="1000" /></el-form-item></div></FormSection>
<FormSection title="库存与快递信息"><div class="form-grid three"><el-form-item label="入库量" prop="inboundQuantity"><el-input-number v-model="form.inboundQuantity" :min="0" controls-position="right" /></el-form-item><el-form-item label="出库量" prop="outboundQuantity"><el-input-number v-model="form.outboundQuantity" :min="0" controls-position="right" /></el-form-item><el-form-item label="状态" prop="status"><el-select v-model="form.status"><el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></el-form-item><el-form-item label="国内承运商"><el-input v-model="form.domesticCarrier" maxlength="120" /></el-form-item><el-form-item label="国内运单号"><el-input v-model="form.domesticTrackingNo" maxlength="120" /></el-form-item><el-form-item label="海外承运商"><el-input v-model="form.overseasCarrier" maxlength="120" /></el-form-item><el-form-item label="海外运单号"><el-input v-model="form.overseasTrackingNo" maxlength="120" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item class="span-3" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></div></FormSection>
</el-form><footer class="form-actions"><el-tooltip content="海外区域更新端点待冻结"><span><el-button type="primary" disabled><Save :size="16" />保存</el-button></span></el-tooltip><el-button @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId } })">取消</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; gap:0 20px; }.form-grid.three { grid-template-columns:repeat(3,minmax(0,1fr)); }.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-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
</style>
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Eye, Pencil } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import type { OverseasPurchaseOrder } from '../types'
const rows = ref<OverseasPurchaseOrder[]>([])
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ orderNo: '', category: '', productName: '' })
const fields: QueryField[] = [
{ key: 'orderNo', type: 'input', label: '订单编号', placeholder: '请输入订单编号', width: 210 },
{ key: 'category', type: 'input', label: '采购品类', placeholder: '请输入采购品类', width: 190 },
{ key: 'productName', type: 'input', label: '商品名称', placeholder: '请输入商品名称', width: 210 },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 180 },
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160 },
{ key: 'category', prop: 'category', label: '采购品类', minWidth: 130 },
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 160 },
{ key: 'specification', prop: 'specification', label: '商品规格', minWidth: 180 },
{ key: 'unitPrice', prop: 'unitPrice', label: '采购单价', width: 120, align: 'right' },
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库量', width: 95, align: 'right' },
{ key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库量', width: 95, align: 'right' },
{ key: 'stockQuantity', prop: 'stockQuantity', label: '库存量', width: 95, align: 'right' },
]
</script>
<template>
<AdminPage><ListPageLayout aria-label="海外采购订单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="海外采购订单筛选" preference-key="overseas-business-purchases-query" @search="page = 1" /></template>
<template #state><ContractPendingNotice domain="海外采购订单" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-business-purchases" row-key="id" empty-text="海外采购订单查询契约待接入">
<template #actions><div class="table-actions"><el-button size="small" type="primary" plain disabled><Eye :size="15" />详情</el-button><el-button size="small" disabled><Pencil :size="15" />编辑</el-button></div></template>
<template #pagination-left>共 0 条海外采购订单</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; }
</style>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Save } from '@lucide/vue'
import type { FormInstance, FormRules } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import { overseasBusinessPermissions } from '../permissions'
import type { LocalTransportStatus, SaveLocalLooseboardOrderRequest } from '../types'
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
const route = useRoute()
const router = useRouter()
const formRef = ref<FormInstance>()
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const editing = computed(() => Boolean(orderId.value))
const isLooseboard = computed(() => props.domain === 'looseboard')
const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车')
const listRouteName = computed(() => isLooseboard.value ? 'overseas-business-looseboards' : 'overseas-business-trucks')
const requiredPermission = computed(() => {
if (isLooseboard.value) return editing.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.looseboardCreate
return editing.value ? overseasBusinessPermissions.truckEdit : overseasBusinessPermissions.truckCreate
})
interface TransportFormModel extends SaveLocalLooseboardOrderRequest { status: LocalTransportStatus }
const form = reactive<TransportFormModel>({ transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactPhone: null, containerType: '20GP', pieces: 0, grossWeightKg: null, volumeCbm: null, budgetAmount: null, currency: 'CNY', requiredDate: null, status: 'PENDING_ACCEPTANCE' })
const rules: FormRules<TransportFormModel> = {
transportMode: [{ validator: (_rule, value, callback) => !isLooseboard.value || value ? callback() : callback(new Error('请选择运输方式')), trigger: 'change' }],
palletRequired: [{ validator: (_rule, value, callback) => !isLooseboard.value || value ? callback() : callback(new Error('请选择是否打盘')), trigger: 'change' }],
transportType: [{ required: true, message: '请选择运输类型', trigger: 'change' }],
origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }],
destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
pieces: [{ required: true, type: 'number', min: 0, message: '件数不能小于 0', trigger: 'change' }],
grossWeightKg: [{ pattern: /^\d+(\.\d{1,3})?$/, message: '总重量最多保留 3 位小数', trigger: 'blur' }],
volumeCbm: [{ pattern: /^\d+(\.\d{1,3})?$/, message: '总体积最多保留 3 位小数', trigger: 'blur' }],
budgetAmount: [{ pattern: /^\d+(\.\d{1,2})?$/, message: '预算总价最多保留 2 位小数', trigger: 'blur' }],
}
const transportTypes = [{ value: 'PORT_PICKUP', label: '⚓ 港口提柜' }, { value: 'WAREHOUSE_TO_WAREHOUSE', label: '🏭 仓到仓' }, { value: 'WAREHOUSE_TO_DOOR', label: '🚛 仓到门' }]
const transportModes = [{ value: 'SHARED_TRUCK', label: '🚐 拼车' }, { value: 'PRIORITY_CONSOLIDATION', label: '📦 优先拼载' }]
const statusOptions: Array<{ value: LocalTransportStatus; label: string }> = [{ value: 'PENDING_ACCEPTANCE', label: '待接单' }, { value: 'DISPATCHED', label: '已派车' }, { value: 'IN_TRANSIT', label: '运输中' }, { value: 'COMPLETED', label: '已完成' }]
useUnsavedWorkRegistration({ key: computed(() => `overseas-${props.domain}-editor`).value, label: computed(() => `${editing.value ? '编辑' : '新增'}${domainLabel.value}订单`), isOpen: computed(() => true), model: form, requiredPermission })
function back() { void router.push({ name: listRouteName.value }) }
</script>
<template>
<AdminPage width="comfortable"><DetailPageLayout :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
<template #header><div class="form-header"><div><h2>{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h2><span v-if="editing">订单标识:{{ orderId }}</span></div><el-button @click="back"><ArrowLeft :size="16" />返回列表</el-button></div></template>
<ContractPendingNotice :domain="`${domainLabel}订单${editing ? '编辑' : '新增'}`" />
<section class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<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="160" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="160" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="255" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="255" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="80" placeholder="联系人姓名" /></el-form-item><el-form-item label="联系方式"><el-input v-model="form.contactPhone" maxlength="40" placeholder="联系电话" /></el-form-item></div></FormSection>
<FormSection title="📦 货物信息"><div class="form-grid four"><el-form-item label="柜型"><el-select v-model="form.containerType"><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item><el-form-item label="件数" prop="pieces"><el-input-number v-model="form.pieces" :min="0" controls-position="right" /></el-form-item><el-form-item label="总重量 (kg)" prop="grossWeightKg"><el-input v-model="form.grossWeightKg" inputmode="decimal" /></el-form-item><el-form-item label="总体积 (m³)" prop="volumeCbm"><el-input v-model="form.volumeCbm" inputmode="decimal" /></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" /></el-form-item><el-form-item label="要求时间"><el-date-picker v-model="form.requiredDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item v-if="editing" label="状态"><el-select v-model="form.status"><el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></div></FormSection>
</el-form><footer class="form-actions"><el-button @click="back">取消</el-button><el-tooltip content="海外区域写端点待冻结"><span><el-button type="primary" disabled><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></span></el-tooltip></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:760px; 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 :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; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
</style>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Pencil, Plus } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import { overseasBusinessPermissions } from '../permissions'
import type { LocalLooseboardOrder, LocalTransportOrder, LocalTransportStatus } from '../types'
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
const router = useRouter()
const access = useAccessPreviewStore()
const isLooseboard = computed(() => props.domain === 'looseboard')
const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车')
const queryPreferenceKey = computed(() => isLooseboard.value
? 'overseas-business-looseboard-query'
: 'overseas-business-truck-query')
const createPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardCreate : overseasBusinessPermissions.truckCreate)
const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([])
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ status: '', routeKeyword: '' })
const statusOptions: Array<{ value: LocalTransportStatus; label: string }> = [
{ value: 'PENDING_ACCEPTANCE', label: '待接单' }, { value: 'DISPATCHED', label: '已派车' }, { value: 'IN_TRANSIT', label: '运输中' }, { value: 'COMPLETED', label: '已完成' },
]
const fields: QueryField[] = [
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: statusOptions },
{ key: 'routeKeyword', type: 'input', label: '运输路线', placeholder: '起运地/目的地', width: 240 },
]
const baseColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'transportType', prop: 'transportType', label: '运输类型', minWidth: 120 },
{ key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 },
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 },
{ key: 'contactPhone', prop: 'contactPhone', label: '联系方式', minWidth: 145 },
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 90 },
{ key: 'grossWeightKg', prop: 'grossWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
{ key: 'budgetAmount', prop: 'budgetAmount', label: '预算总价', width: 115, align: 'right' },
{ key: 'requiredDate', prop: 'requiredDate', label: '要求时间', width: 120 },
{ key: 'status', prop: 'status', label: '状态', width: 105 },
]
const columns = computed<DataTableColumn[]>(() => isLooseboard.value ? [
baseColumns[0]!, baseColumns[1]!,
{ key: 'transportMode', prop: 'transportMode', label: '运输方式', minWidth: 120 },
{ key: 'palletRequired', prop: 'palletRequired', label: '是否打盘', width: 100 },
...baseColumns.slice(2),
] : baseColumns)
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
</script>
<template>
<AdminPage><ListPageLayout :aria-label="`${domainLabel}订单列表`">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :ariaLabel="`${domainLabel}订单筛选`" :preference-key="queryPreferenceKey" @search="page = 1" /></template>
<template #state><ContractPendingNotice :domain="`${domainLabel}订单`" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" :preference-key="`overseas-business-${domain}`" row-key="id" :empty-text="`${domainLabel}订单查询契约待接入`">
<template #toolbar-actions><el-tooltip v-if="access.can(createPermission)" content="海外区域创建端点待冻结"><span><el-button type="primary" disabled @click="openCreate"><Plus :size="16" />新增订单</el-button></span></el-tooltip></template>
<template #actions><el-button size="small" disabled><Pencil :size="15" />编辑</el-button></template>
<template #pagination-left>共 0 条{{ domainLabel }}订单</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
<style scoped>
.table-panel { overflow:hidden; }
</style>
@@ -0,0 +1,10 @@
export const overseasBusinessPermissions = {
purchaseList: 'platform:overseas-business:purchase:list',
purchaseEdit: 'platform:overseas-business:purchase:edit',
truckList: 'platform:overseas-business:truck:list',
truckCreate: 'platform:overseas-business:truck:create',
truckEdit: 'platform:overseas-business:truck:edit',
looseboardList: 'platform:overseas-business:looseboard:list',
looseboardCreate: 'platform:overseas-business:looseboard:create',
looseboardEdit: 'platform:overseas-business:looseboard:edit',
} as const
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { overseasBusinessPermissions as permission } from './permissions'
import { overseasBusinessRoutes } from './routes'
describe('overseas business routes', () => {
it('keeps the frozen paths and permissions', () => {
expect(overseasBusinessRoutes.map((route) => [route.path, route.meta?.permission])).toEqual([
['overseas-business/purchases', permission.purchaseList],
['overseas-business/purchases/:purchaseId', permission.purchaseList],
['overseas-business/purchases/:purchaseId/edit', permission.purchaseEdit],
['overseas-business/trucks', permission.truckList],
['overseas-business/trucks/new', permission.truckCreate],
['overseas-business/trucks/:orderId/edit', permission.truckEdit],
['overseas-business/looseboards', permission.looseboardList],
['overseas-business/looseboards/new', permission.looseboardCreate],
['overseas-business/looseboards/:orderId/edit', permission.looseboardEdit],
])
})
it('does not invent excluded create or detail routes', () => {
const paths = overseasBusinessRoutes.map((route) => route.path)
expect(paths).not.toContain('overseas-business/purchases/new')
expect(paths).not.toContain('overseas-business/trucks/:orderId')
expect(paths).not.toContain('overseas-business/looseboards/:orderId')
expect(JSON.stringify(overseasBusinessRoutes)).not.toContain('permissionAnyOf')
})
})
+65
View File
@@ -0,0 +1,65 @@
import type { RouteRecordRaw } from 'vue-router'
import { overseasBusinessPermissions as permission } from './permissions'
export const overseasBusinessRoutes = [
{
path: 'overseas-business/purchases',
name: 'overseas-business-purchases',
component: () => import('./pages/PurchaseOrdersView.vue'),
meta: { title: '海外采购订单', description: '查询海外采购订单与库存变化', permission: permission.purchaseList },
},
{
path: 'overseas-business/purchases/:purchaseId',
name: 'overseas-business-purchase-detail',
component: () => import('./pages/PurchaseOrderDetailView.vue'),
meta: { title: '海外采购订单详情', description: '查看商品、库存、订单、快递与进销存明细', permission: permission.purchaseList, showPageHeading: false },
},
{
path: 'overseas-business/purchases/:purchaseId/edit',
name: 'overseas-business-purchase-edit',
component: () => import('./pages/PurchaseOrderEditView.vue'),
meta: { title: '编辑海外采购订单', description: '维护采购商品、库存和快递信息', permission: permission.purchaseEdit, showPageHeading: false },
},
{
path: 'overseas-business/trucks',
name: 'overseas-business-trucks',
component: () => import('./pages/TransportOrdersView.vue'),
props: { domain: 'truck' },
meta: { title: '本土卡车', description: '管理海外本土卡车订单', permission: permission.truckList },
},
{
path: 'overseas-business/trucks/new',
name: 'overseas-business-truck-new',
component: () => import('./pages/TransportOrderFormView.vue'),
props: { domain: 'truck' },
meta: { title: '新增本土卡车订单', description: '创建海外本土卡车订单', permission: permission.truckCreate, showPageHeading: false },
},
{
path: 'overseas-business/trucks/:orderId/edit',
name: 'overseas-business-truck-edit',
component: () => import('./pages/TransportOrderFormView.vue'),
props: { domain: 'truck' },
meta: { title: '编辑本土卡车订单', description: '维护海外本土卡车订单', permission: permission.truckEdit, showPageHeading: false },
},
{
path: 'overseas-business/looseboards',
name: 'overseas-business-looseboards',
component: () => import('./pages/TransportOrdersView.vue'),
props: { domain: 'looseboard' },
meta: { title: '本土散板', description: '管理海外本土散板订单', permission: permission.looseboardList },
},
{
path: 'overseas-business/looseboards/new',
name: 'overseas-business-looseboard-new',
component: () => import('./pages/TransportOrderFormView.vue'),
props: { domain: 'looseboard' },
meta: { title: '新增本土散板订单', description: '创建海外本土散板订单', permission: permission.looseboardCreate, showPageHeading: false },
},
{
path: 'overseas-business/looseboards/:orderId/edit',
name: 'overseas-business-looseboard-edit',
component: () => import('./pages/TransportOrderFormView.vue'),
props: { domain: 'looseboard' },
meta: { title: '编辑本土散板订单', description: '维护海外本土散板订单', permission: permission.looseboardEdit, showPageHeading: false },
},
] satisfies RouteRecordRaw[]
+137
View File
@@ -0,0 +1,137 @@
export interface OverseasBusinessPage<T> {
records: T[]
page: number
size: number
total: number
pages: number
}
export interface OverseasPurchaseQuery {
page?: number
size?: number
orderNo?: string
category?: string
productName?: string
}
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'CONFIRMED' | 'PURCHASING' | 'COMPLETED'
export interface InventoryTransaction {
id: string
occurredAt: string
documentType: string
documentNo: string
inboundQuantity: number
outboundQuantity: number
balanceQuantity: number
remark: string | null
}
export interface OverseasPurchaseOrder {
id: string
orderNo: string
customerId: string
customerName: string
purchaseWarehouseId: string
purchaseWarehouseName: string
category: string
productName: string
specification: string | null
productUrl: string | null
purchaseQuantity: number
unitPrice: string | null
currency: string
inboundQuantity: number
outboundQuantity: number
stockQuantity: number
receivingAddress: string | null
domesticCarrier: string | null
domesticTrackingNo: string | null
overseasCarrier: string | null
overseasTrackingNo: string | null
estimatedArrivalAt: string | null
contactName: string | null
contactPhone: string | null
status: OverseasPurchaseStatus
remark: string | null
inventoryTransactions: InventoryTransaction[]
version: number
}
export interface UpdateOverseasPurchaseRequest {
category: string
productName: string
specification: string | null
productUrl: string | null
inboundQuantity: number
outboundQuantity: number
status: OverseasPurchaseStatus
domesticCarrier: string | null
domesticTrackingNo: string | null
overseasCarrier: string | null
overseasTrackingNo: string | null
estimatedArrivalAt: string | null
remark: string | null
expectedVersion: number
}
export type LocalTransportType = 'PORT_PICKUP' | 'WAREHOUSE_TO_WAREHOUSE' | 'WAREHOUSE_TO_DOOR'
export type LocalTransportStatus = 'PENDING_ACCEPTANCE' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED'
export type LooseboardTransportMode = 'SHARED_TRUCK' | 'PRIORITY_CONSOLIDATION'
export type PalletRequired = 'YES' | 'NO'
export interface LocalTransportOrder {
id: string
orderNo: string
transportType: LocalTransportType
origin: string
destination: string
originAddress: string | null
destinationAddress: string | null
contactName: string | null
contactPhone: string | null
containerType: string | null
pieces: number
grossWeightKg: string | null
volumeCbm: string | null
budgetAmount: string | null
currency: string
requiredDate: string | null
status: LocalTransportStatus
version: number
}
export interface LocalLooseboardOrder extends LocalTransportOrder {
transportMode: LooseboardTransportMode
palletRequired: PalletRequired
}
export interface LocalTransportQuery {
page?: number
size?: number
status?: LocalTransportStatus | ''
routeKeyword?: string
}
export interface SaveLocalTransportOrderRequest {
transportType: LocalTransportType | ''
origin: string
destination: string
originAddress: string | null
destinationAddress: string | null
contactName: string | null
contactPhone: string | null
containerType: string | null
pieces: number
grossWeightKg: string | null
volumeCbm: string | null
budgetAmount: string | null
currency: string
requiredDate: string | null
expectedVersion?: number
}
export interface SaveLocalLooseboardOrderRequest extends SaveLocalTransportOrderRequest {
transportMode: LooseboardTransportMode | ''
palletRequired: PalletRequired | ''
}