优化
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# 海外通用资料接口缺口
|
||||
|
||||
当前仓库没有海外仓报价、汇率管理和车辆管理的已确认海外区域 API、请求/响应 DTO、错误码或写操作权限资源。
|
||||
|
||||
因此本模块当前只冻结正式路由、页面结构、字段、承载方式和只读权限,不包含内存示例数据或模拟成功:
|
||||
|
||||
- 海外仓报价:`platform:overseas-master-data:quote:list`
|
||||
- 汇率管理:`platform:overseas-master-data:exchange-rate:list`
|
||||
- 车辆列表/详情:`platform:overseas-master-data:vehicle:list`
|
||||
|
||||
新增、编辑、启停、删除、导出等写操作继续按动作使用独立权限,但编码与接口需等海外区域契约确认后再接入。列表权限只包含对应列表、详情和只读辅助数据访问。
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# 四期海外通用资料原型核对矩阵
|
||||
|
||||
事实来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/10-general.html`、`prototype-src/overlays.html`、`prototype-src/app.js`。
|
||||
|
||||
| 页面 | 操作 | 原型证据 | 原型承载方式 | 原型尺寸/布局 | 当前实现 | 结论 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 合作仓 `gen-partner-warehouse` | 列表/筛选 | `10-general.html:50-56` | 现有仓库管理页内分类页签 | 自有仓筛选、代码/地址、国家;操作列在表格末尾(与仓库稳定页的全局操作列第一约定冲突时服从全局约定) | 不新增重复页面 | 建议类型码 `OVERSEAS_PARTNER` 未生效,不得冒充 `OVERSEAS` |
|
||||
| 合作仓 | 新增/编辑 | `overlays.html:1731-1756`、`app.js:1655-1701` | 居中弹框 `modal-fba-add` | 最大约 560px;单列标签表单 | 现有仓库页暂不开放该类型 | 后端正式接受合作仓类型后复用现有仓库弹框和写权限 |
|
||||
| 海外仓报价 `gen-overseas-quote` | 列表/筛选 | `10-general.html:152-169`、`app.js:14007-14039` | 独立列表页 | 五项筛选;操作列固定在第一列;横向密集表格 | 正式模块列表页、结构化空态 | 保留原型字段;等待海外查询接口 |
|
||||
| 海外仓报价 | 详情 | `10-general.html:201-210`、`app.js:14043-14081` | 居中弹框 `modal-gen-overseas-quote-detail` | 约 980px;摘要、仓储作业费用、中转派送价格 | 写入口禁用,详情无记录时不生成示例对象 | 接口接入后使用居中弹框,不改抽屉或独立页 |
|
||||
| 海外仓报价 | 新增/编辑 | `10-general.html:171-199`、`app.js:14135-14202` | 居中弹框 `modal-gen-overseas-quote` | 最大约 1180px;基础信息、费用明细、派送价格;正文滚动 | 工具栏写按钮禁用 | 等写接口与动作权限确认后接入,不模拟保存成功 |
|
||||
| 海外仓报价 | 启停/删除/导出 | `10-general.html:154-169`、`app.js:14203-14218` | 列表行操作/工具栏 | 操作列第一列 | 无记录;写入口禁用 | 等独立动作权限与接口 |
|
||||
| 汇率管理 `gen-exchange-rate` | 列表/筛选 | `10-general.html:21-33`、`app.js:3373-3397` | 独立列表页 | 四项摘要;月份筛选;8 列表格 | 正式模块列表页、结构化空态 | 统一展示 `1 USD = X CNY`,等待查询接口 |
|
||||
| 汇率管理 | 更新/编辑 | `overlays.html:1648-1658`、`app.js:3399-3434` | 居中弹框 `modal-exchange-rate-edit` | 560px;月份、平均汇率、备注;已有月份覆盖提示 | 更新按钮禁用 | 等写接口与动作权限,不模拟覆盖成功 |
|
||||
| 车辆 `vehicle` | 列表/筛选 | `10-general.html:299-351`、`app.js:9719-9806`;当前对话于 2026-08-19 修正双页签问题,并指定复用统一订单列表 `UnifiedOrdersView.vue` 的 `status-tabs` 样式 | 独立列表页 | 原型同时使用国内/海外区域页签与自有/外派归属页签,形成重复主切换;原型操作列在末尾 | 正式模块将区域降为查询下拉框,仅保留自有/外派归属页签;归属页签直接沿用订单列表的查询面板、底部指示条和键盘切换结构;操作列按全局约定移到第一列 | 当前对话确认高于原型;保留会改变表格列结构的归属页签,区域作为普通筛选;等待查询接口 |
|
||||
| 车辆 | 详情 | `10-general.html:421-448`、`app.js:9871-9947` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 正式详情路由;无记录时显示契约待接入态 | 不改弹框或抽屉,不生成示例车辆 |
|
||||
| 车辆 | 新增/编辑 | `10-general.html:353-383`、`app.js:9808-9869` | 居中弹框 `modal-vehicle-edit` | 760px;两列表单,窄屏单列退化 | 写按钮禁用 | 等写接口与动作权限,不模拟保存成功 |
|
||||
| 车辆 | 删除/导出 | `10-general.html:302-351`、`app.js:9759-9806` | 列表行操作/工具栏 | 原型列表操作 | 无记录;写入口禁用 | 等独立动作权限与接口 |
|
||||
|
||||
原型未定义车辆文件预览、审核/确认流程;本模块不自行补充。
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ domain: string; detail?: boolean }>(), { detail: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-alert class="contract-pending" type="warning" :closable="false" show-icon>
|
||||
<template #title>{{ domain }}海外区域接口待接入</template>
|
||||
<p>{{ detail ? '当前详情没有可查询的真实记录。' : '页面已按四期原型冻结字段与布局。' }}在海外 API、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,3 @@
|
||||
export { overseasMasterDataRoutes } from './routes'
|
||||
export { overseasMasterDataPermissions } from './permissions'
|
||||
export type * from './types'
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { 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 ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import type { MonthlyExchangeRate } from '../types'
|
||||
|
||||
const rows = ref<MonthlyExchangeRate[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = ref<QueryModel>({ month: '' })
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'month', type: 'date', label: '查询月份', placeholder: '请选择月份', width: 200, props: { type: 'month', 'value-format': 'YYYY-MM' } },
|
||||
]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'month', prop: 'month', label: '汇率月份', minWidth: 120 },
|
||||
{ key: 'usdToCnyAverage', prop: 'usdToCnyAverage', label: '平均汇率', minWidth: 120 },
|
||||
{ key: 'conversion', label: '换算说明', minWidth: 190, slotName: 'conversion' },
|
||||
{ key: 'recordStatus', label: '状态', minWidth: 110 },
|
||||
{ key: 'updatedBy', prop: 'updatedBy', label: '更新人', minWidth: 120 },
|
||||
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150 },
|
||||
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 200 },
|
||||
]
|
||||
const summaries = [
|
||||
{ label: '最新维护汇率', value: '—' },
|
||||
{ label: '汇率月份', value: '—' },
|
||||
{ label: '最后更新人', value: '—' },
|
||||
{ label: '最后更新时间', value: '—' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<ListPageLayout aria-label="汇率管理">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" aria-label="汇率月份筛选" ariaLabel="汇率月份筛选" preference-key="overseas-exchange-rates-query" @search="page = 1" /></template>
|
||||
<template #metrics>
|
||||
<section class="rate-summary" aria-label="汇率摘要">
|
||||
<article v-for="item in summaries" :key="item.label" class="surface-panel rate-summary__item"><span>{{ item.label }}</span><strong>{{ item.value }}</strong></article>
|
||||
</section>
|
||||
</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-exchange-rates-table" row-key="month" empty-text="月度汇率查询契约待接入">
|
||||
<template #toolbar-start><div><h2>美元兑人民币月平均汇率</h2><p>统一口径:1 USD = X CNY</p></div></template>
|
||||
<template #toolbar-actions><el-tooltip content="海外区域写接口待接入"><span><el-button type="primary" disabled><Plus :size="16" />更新月度汇率</el-button></span></el-tooltip></template>
|
||||
<template #actions><span class="muted-value">—</span></template>
|
||||
<template #conversion>—</template>
|
||||
<template #pagination-left>共 0 条月度汇率</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rate-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||||
.rate-summary__item { position: relative; min-width: 0; padding: 16px 18px; overflow: hidden; }
|
||||
.rate-summary__item::before { position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--cc-primary); content: ''; }
|
||||
.rate-summary__item span { display: block; color: var(--cc-muted); font-size: 12px; }
|
||||
.rate-summary__item strong { display: block; margin-top: 8px; color: var(--cc-text); font-size: 22px; }
|
||||
.table-panel { min-width: 0; overflow: hidden; }
|
||||
.muted-value { color: var(--cc-muted); }
|
||||
@media (max-width: 720px) { .rate-summary { grid-template-columns: minmax(0, 1fr); } }
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function source(name: string) {
|
||||
return readFileSync(resolve(process.cwd(), `src/modules/overseas-master-data/pages/${name}.vue`), 'utf8')
|
||||
}
|
||||
|
||||
describe('overseas master data pending-contract pages', () => {
|
||||
it('gives every list query toolbar a unique stable preference key', () => {
|
||||
const expectedKeys = new Map([
|
||||
['OverseasQuotesView', 'overseas-quotes-query'],
|
||||
['ExchangeRatesView', 'overseas-exchange-rates-query'],
|
||||
['VehiclesView', 'overseas-vehicles-query'],
|
||||
])
|
||||
const actualKeys: string[] = []
|
||||
|
||||
for (const [name, expectedKey] of expectedKeys) {
|
||||
const view = source(name)
|
||||
const toolbar = view.match(/<QueryToolbar\b[^>]*\/>/s)?.[0]
|
||||
expect(toolbar, `${name} should render QueryToolbar`).toBeDefined()
|
||||
expect(toolbar).toContain(`preference-key="${expectedKey}"`)
|
||||
const actualKey = toolbar?.match(/preference-key="([^"]+)"/)?.[1]
|
||||
expect(actualKey).toBe(expectedKey)
|
||||
actualKeys.push(actualKey!)
|
||||
}
|
||||
|
||||
expect(new Set(actualKeys).size).toBe(actualKeys.length)
|
||||
})
|
||||
|
||||
it('keeps operation columns first and preserves the prototype fields', () => {
|
||||
const quotes = source('OverseasQuotesView')
|
||||
const exchangeRates = source('ExchangeRatesView')
|
||||
const vehicles = source('VehiclesView')
|
||||
expect(quotes.indexOf("key: 'actions'")).toBeLessThan(quotes.indexOf("key: 'name'"))
|
||||
for (const label of ['报价名称', '海外仓', '服务商', '币种', '生效日期', '失效日期', '报价版本', '状态', '更新时间', '备注']) {
|
||||
expect(quotes).toContain(`label: '${label}'`)
|
||||
}
|
||||
const exchangeColumns = exchangeRates.slice(exchangeRates.indexOf('const columns'))
|
||||
expect(exchangeColumns.indexOf("key: 'actions'")).toBeLessThan(exchangeColumns.indexOf("key: 'month'"))
|
||||
expect(vehicles.indexOf("key: 'actions'")).toBeLessThan(vehicles.indexOf("key: 'plateNumber'"))
|
||||
for (const label of ['自有车辆', '外派车辆', '当前派送单', '最近更换原因']) expect(vehicles).toContain(label)
|
||||
expect(vehicles).toContain("key: 'region'")
|
||||
expect(vehicles).toContain("placeholder: '全部区域'")
|
||||
expect(vehicles).toContain('class="status-tabs"')
|
||||
expect(vehicles).toContain("'status-tab', { 'is-active': ownership === tab.value }")
|
||||
expect(vehicles).not.toContain('<el-segmented')
|
||||
expect(vehicles).not.toContain('aria-label="车辆区域"')
|
||||
})
|
||||
|
||||
it('keeps all write and export entry points disabled with an explicit contract message', () => {
|
||||
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
|
||||
const view = source(name)
|
||||
expect(view).toContain('海外区域')
|
||||
expect(view).toMatch(/<el-button[^>]*disabled/)
|
||||
expect(view).not.toContain('ElMessage.success')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses an independent vehicle detail page without inventing a record', () => {
|
||||
const detail = source('VehicleDetailView')
|
||||
expect(detail).toContain('车辆信息')
|
||||
expect(detail).toContain('派送订单明细')
|
||||
expect(detail).toContain('派送更换记录')
|
||||
expect(detail).toContain('<ContractPendingNotice domain="车辆详情" detail />')
|
||||
expect(detail).not.toContain('AppDrawer')
|
||||
expect(detail).not.toContain('const vehicle =')
|
||||
})
|
||||
|
||||
it('records the prototype evidence before implementation', () => {
|
||||
const matrix = readFileSync(resolve(process.cwd(), 'src/modules/overseas-master-data/PROTOTYPE_MATRIX.md'), 'utf8')
|
||||
for (const id of ['gen-partner-warehouse', 'gen-overseas-quote', 'gen-exchange-rate', 'vehicle-detail']) expect(matrix).toContain(id)
|
||||
for (const modal of ['modal-gen-overseas-quote-detail', 'modal-gen-overseas-quote', 'modal-exchange-rate-edit', 'modal-vehicle-edit']) expect(matrix).toContain(modal)
|
||||
expect(matrix).toContain('原型未定义车辆文件预览、审核/确认流程')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Download, 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 ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import type { OverseasWarehouseQuote } from '../types'
|
||||
|
||||
const rows = ref<OverseasWarehouseQuote[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = ref<QueryModel>({ keyword: '', warehouseId: '', provider: '', currency: '', status: '' })
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'keyword', type: 'input', label: '报价名称 / 版本', placeholder: '报价名称 / 版本', width: 180 },
|
||||
{ key: 'warehouseId', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: [], width: 180 },
|
||||
{ key: 'provider', type: 'input', label: '服务商', placeholder: '服务商', width: 170 },
|
||||
{ key: 'currency', type: 'select', label: '币种', placeholder: '全部币种', options: [{ label: 'USD', value: 'USD' }, { label: 'CNY', value: 'CNY' }], width: 120 },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '启用', value: 'ACTIVE' }, { label: '停用', value: 'INACTIVE' }], width: 120 },
|
||||
]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 248, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'name', prop: 'name', label: '报价名称', minWidth: 190 },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '海外仓', minWidth: 170 },
|
||||
{ key: 'providerName', prop: 'providerName', label: '服务商', minWidth: 180 },
|
||||
{ key: 'currency', prop: 'currency', label: '币种', width: 74 },
|
||||
{ key: 'effectiveStart', prop: 'effectiveStart', label: '生效日期', width: 112 },
|
||||
{ key: 'effectiveEnd', prop: 'effectiveEnd', label: '失效日期', width: 112 },
|
||||
{ key: 'version', prop: 'version', label: '报价版本', width: 92 },
|
||||
{ key: 'status', prop: 'status', label: '状态', width: 76 },
|
||||
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 142 },
|
||||
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<ListPageLayout aria-label="海外仓报价列表">
|
||||
<template #toolbar>
|
||||
<QueryToolbar v-model="filters" class="surface-panel" :fields="fields" aria-label="海外仓报价筛选" ariaLabel="海外仓报价筛选" preference-key="overseas-quotes-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-quotes-table" row-key="id" empty-text="海外仓报价查询契约待接入">
|
||||
<template #toolbar-actions>
|
||||
<el-tooltip content="海外区域写接口待接入"><span><el-button type="primary" disabled><Plus :size="16" />新增报价</el-button></span></el-tooltip>
|
||||
<el-tooltip content="海外区域导出接口待接入"><span><el-button disabled><Download :size="16" />导出Excel</el-button></span></el-tooltip>
|
||||
</template>
|
||||
<template #actions><span class="muted-value">—</span></template>
|
||||
<template #pagination-left>共 0 条海外仓报价</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-panel { min-width: 0; overflow: hidden; }
|
||||
.muted-value { color: var(--cc-muted); }
|
||||
@media (max-width: 720px) { .table-panel :deep(.data-table__business-actions) { width: 100%; } }
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const deliveryColumns: DataTableColumn[] = [
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '派送单号', minWidth: 160 },
|
||||
{ key: 'sourceOrderNo', prop: 'sourceOrderNo', label: 'BOL / 来源单号', minWidth: 170 },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 160 },
|
||||
{ key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 140 },
|
||||
{ key: 'appointmentDate', prop: 'appointmentDate', label: '预约日期', minWidth: 130 },
|
||||
{ key: 'status', prop: 'status', label: '派送状态', minWidth: 110 },
|
||||
{ key: 'relation', prop: 'relation', label: '关联关系', minWidth: 120 },
|
||||
]
|
||||
const historyColumns: DataTableColumn[] = [
|
||||
{ key: 'occurredAt', prop: 'occurredAt', label: '时间', minWidth: 150 },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '派送单号', minWidth: 160 },
|
||||
{ key: 'action', prop: 'action', label: '动作', minWidth: 110 },
|
||||
{ key: 'relatedVehicle', prop: 'relatedVehicle', label: '关联车辆', minWidth: 140 },
|
||||
{ key: 'reason', prop: 'reason', label: '原因', minWidth: 220 },
|
||||
{ key: 'operatorName', prop: 'operatorName', label: '操作人', minWidth: 120 },
|
||||
]
|
||||
function backToList() {
|
||||
void router.push({ name: 'overseas-master-data-vehicles' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage width="comfortable">
|
||||
<DetailPageLayout aria-label="车辆详情">
|
||||
<template #header>
|
||||
<div class="detail-heading">
|
||||
<div><h1>车辆详情</h1><p>车辆 ID:{{ String(route.params.vehicleId || '—') }}</p></div>
|
||||
<el-button @click="backToList"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<ContractPendingNotice domain="车辆详情" detail />
|
||||
<section class="surface-panel detail-surface">
|
||||
<FormSection title="车辆信息"><div class="detail-empty">没有可展示的真实车辆档案</div></FormSection>
|
||||
<FormSection title="派送订单明细"><DataTable :data="[]" :columns="deliveryColumns" empty-text="车辆详情接口待接入" /></FormSection>
|
||||
<FormSection title="派送更换记录" :bordered="false"><DataTable :data="[]" :columns="historyColumns" empty-text="车辆详情接口待接入" /></FormSection>
|
||||
</section>
|
||||
</DetailPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.detail-heading h1 { margin: 0; color: var(--cc-text); font-size: 20px; }
|
||||
.detail-heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-surface { min-width: 0; overflow: hidden; }
|
||||
.detail-empty { padding: 18px; color: var(--cc-muted); text-align: center; }
|
||||
@media (max-width: 720px) { .detail-heading { align-items: stretch; flex-direction: column; } .detail-heading :deep(.el-button) { min-height: 44px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Download, 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 ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import type { VehicleOwnership, VehicleSummary } from '../types'
|
||||
|
||||
const rows = ref<VehicleSummary[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const ownership = ref<VehicleOwnership>('OWNED')
|
||||
const ownershipTabs: Array<{ label: string; value: VehicleOwnership }> = [
|
||||
{ label: '自有车辆', value: 'OWNED' },
|
||||
{ label: '外派车辆', value: 'DISPATCHED' },
|
||||
]
|
||||
const filters = ref<QueryModel>({ keyword: '', region: '', status: '' })
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'keyword', type: 'input', label: '车辆关键词', placeholder: '车牌 / 司机 / 车型', width: 220 },
|
||||
{ key: 'region', type: 'select', label: '区域', placeholder: '全部区域', width: 140, options: [
|
||||
{ label: '国内', value: 'DOMESTIC' },
|
||||
{ label: '海外', value: 'OVERSEAS' },
|
||||
] },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 140, options: [
|
||||
['在役', 'IN_SERVICE'], ['外派中', 'DISPATCHED'], ['维修中', 'MAINTENANCE'], ['停用', 'INACTIVE'], ['已归还', 'RETURNED'],
|
||||
].map(([label, value]) => ({ label, value })) },
|
||||
]
|
||||
const ownedColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 160, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'plateNumber', prop: 'plateNumber', label: '车牌', minWidth: 130 },
|
||||
{ key: 'model', prop: 'model', label: '车型', minWidth: 180 },
|
||||
{ key: 'fuelConsumptionPer100Km', prop: 'fuelConsumptionPer100Km', label: '油耗(L/100km)', minWidth: 130, align: 'right' },
|
||||
{ key: 'mileageKm', prop: 'mileageKm', label: '当前公里码(km)', minWidth: 140, align: 'right' },
|
||||
{ key: 'driverOrDispatchCompany', prop: 'driverOrDispatchCompany', label: '司机', minWidth: 130 },
|
||||
{ key: 'phone', prop: 'phone', label: '联系电话', minWidth: 145 },
|
||||
{ key: 'status', prop: 'status', label: '状态', width: 100 },
|
||||
{ key: 'region', prop: 'region', label: '区域', width: 100 },
|
||||
]
|
||||
const dispatchedColumns: DataTableColumn[] = [
|
||||
...ownedColumns.slice(0, 5),
|
||||
{ key: 'driverOrDispatchCompany', prop: 'driverOrDispatchCompany', label: '外派单位', minWidth: 160 },
|
||||
{ key: 'phone', prop: 'phone', label: '联系电话', minWidth: 145 },
|
||||
{ key: 'currentDeliveryOrderNo', prop: 'currentDeliveryOrderNo', label: '当前派送单', minWidth: 160 },
|
||||
{ key: 'latestReplacementReason', prop: 'latestReplacementReason', label: '最近更换原因', minWidth: 180 },
|
||||
{ key: 'status', prop: 'status', label: '状态', width: 100 },
|
||||
{ key: 'region', prop: 'region', label: '区域', width: 100 },
|
||||
]
|
||||
const columns = computed(() => ownership.value === 'OWNED' ? ownedColumns : dispatchedColumns)
|
||||
const regionLabel = computed(() => ({ DOMESTIC: '国内', OVERSEAS: '海外' }[String(filters.value.region)] ?? '全部区域'))
|
||||
|
||||
function selectOwnership(value: VehicleOwnership) {
|
||||
if (ownership.value === value) return
|
||||
ownership.value = value
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
function handleOwnershipTabKeydown(event: KeyboardEvent, index: number) {
|
||||
let nextIndex = index
|
||||
if (event.key === 'ArrowRight') nextIndex = (index + 1) % ownershipTabs.length
|
||||
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + ownershipTabs.length) % ownershipTabs.length
|
||||
else if (event.key === 'Home') nextIndex = 0
|
||||
else if (event.key === 'End') nextIndex = ownershipTabs.length - 1
|
||||
else return
|
||||
event.preventDefault()
|
||||
const tab = ownershipTabs[nextIndex]
|
||||
if (!tab) return
|
||||
selectOwnership(tab.value)
|
||||
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
buttons?.[nextIndex]?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<ListPageLayout aria-label="车辆列表">
|
||||
<template #toolbar>
|
||||
<div class="vehicles-query-panel">
|
||||
<div class="status-tabs" role="tablist" aria-label="车辆归属">
|
||||
<button
|
||||
v-for="(tab, index) in ownershipTabs"
|
||||
:key="tab.value"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="ownership === tab.value"
|
||||
aria-controls="vehicle-results"
|
||||
:tabindex="ownership === tab.value ? 0 : -1"
|
||||
:class="['status-tab', { 'is-active': ownership === tab.value }]"
|
||||
@click="selectOwnership(tab.value)"
|
||||
@keydown="handleOwnershipTabKeydown($event, index)"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
<QueryToolbar v-model="filters" class="vehicles-query-toolbar" :fields="fields" aria-label="车辆筛选" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" @search="page = 1" />
|
||||
</div>
|
||||
</template>
|
||||
<template #state><ContractPendingNotice domain="车辆管理" /></template>
|
||||
<section id="vehicle-results" class="surface-panel vehicle-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-vehicles-table" row-key="id" empty-text="车辆查询契约待接入">
|
||||
<template #toolbar-actions>
|
||||
<el-tooltip content="海外区域写接口待接入"><span><el-button type="primary" disabled><Plus :size="16" />添加车辆</el-button></span></el-tooltip>
|
||||
<el-tooltip content="海外区域导出接口待接入"><span><el-button disabled><Download :size="16" />导出Excel</el-button></span></el-tooltip>
|
||||
</template>
|
||||
<template #actions><span class="muted-value">—</span></template>
|
||||
<template #pagination-left>共 0 条{{ regionLabel }}{{ ownership === 'OWNED' ? '自有' : '外派' }}车辆</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vehicle-panel { min-width: 0; overflow: hidden; }
|
||||
.vehicles-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.status-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; background: #f9fafb; scrollbar-width: thin; }
|
||||
.status-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }
|
||||
.status-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }
|
||||
.status-tab:hover { color: var(--cc-primary); background: #f6f9fd; }
|
||||
.status-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
|
||||
.status-tab.is-active { color: var(--cc-primary); font-weight: 600; }
|
||||
.status-tab.is-active::after { background: var(--cc-primary); }
|
||||
.vehicles-query-toolbar { border: 0; }
|
||||
.muted-value { color: var(--cc-muted); }
|
||||
@media (max-width: 720px) { .status-tab { min-height: 46px; padding: 0 14px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
export const overseasMasterDataPermissions = {
|
||||
quoteList: 'platform:overseas-master-data:quote:list',
|
||||
exchangeRateList: 'platform:overseas-master-data:exchange-rate:list',
|
||||
vehicleList: 'platform:overseas-master-data:vehicle:list',
|
||||
} as const
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { overseasMasterDataPermissions } from './permissions'
|
||||
import { overseasMasterDataRoutes } from './routes'
|
||||
|
||||
describe('overseas master data routes', () => {
|
||||
it('uses the frozen stable paths and list permissions', () => {
|
||||
expect(overseasMasterDataRoutes.map((route) => [route.path, route.meta?.permission])).toEqual([
|
||||
['overseas-master-data/quotes', overseasMasterDataPermissions.quoteList],
|
||||
['overseas-master-data/exchange-rates', overseasMasterDataPermissions.exchangeRateList],
|
||||
['overseas-master-data/vehicles', overseasMasterDataPermissions.vehicleList],
|
||||
['overseas-master-data/vehicles/:vehicleId', overseasMasterDataPermissions.vehicleList],
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps vehicle detail as a route page and does not add a duplicate partner warehouse route', () => {
|
||||
expect(overseasMasterDataRoutes.at(-1)?.meta?.showPageHeading).toBe(false)
|
||||
expect(overseasMasterDataRoutes.some((route) => route.path.includes('partner-warehouse'))).toBe(false)
|
||||
})
|
||||
|
||||
it('documents but does not activate the suggested partner warehouse type', () => {
|
||||
const apiSource = readFileSync(resolve(process.cwd(), 'src/modules/warehouses/api/platformWarehouses.ts'), 'utf8')
|
||||
const gap = readFileSync(resolve(process.cwd(), 'src/modules/warehouses/PARTNER_WAREHOUSE_CONTRACT_GAP.md'), 'utf8')
|
||||
expect(gap).toContain('OVERSEAS_PARTNER')
|
||||
expect(apiSource).not.toContain("'OVERSEAS_PARTNER'")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { overseasMasterDataPermissions as permission } from './permissions'
|
||||
|
||||
export const overseasMasterDataRoutes = [
|
||||
{
|
||||
path: 'overseas-master-data/quotes',
|
||||
name: 'overseas-master-data-quotes',
|
||||
component: () => import('./pages/OverseasQuotesView.vue'),
|
||||
meta: { title: '海外仓报价', description: '维护海外合作仓仓储作业与中转派送报价', permission: permission.quoteList },
|
||||
},
|
||||
{
|
||||
path: 'overseas-master-data/exchange-rates',
|
||||
name: 'overseas-master-data-exchange-rates',
|
||||
component: () => import('./pages/ExchangeRatesView.vue'),
|
||||
meta: { title: '汇率管理', description: '维护美元兑人民币月平均汇率', permission: permission.exchangeRateList },
|
||||
},
|
||||
{
|
||||
path: 'overseas-master-data/vehicles',
|
||||
name: 'overseas-master-data-vehicles',
|
||||
component: () => import('./pages/VehiclesView.vue'),
|
||||
meta: { title: '车辆列表', description: '查询国内与海外自有、外派车辆档案', permission: permission.vehicleList },
|
||||
},
|
||||
{
|
||||
path: 'overseas-master-data/vehicles/:vehicleId',
|
||||
name: 'overseas-master-data-vehicle-detail',
|
||||
component: () => import('./pages/VehicleDetailView.vue'),
|
||||
meta: { title: '车辆详情', description: '查看车辆档案、派送订单与更换记录', permission: permission.vehicleList, showPageHeading: false },
|
||||
},
|
||||
] satisfies RouteRecordRaw[]
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
export type MasterDataStatus = 'ACTIVE' | 'INACTIVE'
|
||||
export type QuoteCurrency = 'USD' | 'CNY'
|
||||
export type VehicleRegion = 'DOMESTIC' | 'OVERSEAS'
|
||||
export type VehicleOwnership = 'OWNED' | 'DISPATCHED'
|
||||
export type VehicleStatus = 'IN_SERVICE' | 'DISPATCHED' | 'MAINTENANCE' | 'INACTIVE' | 'RETURNED'
|
||||
|
||||
export interface OverseasWarehouseFee {
|
||||
item: string
|
||||
billingUnit: string
|
||||
unitPrice: number
|
||||
minimumCharge: number | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface OverseasTransitPrice {
|
||||
warehouseId: string
|
||||
fbaWarehouseCode: string
|
||||
deliveryMethod: string
|
||||
estimatedMiles: number
|
||||
price: number
|
||||
minimumCharge: number | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface OverseasWarehouseQuote {
|
||||
id: string
|
||||
name: string
|
||||
warehouseId: string
|
||||
warehouseName: string
|
||||
providerName: string
|
||||
currency: QuoteCurrency
|
||||
effectiveStart: string
|
||||
effectiveEnd: string | null
|
||||
version: string
|
||||
status: MasterDataStatus
|
||||
updatedAt: string
|
||||
remark: string | null
|
||||
warehouseFees: OverseasWarehouseFee[]
|
||||
transitPrices: OverseasTransitPrice[]
|
||||
}
|
||||
|
||||
export interface MonthlyExchangeRate {
|
||||
month: string
|
||||
usdToCnyAverage: number
|
||||
updatedBy: string
|
||||
updatedAt: string
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface VehicleSummary {
|
||||
id: string
|
||||
plateNumber: string
|
||||
model: string
|
||||
fuelConsumptionPer100Km: number
|
||||
mileageKm: number
|
||||
driverOrDispatchCompany: string | null
|
||||
phone: string | null
|
||||
region: VehicleRegion
|
||||
ownership: VehicleOwnership
|
||||
status: VehicleStatus
|
||||
currentDeliveryOrderNo: string | null
|
||||
latestReplacementReason: string | null
|
||||
}
|
||||
|
||||
export interface VehicleDeliveryOrder {
|
||||
orderNo: string
|
||||
sourceOrderNo: string | null
|
||||
customerName: string
|
||||
destinationWarehouse: string
|
||||
appointmentDate: string | null
|
||||
status: string
|
||||
relation: string
|
||||
}
|
||||
|
||||
export interface VehicleReplacementHistory {
|
||||
occurredAt: string
|
||||
orderNo: string
|
||||
action: string
|
||||
relatedVehicle: string
|
||||
reason: string
|
||||
operatorName: string
|
||||
}
|
||||
|
||||
export interface VehicleDetail extends VehicleSummary {
|
||||
remark: string | null
|
||||
deliveryOrders: VehicleDeliveryOrder[]
|
||||
replacementHistory: VehicleReplacementHistory[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user