This commit is contained in:
2026-08-20 18:02:52 +08:00
parent 4ae5e7c672
commit 80947cd9ce
65 changed files with 1386 additions and 1083 deletions
@@ -1,44 +1,13 @@
# 海外通用资料接口缺口
# 海外通用资料真实接口与剩余缺口
本文件记录 Admin 海外通用资料与当前 Java 契约之间的实际差距。前端页面类型只是原型展示模型,不是已经发布的服务端 DTO。
三域统一经 `overseasHttp` 调用 `/api/platform/v1/last-leg/master-data`,长 ID 始终按字符串处理,分页使用 `records/page/size/total/pages`,更新与状态操作提交 `recordVersion`。
## 已确认的服务入口
- 报价:已接分页、详情、创建、编辑、启停、CSV 导出和 `/overseas-quotes/warehouse-options`。浏览器不调用国内 `/internal/warehouses/overseas-partners`。
- 汇率:已接摘要、分页、详情、创建、覆盖编辑、状态和 CSV 导出。
- 车辆:已接分页、独立详情、创建、编辑、状态和 CSV 导出。
- `gateway-overseas` 将 `/api/platform/v1/last-leg/**` 路由到 `lastleg-service`。
- `gateway-overseas` 将 `/api/platform/v1/warehouses/**`、`/api/platform/v1/warehouse-admin/**` 路由到 `warehouse-overseas-service`。
- 网关海外 Token Audience 为 `lastleg-api`。
- 两个海外服务没有提交对应业务的静态 OpenAPI YAML;运行时契约由 Springdoc 注解生成,网关暴露 `/lastleg-service/v3/api-docs/**` 和 `/warehouse-overseas-service/v3/api-docs/**`。
剩余后端契约缺口:
`warehouse-overseas-service` 已有实际海外仓聚合接口:
| 操作 | 端点 | 请求/响应 | 当前限制 |
|---|---|---|---|
| 仓库列表 | `GET /api/platform/v1/warehouse-admin` | 必填 `customerId`;`page`、`size`、`countryCode`、`capabilityCode`、`status`;返回 `PageResult<WarehouseEntity>` | 仅校验平台身份,没有与本模块冻结权限对应的细粒度权限;按客户隔离,不能直接当作平台全局报价仓候选 |
| 仓库详情 | `GET /api/platform/v1/warehouse-admin/{id}` | 必填 `customerId`;返回仓库、地址、能力聚合 | 需要先冻结报价/车辆记录与客户、海外仓的外键口径 |
| 仓库新增/编辑/启停 | `POST`、`PUT /{id}`、`PATCH /{id}/status` | `WarehouseRequests.SaveRequest`;状态仅 `ACTIVE/INACTIVE` | 属于实际海外仓资源,不是国内公共合作仓类型,也不是报价、汇率或车辆主数据接口 |
上述接口不在本模块接入:它们是按客户隔离的实际海外仓资源,不是平台公共合作仓,也不是海外报价、汇率或车辆主数据。业务页面不得为了补候选数据而跨区域拼接这两类仓库资源。
国内 Warehouse 已正式支持公共类型 `OVERSEAS_PARTNER`,Admin 在现有 `/api/platform/v1/warehouse-admin` 仓库页完成筛选、新增和编辑。国内服务另有受 `X-Internal-Service-Token` 保护的 `GET /internal/warehouses/overseas-partners`,仅供未来海外报价服务内部读取 `ACTIVE + OVERSEAS_PARTNER` 候选;前端不得调用该内部端点,报价页面也不跨区域拉取国内合作仓列表。
## 页面与操作缺口
| 页面/操作 | 端点 | DTO | 权限 | 状态机/规则 | 结论 |
|---|---|---|---|---|---|
| 海外仓报价列表、筛选、详情 | 缺失 | 缺少报价概要、仓储费用、中转派送价格及分页 DTO | 已冻结只读权限 `platform:overseas-master-data:quote:list`,服务端未实现 | 需定义生效区间、版本唯一性及当前有效版本规则 | 保持结构化空态 |
| 海外仓报价新增、编辑 | 缺失 | 缺少基础信息、费用明细、派送价格保存 DTO;海外仓必须使用真实 ID,不能只传名称 | create/edit 动作权限未冻结 | 需定义并发版本、日期重叠、费用组合去重与金额精度 | 写按钮禁用 |
| 海外仓报价启停、删除、导出 | 缺失 | 缺少状态命令、删除约束和导出任务 DTO | status/delete/export 动作权限未冻结 | 需定义 `ACTIVE/INACTIVE`、历史引用后的删除规则和导出方式 | 写按钮禁用 |
| 汇率摘要、列表、详情 | 缺失 | 缺少 `yyyy-MM`、USD→CNY 月平均值、更新人/时间及备注 DTO | 已冻结只读权限 `platform:overseas-master-data:exchange-rate:list`,服务端未实现 | 固定方向应为 `1 USD = X CNY`;需定义精度和月份时区 | 保持结构化空态 |
| 汇率新增、覆盖编辑 | 缺失 | 缺少月度汇率保存 DTO | edit 动作权限未冻结 | 需定义同月唯一、覆盖/乐观锁及下游引用后的修改规则 | 写按钮禁用 |
| 车辆列表、详情 | 缺失 | 缺少车辆档案、归属、区域、里程、司机/外派单位、派送订单与更换历史 DTO | 已冻结只读权限 `platform:overseas-master-data:vehicle:list`,服务端未实现 | 需定义车辆状态及其与派送订单的关联来源 | 保持结构化空态 |
| 车辆新增、编辑、删除、导出 | 缺失 | 缺少车辆保存、删除约束和导出 DTO | create/edit/delete/export 动作权限未冻结 | 需定义车牌唯一性、归属变化、里程校验和被引用车辆删除规则 | 写按钮禁用 |
`lastleg-service` 的 `GET /api/platform/v1/last-leg/us-truck-orders` 等接口管理的是本土卡车业务订单,写操作权限为 `lastleg:us-trucking:manage`,状态从 `PENDING_REVIEW` 经收货、分配、派车、运输推进至 `COMPLETED`。它不是车辆档案,不得用订单字段拼装车辆主数据或复用其权限。
## 前端权限边界
- 海外仓报价:`platform:overseas-master-data:quote:list`
- 汇率管理:`platform:overseas-master-data:exchange-rate:list`
- 车辆列表/详情:`platform:overseas-master-data:vehicle:list`
新增、编辑、启停、删除、导出等写操作继续按动作使用独立权限。列表权限只包含对应列表、详情和只读辅助数据访问。
- 报价和车辆 Controller/OpenAPI 没有删除端点,前端不展示删除操作。
- 车辆详情明确不包含派送履约与车辆更换历史;页面保留原型分区并标记当前接口边界,不拼装其他订单数据。
- 权限资源由总控统一写入 IAM/SQL;本模块只使用 canonical `platform:overseas-master-data:{quote|exchange-rate|vehicle}:{list|create|edit|status|export}`。
@@ -6,15 +6,15 @@
|---|---|---|---|---|---|---|
| 合作仓 `gen-partner-warehouse` | 列表/筛选 | `10-general.html:50-56` | 现有仓库管理页内分类 | 自有仓筛选、代码/地址、国家;操作列在表格末尾(与仓库稳定页的全局操作列第一约定冲突时服从全局约定) | 现有仓库页新增 `OVERSEAS_PARTNER` 类型筛选,不新增重复页面 | 使用国内公共仓库接口与 `platform:warehouse:list`;普通 `OVERSEAS` 语义不变 |
| 合作仓 | 新增/编辑 | `overlays.html:1731-1756`、`app.js:1655-1701` | 居中弹框 `modal-fba-add` | 最大约 560px;单列标签表单 | 复用现有仓库居中弹框,开放 `OVERSEAS_PARTNER` 新增和编辑 | 分别复用 `platform:warehouse:create`、`platform:warehouse:edit`,不跨区域查询报价候选 |
| 海外仓报价 `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;基础信息、费用明细、派送价格;正文滚动 | 工具栏写按钮禁用 | 等写接口与动作权限确认后接入,不模拟保存成功 |
| 海外仓报价 `gen-overseas-quote` | 列表/筛选 | `10-general.html:152-169`、`app.js:14007-14039` | 独立列表页 | 五项筛选;操作列固定在第一列;横向密集表格 | 已接海外分页与服务端合作仓候选 | 使用 quote:list,不跨区拼装候选 |
| 海外仓报价 | 详情 | `10-general.html:201-210`、`app.js:14043-14081` | 居中弹框 `modal-gen-overseas-quote-detail` | 约 980px;摘要、仓储作业费用、中转派送价格 | 已接真实详情弹框 | 使用 quote:list |
| 海外仓报价 | 新增/编辑 | `10-general.html:171-199`、`app.js:14135-14202` | 居中弹框 `modal-gen-overseas-quote` | 最大约 1180px;基础信息、费用明细、派送价格;正文滚动 | 已接真实创建/编辑与 recordVersion | quote:create/edit 独立校验 |
| 海外仓报价 | 启停/删除/导出 | `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;月份、平均汇率、备注;已有月份覆盖提示 | 更新按钮禁用 | 等写接口与动作权限,不模拟覆盖成功 |
| 汇率管理 `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;月份、平均汇率、备注;已有月份覆盖提示 | 已接创建/覆盖、状态与导出 | recordVersion 冲突保留服务端错误 |
| 车辆 `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:421-448`、`app.js:9871-9947` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 已接真实车辆详情;履约与更换区显示后端明确排除说明 | 不拼装订单/派送数据 |
| 车辆 | 新增/编辑 | `10-general.html:353-383`、`app.js:9808-9869` | 居中弹框 `modal-vehicle-edit` | 760px;两列表单,窄屏单列退化 | 已接创建/编辑、独立状态与导出 | create/edit/status 权限独立 |
| 车辆 | 删除/导出 | `10-general.html:302-351`、`app.js:9759-9806` | 列表行操作/工具栏 | 原型列表操作 | 无记录;写入口禁用 | 等独立动作权限与接口 |
原型未定义车辆文件预览、审核/确认流程;本模块不自行补充。
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import {
changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle,
exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getExchangeRateSummary, getQuote, getVehicle,
listExchangeRates, listQuotes, listQuoteWarehouseOptions, listVehicles, updateExchangeRate, updateQuote, updateVehicle,
} from './overseasMasterData'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() } }))
const ok = (data: unknown) => ({ code: '200', message: 'success', data })
beforeEach(() => vi.clearAllMocks())
describe('overseas master data API', () => {
it('uses the overseas quote contract including server-owned partner options', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ records: [], page: '1', size: '20', total: '0', pages: '0' }))
await listQuotes({ page: 1, size: 20, keyword: 'V1' })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/master-data/overseas-quotes', { params: { page: 1, size: 20, keyword: 'V1' } })
vi.mocked(overseasHttp.get).mockResolvedValue(ok([])); await listQuoteWarehouseOptions()
expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options')
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ summary: { id: '9223372036854775807' }, warehouseFees: [], transitPrices: [] })); await getQuote('9223372036854775807')
expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/overseas-quotes/9223372036854775807')
const payload = { name: '报价', warehouseId: '9', providerName: '服务商', currency: 'USD' as const, effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', versionCode: 'V1', warehouseFees: [], transitPrices: [], remark: null }
vi.mocked(overseasHttp.post).mockResolvedValue(ok({})); await createQuote(payload)
vi.mocked(overseasHttp.put).mockResolvedValue(ok({})); await updateQuote('9', { ...payload, recordVersion: 2 })
vi.mocked(overseasHttp.patch).mockResolvedValue(ok({})); await changeQuoteStatus('9', 'INACTIVE', 2)
expect(overseasHttp.patch).toHaveBeenCalledWith('/platform/v1/last-leg/master-data/overseas-quotes/9/status', { status: 'INACTIVE', recordVersion: 2 })
})
it('uses the monthly exchange-rate contract', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ records: [], page: 1, size: 20, total: 0, pages: 0 })); await listExchangeRates({ page: 1, size: 20, monthFrom: '2026-08', monthTo: '2026-08' })
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ latestMonth: '2026-08' })); await getExchangeRateSummary()
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ month: '2026-08' })); await getExchangeRate('2026-08')
vi.mocked(overseasHttp.post).mockResolvedValue(ok({})); await createExchangeRate({ month: '2026-08', usdToCnyAverage: 7.18, remark: null })
vi.mocked(overseasHttp.put).mockResolvedValue(ok({})); await updateExchangeRate('2026-08', { recordVersion: 1, usdToCnyAverage: 7.19, remark: null })
vi.mocked(overseasHttp.patch).mockResolvedValue(ok({})); await changeExchangeRateStatus('2026-08', 'INACTIVE', 1, '停用')
expect(overseasHttp.patch).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/exchange-rates/2026-08/status', { status: 'INACTIVE', recordVersion: 1, remark: '停用' })
})
it('uses the vehicle contract and preserves string IDs', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ records: [], page: 1, size: 20, total: 0, pages: 0 })); await listVehicles({ page: 1, size: 20, ownership: 'OWNED' })
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ id: '9223372036854775807' })); await getVehicle('9223372036854775807')
expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/vehicles/9223372036854775807')
const payload = { plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12, mileageKm: 1, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, remark: null, status: 'IN_SERVICE' as const }
vi.mocked(overseasHttp.post).mockResolvedValue(ok({})); await createVehicle(payload)
vi.mocked(overseasHttp.put).mockResolvedValue(ok({})); await updateVehicle('9', { ...payload, recordVersion: 1 })
vi.mocked(overseasHttp.patch).mockResolvedValue(ok({})); await changeVehicleStatus('9', 'INACTIVE', 1)
expect(overseasHttp.patch).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/vehicles/9/status', { status: 'INACTIVE', recordVersion: 1 })
})
it('downloads each CSV through overseasHttp', async () => {
const blob = new Blob(['csv']); vi.mocked(overseasHttp.get).mockResolvedValue(blob)
await exportQuotes({ status: 'ACTIVE' }); await exportExchangeRates({}); await exportVehicles({ ownership: 'OWNED' })
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/master-data/overseas-quotes/export', { params: { status: 'ACTIVE' }, responseType: 'blob' })
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/master-data/exchange-rates/export', { params: {}, responseType: 'blob' })
expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/vehicles/export', { params: { ownership: 'OWNED' }, responseType: 'blob' })
})
})
@@ -0,0 +1,42 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
MasterDataStatus, MonthlyExchangeRate, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption,
VehicleDetail, VehicleOwnership, VehicleRegion, VehicleStatus,
} from '../types'
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
type PagePayload<T> = Omit<PageResult<T>, 'page' | 'size' | 'total' | 'pages'> & { page: number | string; size: number | string; total: number | string; pages: number | string }
const base = '/platform/v1/last-leg/master-data'
function page<T>(value: PagePayload<T>): PageResult<T> { return { ...value, page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) } }
async function getPage<T>(path: string, params: object) { return page(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<PagePayload<T>>, ApiEnvelope<PagePayload<T>>>(path, { params }))) }
async function download(path: string, params: object) { return overseasHttp.get<Blob, Blob>(path, { params, responseType: 'blob' }) }
export interface QuoteQuery { page: number; size: number; keyword?: string; warehouseId?: string; providerName?: string; currency?: string; status?: MasterDataStatus }
export type QuoteSave = Pick<OverseasWarehouseQuote, 'name' | 'providerName' | 'currency' | 'effectiveStart' | 'effectiveEnd' | 'warehouseFees' | 'transitPrices' | 'remark'>
& { warehouseId?: string; versionCode?: string; recordVersion?: number }
export const listQuotes = (query: QuoteQuery) => getPage<OverseasWarehouseQuote>(`${base}/overseas-quotes`, query)
export async function getQuote(id: string) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasWarehouseQuoteDetail>, ApiEnvelope<OverseasWarehouseQuoteDetail>>(`${base}/overseas-quotes/${encodeURIComponent(id)}`)) }
export async function listQuoteWarehouseOptions() { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<PartnerWarehouseOption[]>, ApiEnvelope<PartnerWarehouseOption[]>>(`${base}/overseas-quotes/warehouse-options`)) }
export async function createQuote(payload: QuoteSave) { return unwrapApiResponse(await overseasHttp.post<ApiEnvelope<OverseasWarehouseQuoteDetail>, ApiEnvelope<OverseasWarehouseQuoteDetail>>(`${base}/overseas-quotes`, payload)) }
export async function updateQuote(id: string, payload: QuoteSave) { return unwrapApiResponse(await overseasHttp.put<ApiEnvelope<OverseasWarehouseQuoteDetail>, ApiEnvelope<OverseasWarehouseQuoteDetail>>(`${base}/overseas-quotes/${encodeURIComponent(id)}`, payload)) }
export async function changeQuoteStatus(id: string, status: MasterDataStatus, recordVersion: number) { return unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<OverseasWarehouseQuoteDetail>, ApiEnvelope<OverseasWarehouseQuoteDetail>>(`${base}/overseas-quotes/${encodeURIComponent(id)}/status`, { status, recordVersion })) }
export const exportQuotes = (query: Omit<QuoteQuery, 'page' | 'size'>) => download(`${base}/overseas-quotes/export`, query)
export interface ExchangeRateQuery { page: number; size: number; monthFrom?: string; monthTo?: string; status?: MasterDataStatus }
export const listExchangeRates = (query: ExchangeRateQuery) => getPage<MonthlyExchangeRate>(`${base}/exchange-rates`, query)
export async function getExchangeRate(month: string) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<MonthlyExchangeRate>, ApiEnvelope<MonthlyExchangeRate>>(`${base}/exchange-rates/${encodeURIComponent(month)}`)) }
export async function getExchangeRateSummary() { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<{ latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null }>, ApiEnvelope<{ latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null }>>(`${base}/exchange-rates/summary`)) }
export async function createExchangeRate(payload: { month: string; usdToCnyAverage: number; remark: string | null }) { return unwrapApiResponse(await overseasHttp.post<ApiEnvelope<MonthlyExchangeRate>, ApiEnvelope<MonthlyExchangeRate>>(`${base}/exchange-rates`, payload)) }
export async function updateExchangeRate(month: string, payload: { recordVersion: number; usdToCnyAverage: number; remark: string | null }) { return unwrapApiResponse(await overseasHttp.put<ApiEnvelope<MonthlyExchangeRate>, ApiEnvelope<MonthlyExchangeRate>>(`${base}/exchange-rates/${encodeURIComponent(month)}`, payload)) }
export async function changeExchangeRateStatus(month: string, status: MasterDataStatus, recordVersion: number, remark: string | null) { return unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<MonthlyExchangeRate>, ApiEnvelope<MonthlyExchangeRate>>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark })) }
export const exportExchangeRates = (query: Omit<ExchangeRateQuery, 'page' | 'size'>) => download(`${base}/exchange-rates/export`, query)
export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus }
export type VehicleSave = Omit<VehicleDetail, 'id' | 'recordVersion' | 'updatedBy' | 'updatedAt' | 'status'> & { status?: VehicleStatus; recordVersion?: number }
export const listVehicles = (query: VehicleQuery) => getPage<VehicleDetail>(`${base}/vehicles`, query)
export async function getVehicle(id: string) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<VehicleDetail>, ApiEnvelope<VehicleDetail>>(`${base}/vehicles/${encodeURIComponent(id)}`)) }
export async function createVehicle(payload: VehicleSave) { return unwrapApiResponse(await overseasHttp.post<ApiEnvelope<VehicleDetail>, ApiEnvelope<VehicleDetail>>(`${base}/vehicles`, payload)) }
export async function updateVehicle(id: string, payload: VehicleSave) { return unwrapApiResponse(await overseasHttp.put<ApiEnvelope<VehicleDetail>, ApiEnvelope<VehicleDetail>>(`${base}/vehicles/${encodeURIComponent(id)}`, payload)) }
export async function changeVehicleStatus(id: string, status: VehicleStatus, recordVersion: number) { return unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<VehicleDetail>, ApiEnvelope<VehicleDetail>>(`${base}/vehicles/${encodeURIComponent(id)}/status`, { status, recordVersion })) }
export const exportVehicles = (query: Omit<VehicleQuery, 'page' | 'size'>) => download(`${base}/vehicles/export`, query)
@@ -0,0 +1,7 @@
import { getApiErrorDetails } from '@/shared/api/http'
export function masterDataErrorMessage(error: unknown, fallback: string) {
const details = getApiErrorDetails(error, fallback)
const reference = details.requestId || details.traceId
return reference ? `${details.message}(请求标识:${reference})` : details.message
}
@@ -0,0 +1,6 @@
export function saveCsv(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
try { anchor.href = url; anchor.download = fileName; anchor.click() }
finally { URL.revokeObjectURL(url) }
}
@@ -1,68 +1,58 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Plus } from '@lucide/vue'
import { onMounted, reactive, ref, watch } from 'vue'
import { Download, Pencil, Plus, Power } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.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'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { changeExchangeRateStatus, createExchangeRate, exportExchangeRates, getExchangeRate, getExchangeRateSummary, listExchangeRates, updateExchangeRate } from '../api/overseasMasterData'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { saveCsv } from '../downloadCsv'
import { masterDataErrorMessage } from '../apiError'
import type { MasterDataStatus, MonthlyExchangeRate } from '../types'
const rows = ref<MonthlyExchangeRate[]>([])
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ month: '' })
const access = useAccessPreviewStore()
const rows = ref<MonthlyExchangeRate[]>([]), total = ref(0), page = ref(1), pageSize = ref(20), loading = ref(false), saving = ref(false), exporting = ref(false)
const error = ref<ApiErrorDetails | null>(null), editorOpen = ref(false), editingMonth = ref('')
const filters = ref<QueryModel>({ month: '', status: '' })
const fields: QueryField[] = [
{ key: 'month', type: 'date', label: '查询月份', placeholder: '请选择月份', width: 200, props: { type: 'month', 'value-format': 'YYYY-MM' } },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 130, options: [{ label: '启用', value: 'ACTIVE' }, { label: '停用', value: 'INACTIVE' }] },
]
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: '—' },
{ key: 'actions', label: '操作', width: 230, 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: 'status', label: '状态', width: 90, slotName: 'status' }, { key: 'updatedBy', prop: 'updatedBy', label: '更新人', minWidth: 120 },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 200 },
]
const summary = reactive({ latestMonth: '', latestUsdToCnyAverage: null as number | null, updatedBy: '', updatedAt: '' })
const form = reactive({ month: '', usdToCnyAverage: 0, remark: '', recordVersion: 0 })
function params() { const month = String(filters.value.month || ''); return { page: page.value, size: pageSize.value, monthFrom: month || undefined, monthTo: month || undefined, status: (filters.value.status || undefined) as MasterDataStatus | undefined } }
async function load() { loading.value = true; error.value = null; try { const [result, latest] = await Promise.all([listExchangeRates(params()), getExchangeRateSummary()]); rows.value = result.records; total.value = result.total; Object.assign(summary, latest) } catch (cause) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '月度汇率加载失败') } finally { loading.value = false } }
function search() { if (page.value === 1) void load(); else page.value = 1 }
function openCreate() { editingMonth.value = ''; Object.assign(form, { month: '', usdToCnyAverage: 0, remark: '', recordVersion: 0 }); editorOpen.value = true }
async function openEdit(row: MonthlyExchangeRate) { try { const value = await getExchangeRate(row.month); editingMonth.value = value.month; Object.assign(form, { month: value.month, usdToCnyAverage: Number(value.usdToCnyAverage), remark: value.remark || '', recordVersion: value.recordVersion }); editorOpen.value = true } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率详情加载失败')) } }
async function save() { if (!form.month || form.usdToCnyAverage <= 0) { ElMessage.warning('请选择月份并填写大于 0 的平均汇率'); return } saving.value = true; try { if (editingMonth.value) await updateExchangeRate(editingMonth.value, { recordVersion: form.recordVersion, usdToCnyAverage: form.usdToCnyAverage, remark: form.remark || null }); else await createExchangeRate({ month: form.month, usdToCnyAverage: form.usdToCnyAverage, remark: form.remark || null }); ElMessage.success(editingMonth.value ? '月度汇率已更新' : '月度汇率已创建'); editorOpen.value = false; await load() } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '月度汇率保存失败')) } finally { saving.value = false } }
async function toggle(row: MonthlyExchangeRate) { const next: MasterDataStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'; try { await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'} ${row.month} 汇率吗?`, '汇率状态', { type: 'warning' }); await changeExchangeRateStatus(row.month, next, row.recordVersion, null); ElMessage.success('汇率状态已更新'); await load() } catch (cause) { if (cause !== 'cancel' && cause !== 'close') ElMessage.error(masterDataErrorMessage(cause, '汇率状态更新失败')) } }
async function exportCsv() { exporting.value = true; try { const { page: _page, size: _size, ...query } = params(); saveCsv(await exportExchangeRates(query), 'monthly-exchange-rates.csv') } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率导出失败')) } finally { exporting.value = false } }
watch([page, pageSize], load); onMounted(load)
</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>
<template><AdminPage><ListPageLayout aria-label="汇率管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="汇率月份筛选" preference-key="overseas-exchange-rates-query" :loading="loading" @search="search" /></template>
<template #metrics><section class="rate-summary"><article v-for="item in [{label:'最新维护汇率',value:summary.latestUsdToCnyAverage??'—'},{label:'汇率月份',value:summary.latestMonth||'—'},{label:'最后更新人',value:summary.updatedBy||'—'},{label:'最后更新时间',value:summary.updatedAt||'—'}]" :key="item.label" class="surface-panel"><span>{{ item.label }}</span><strong>{{ item.value }}</strong></article></section></template>
<template #state><LoadErrorState v-if="error" title="月度汇率加载失败" :message="error.message" @retry="load" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="month" preference-key="overseas-exchange-rates-table" empty-text="暂无月度汇率">
<template #toolbar-start><div><h2>美元兑人民币月平均汇率</h2><p>统一口径:1 USD = X CNY</p></div></template>
<template #toolbar-actions><el-button v-if="access.can(permission.exchangeRateCreate)" type="primary" @click="openCreate"><Plus :size="16" />更新月度汇率</el-button><el-button v-if="access.can(permission.exchangeRateExport)" :loading="exporting" @click="exportCsv"><Download :size="16" />导出</el-button></template>
<template #actions="{ row }"><el-button v-if="access.can(permission.exchangeRateEdit)" link type="primary" @click="openEdit(row as MonthlyExchangeRate)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can(permission.exchangeRateStatus)" link :type="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggle(row as MonthlyExchangeRate)"><Power :size="15" />{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '停用' : '启用' }}</el-button></template>
<template #conversion="{ row }">1 USD = {{ (row as MonthlyExchangeRate).usdToCnyAverage }} CNY</template><template #status="{ row }"><el-tag :type="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'success' : 'info'">{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
</DataTable></section>
<AppDialog v-model="editorOpen" :title="editingMonth ? '编辑月度汇率' : '更新月度汇率'" width="min(560px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form label-position="top"><el-form-item label="汇率月份"><el-date-picker v-model="form.month" type="month" value-format="YYYY-MM" :disabled="!!editingMonth" /></el-form-item><el-form-item label="美元兑人民币平均汇率"><el-input-number v-model="form.usdToCnyAverage" :min="0.00000001" :precision="8" /><p>汇率方向:1 USD = X CNY</p></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" /></el-form-item></el-form></AppDialog>
</ListPageLayout></AdminPage></template>
<style scoped>.rate-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.rate-summary article{padding:16px 18px}.rate-summary span{display:block;color:var(--cc-muted);font-size:12px}.rate-summary strong{display:block;margin-top:8px;font-size:22px}.table-panel{min-width:0;overflow:hidden}</style>
@@ -0,0 +1,30 @@
import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ExchangeRatesView from './ExchangeRatesView.vue'
import OverseasQuotesView from './OverseasQuotesView.vue'
import VehicleDetailView from './VehicleDetailView.vue'
import VehiclesView from './VehiclesView.vue'
const push = vi.fn()
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { vehicleId: 'VH-TEST-001' } }), useRouter: () => ({ push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('../api/overseasMasterData', () => ({
listQuotes: vi.fn().mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }),
listQuoteWarehouseOptions: vi.fn().mockResolvedValue([]), getQuote: vi.fn(), createQuote: vi.fn(), updateQuote: vi.fn(), changeQuoteStatus: vi.fn(), exportQuotes: vi.fn(),
listExchangeRates: vi.fn().mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }),
getExchangeRateSummary: vi.fn().mockResolvedValue({ latestMonth: null, latestUsdToCnyAverage: null, updatedBy: null, updatedAt: null }),
getExchangeRate: vi.fn(), createExchangeRate: vi.fn(), updateExchangeRate: vi.fn(), changeExchangeRateStatus: vi.fn(), exportExchangeRates: vi.fn(),
listVehicles: vi.fn().mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }),
getVehicle: vi.fn().mockResolvedValue({ id: 'VH-TEST-001', plateNumber: 'CA-TEST', model: 'Truck', fuelConsumptionPer100Km: 12, mileageKm: 100, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: 'tester', updatedAt: '2026-08-20T10:00:00' }),
createVehicle: vi.fn(), updateVehicle: vi.fn(), changeVehicleStatus: vi.fn(), exportVehicles: vi.fn(),
}))
const slot = { template: '<div><slot/><slot name="header"/><slot name="toolbar"/><slot name="metrics"/><slot name="state"/><slot name="toolbar-start"/><slot name="toolbar-actions"/><slot name="actions"/></div>' }
const stubs = { AdminPage: slot, ListPageLayout: slot, DetailPageLayout: slot, QueryToolbar: true, DataTable: { template: '<div data-test="data-table"><slot name="toolbar-actions"/></div>' }, FormSection: slot, LoadErrorState: true, AppDialog: slot, ElTooltip: slot, ElButton: slot, ElTag: slot, ElTable: slot, ElTableColumn: true, ElForm: slot, ElFormItem: slot, ElInput: true, ElSelect: slot, ElOption: true, ElDatePicker: true, ElInputNumber: true }
function mountView(component: object): VueWrapper { return shallowMount(component, { global: { stubs, directives: { loading: () => undefined } } }) }
describe('overseas master data real page mounts', () => {
beforeEach(() => push.mockReset())
it.each([OverseasQuotesView, ExchangeRatesView, VehiclesView])('mounts a real API-backed list page', async (component) => { const wrapper = mountView(component); await flushPromises(); expect(wrapper.find('[data-test="data-table"]').exists()).toBe(true) })
it('mounts API-backed vehicle detail without fabricating fulfillment history', async () => { const wrapper = mountView(VehicleDetailView); await flushPromises(); expect(wrapper.text()).toContain('CA-TEST'); expect(wrapper.text()).toContain('当前车辆主数据接口明确不包含派送履约记录') })
})
@@ -6,7 +6,7 @@ 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', () => {
describe('overseas master data real contract pages', () => {
it('gives every list query toolbar a unique stable preference key', () => {
const expectedKeys = new Map([
['OverseasQuotesView', 'overseas-quotes-query'],
@@ -32,37 +32,38 @@ describe('overseas master data pending-contract pages', () => {
const quotes = source('OverseasQuotesView')
const exchangeRates = source('ExchangeRatesView')
const vehicles = source('VehiclesView')
expect(quotes.indexOf("key: 'actions'")).toBeLessThan(quotes.indexOf("key: 'name'"))
expect(quotes.indexOf("key:'actions'")).toBeLessThan(quotes.indexOf("key:'name'"))
for (const label of ['报价名称', '海外仓', '服务商', '币种', '生效日期', '失效日期', '报价版本', '状态', '更新时间', '备注']) {
expect(quotes).toContain(`label: '${label}'`)
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.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).toContain("'status-tab',{'is-active':ownership===tab[1]}")
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', () => {
it('uses real API operations and canonical action permissions', () => {
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')
expect(view).toContain("from '../api/overseasMasterData'")
expect(view).toContain('access.can(permission.')
expect(view).toContain('ElMessage.success')
}
})
it('uses an independent vehicle detail page without inventing a record', () => {
it('uses an independent API-backed vehicle detail page without inventing fulfillment history', () => {
const detail = source('VehicleDetailView')
expect(detail).toContain('车辆信息')
expect(detail).toContain('派送订单明细')
expect(detail).toContain('派送更换记录')
expect(detail).toContain('<ContractPendingNotice domain="车辆详情" detail />')
expect(detail).toContain('getVehicle(')
expect(detail).toContain('明确不包含派送履约记录')
expect(detail).not.toContain('AppDrawer')
expect(detail).not.toContain('const vehicle =')
})
@@ -74,13 +75,11 @@ describe('overseas master data pending-contract pages', () => {
expect(matrix).toContain('原型未定义车辆文件预览、审核/确认流程')
})
it('records audited service boundaries without treating truck orders as vehicle master data', () => {
it('records the real service boundary and remaining unsupported operations', () => {
const gaps = readFileSync(resolve(process.cwd(), 'src/modules/overseas-master-data/CONTRACT_GAPS.md'), 'utf8')
expect(gaps).toContain('/api/platform/v1/warehouse-admin')
expect(gaps).toContain('/api/platform/v1/last-leg/us-truck-orders')
expect(gaps).toContain('它不是车辆档案')
expect(gaps).toContain('/internal/warehouses/overseas-partners')
expect(gaps).toContain('前端不得调用该内部端点')
expect(gaps).toContain('保持结构化空态')
expect(gaps).toContain('/api/platform/v1/last-leg/master-data')
expect(gaps).toContain('/overseas-quotes/warehouse-options')
expect(gaps).toContain('浏览器不调用国内')
expect(gaps).toContain('没有删除端点')
})
})
@@ -1,62 +1,66 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Download, Plus } from '@lucide/vue'
import { onMounted, reactive, ref, watch } from 'vue'
import { Download, Eye, Pencil, Plus, Power } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.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'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { changeQuoteStatus, createQuote, exportQuotes, getQuote, listQuotes, listQuoteWarehouseOptions, updateQuote } from '../api/overseasMasterData'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { saveCsv } from '../downloadCsv'
import { masterDataErrorMessage } from '../apiError'
import type { MasterDataStatus, OverseasTransitPrice, OverseasWarehouseFee, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption } 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[] = [
const access = useAccessPreviewStore()
const rows = ref<OverseasWarehouseQuote[]>([]), total = ref(0), page = ref(1), pageSize = ref(20), loading = ref(false), saving = ref(false), exporting = ref(false)
const error = ref<ApiErrorDetails | null>(null), options = ref<PartnerWarehouseOption[]>([]), detail = ref<OverseasWarehouseQuoteDetail | null>(null)
const detailOpen = ref(false), editorOpen = ref(false), editingId = ref('')
const filters = ref<QueryModel>({ keyword: '', warehouseId: '', providerName: '', currency: '', status: '' })
const fields = ref<QueryField[]>([])
function syncFields() { fields.value = [
{ 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 },
]
{ key: 'warehouseId', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: options.value.map(v => ({ label: `${v.warehouseName} · ${v.warehouseCode}`, value: v.warehouseId })), width: 220 },
{ key: 'providerName', type: 'input', label: '服务商', placeholder: '服务商', width: 170 },
{ key: 'currency', type: 'select', label: '币种', placeholder: '全部币种', options: ['USD','CNY'].map(v => ({ label:v,value:v })), width: 120 },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{label:'启用',value:'ACTIVE'},{label:'停用',value:'INACTIVE'}], width: 120 },
] }
syncFields()
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 },
{ key:'actions',label:'操作',width:270,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:'versionCode',prop:'versionCode',label:'报价版本',width:92 },
{ key:'status',label:'状态',width:76,slotName:'status' }, { key:'updatedAt',prop:'updatedAt',label:'更新时间',minWidth:150 }, { key:'remark',prop:'remark',label:'备注',minWidth:180 },
]
const form = reactive({ name:'', warehouseId:'', providerName:'', currency:'USD', effectiveStart:'', effectiveEnd:'', versionCode:'V1.0', remark:'', recordVersion:0, warehouseFees:[] as OverseasWarehouseFee[], transitPrices:[] as OverseasTransitPrice[] })
function resetForm(){ Object.assign(form,{name:'',warehouseId:'',providerName:'',currency:'USD',effectiveStart:'',effectiveEnd:'',versionCode:'V1.0',remark:'',recordVersion:0,warehouseFees:[{item:'入库卸货',billingUnit:'托',unitPrice:0,minimumCharge:null,remark:null}],transitPrices:[]}) }
function params(){ return { page:page.value,size:pageSize.value,keyword:String(filters.value.keyword||'')||undefined,warehouseId:String(filters.value.warehouseId||'')||undefined,providerName:String(filters.value.providerName||'')||undefined,currency:String(filters.value.currency||'')||undefined,status:(filters.value.status||undefined) as MasterDataStatus|undefined } }
async function load(){ loading.value=true;error.value=null;try{const r=await listQuotes(params());rows.value=r.records;total.value=r.total}catch(e){rows.value=[];total.value=0;error.value=getApiErrorDetails(e,'海外仓报价加载失败')}finally{loading.value=false} }
async function loadOptions(){ try{options.value=await listQuoteWarehouseOptions();syncFields()}catch(e){ElMessage.error(masterDataErrorMessage(e,'合作仓候选加载失败'))} }
function search(){ if(page.value===1) void load(); else page.value=1 }
async function openDetail(row:OverseasWarehouseQuote){ try{detail.value=await getQuote(row.id);detailOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价详情加载失败'))} }
function fill(d:OverseasWarehouseQuoteDetail){const s=d.summary;Object.assign(form,{name:s.name,warehouseId:s.warehouseId,providerName:s.providerName,currency:s.currency,effectiveStart:s.effectiveStart,effectiveEnd:s.effectiveEnd,versionCode:s.versionCode,remark:s.remark||'',recordVersion:s.recordVersion,warehouseFees:d.warehouseFees.map(v=>({...v})),transitPrices:d.transitPrices.map(v=>({...v}))})}
async function openCreate(){editingId.value='';resetForm();if(!options.value.length)await loadOptions();editorOpen.value=true}
async function openEdit(row:OverseasWarehouseQuote){try{const d=await getQuote(row.id);editingId.value=row.id;fill(d);editorOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价详情加载失败'))}}
async function save(){if(!form.name||!form.warehouseId||!form.providerName||!form.effectiveStart||!form.effectiveEnd||!form.warehouseFees.length){ElMessage.warning('请完整填写报价必填信息');return} if(form.effectiveEnd<form.effectiveStart){ElMessage.warning('失效日期不能早于生效日期');return} saving.value=true;try{const common={name:form.name,providerName:form.providerName,currency:form.currency as 'USD'|'CNY',effectiveStart:form.effectiveStart,effectiveEnd:form.effectiveEnd,warehouseFees:form.warehouseFees,transitPrices:form.transitPrices,remark:form.remark||null};if(editingId.value)await updateQuote(editingId.value,{...common,recordVersion:form.recordVersion});else await createQuote({...common,warehouseId:form.warehouseId,versionCode:form.versionCode});ElMessage.success(editingId.value?'报价已更新':'报价已创建');editorOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价保存失败'))}finally{saving.value=false}}
async function toggle(row:OverseasWarehouseQuote){const next:MasterDataStatus=row.status==='ACTIVE'?'INACTIVE':'ACTIVE';try{await ElMessageBox.confirm(`确认${next==='ACTIVE'?'启用':'停用'}“${row.name}”吗?`,'报价状态',{type:'warning'});await changeQuoteStatus(row.id,next,row.recordVersion);ElMessage.success('报价状态已更新');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(masterDataErrorMessage(e,'报价状态更新失败'))}}
async function exportCsv(){exporting.value=true;try{const {page:_,size:__,...q}=params();saveCsv(await exportQuotes(q),'overseas-quotes.csv')}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价导出失败'))}finally{exporting.value=false}}
watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions()])})
</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>
<template><AdminPage><ListPageLayout aria-label="海外仓报价列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="海外仓报价筛选" preference-key="overseas-quotes-query" :loading="loading" @search="search" /></template>
<template #state><LoadErrorState v-if="error" title="海外仓报价加载失败" :message="error.message" @retry="load" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-quotes-table" row-key="id" empty-text="暂无海外仓报价">
<template #toolbar-actions><el-button v-if="access.can(permission.quoteCreate)" type="primary" @click="openCreate"><Plus :size="16"/>新增报价</el-button><el-button v-if="access.can(permission.quoteExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出Excel</el-button></template>
<template #actions="{row}"><el-button link type="primary" @click="openDetail(row as OverseasWarehouseQuote)"><Eye :size="15"/>详情</el-button><el-button v-if="access.can(permission.quoteEdit)" link type="primary" @click="openEdit(row as OverseasWarehouseQuote)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can(permission.quoteStatus)" link :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'danger':'success'" @click="toggle(row as OverseasWarehouseQuote)"><Power :size="15"/>{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'停用':'启用'}}</el-button></template>
<template #status="{row}"><el-tag :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'success':'info'">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'启用':'停用'}}</el-tag></template>
</DataTable></section>
<AppDialog v-model="detailOpen" title="海外仓报价详情" width="min(980px, calc(100vw - 28px))" :show-confirm="false"><div v-if="detail" class="detail-grid"><div v-for="item in [['报价名称',detail.summary.name],['海外仓',detail.summary.warehouseName],['服务商',detail.summary.providerName],['币种',detail.summary.currency],['生效日期',detail.summary.effectiveStart],['失效日期',detail.summary.effectiveEnd],['版本',detail.summary.versionCode],['状态',detail.summary.status]]" :key="item[0]"><span>{{item[0]}}</span><strong>{{item[1]}}</strong></div></div><h3>仓储作业费用</h3><el-table :data="detail?.warehouseFees||[]" border><el-table-column prop="item" label="费用项目"/><el-table-column prop="billingUnit" label="计费单位"/><el-table-column prop="unitPrice" label="单价"/><el-table-column prop="minimumCharge" label="最低收费"/><el-table-column prop="remark" label="备注"/></el-table><h3>中转派送价格</h3><el-table :data="detail?.transitPrices||[]" border><el-table-column prop="fbaWarehouseCode" label="目的FBA仓"/><el-table-column prop="deliveryMethod" label="车型/提货方式"/><el-table-column prop="estimatedMiles" label="预估迈数"/><el-table-column prop="price" label="报价"/><el-table-column prop="minimumCharge" label="最低收费"/></el-table></AppDialog>
<AppDialog v-model="editorOpen" :title="editingId?'编辑海外仓报价':'新增海外仓报价'" width="min(1180px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存报价" @confirm="save"><el-form label-position="top"><div class="form-grid"><el-form-item label="报价名称"><el-input v-model="form.name"/></el-form-item><el-form-item label="海外仓"><el-select v-model="form.warehouseId" :disabled="!!editingId"><el-option v-for="o in options" :key="o.warehouseId" :label="`${o.warehouseName} · ${o.warehouseCode}`" :value="o.warehouseId"/></el-select></el-form-item><el-form-item label="服务商"><el-input v-model="form.providerName"/></el-form-item><el-form-item label="币种"><el-select v-model="form.currency"><el-option label="USD" value="USD"/><el-option label="CNY" value="CNY"/></el-select></el-form-item><el-form-item label="生效日期"><el-date-picker v-model="form.effectiveStart" type="date" value-format="YYYY-MM-DD"/></el-form-item><el-form-item label="失效日期"><el-date-picker v-model="form.effectiveEnd" type="date" value-format="YYYY-MM-DD"/></el-form-item><el-form-item label="报价版本"><el-input v-model="form.versionCode" :disabled="!!editingId"/></el-form-item><el-form-item label="备注"><el-input v-model="form.remark"/></el-form-item></div><h3>仓储作业费用 <el-button @click="form.warehouseFees.push({item:'',billingUnit:'',unitPrice:0,minimumCharge:null,remark:null})">+ 添加费用</el-button></h3><div v-for="(f,i) in form.warehouseFees" :key="i" class="line-grid"><el-input v-model="f.item" placeholder="费用项目"/><el-input v-model="f.billingUnit" placeholder="计费单位"/><el-input-number v-model="f.unitPrice" :min="0"/><el-input-number v-model="f.minimumCharge" :min="0"/><el-button type="danger" plain @click="form.warehouseFees.splice(i,1)">删除</el-button></div><h3>中转派送价格 <el-button @click="form.transitPrices.push({fbaWarehouseCode:'',deliveryMethod:'',estimatedMiles:null,price:0,minimumCharge:null,remark:null})">+ 添加派送价格</el-button></h3><div v-for="(t,i) in form.transitPrices" :key="i" class="line-grid"><el-input v-model="t.fbaWarehouseCode" placeholder="FBA仓"/><el-input v-model="t.deliveryMethod" placeholder="车型/提货方式"/><el-input-number v-model="t.price" :min="0"/><el-input-number v-model="t.minimumCharge" :min="0"/><el-button type="danger" plain @click="form.transitPrices.splice(i,1)">删除</el-button></div></el-form></AppDialog>
</ListPageLayout></AdminPage></template>
<style scoped>.table-panel{min-width:0;overflow:hidden}.form-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px 16px}.line-grid{display:grid;grid-template-columns:2fr 1.2fr 1fr 1fr auto;gap:8px;margin:8px 0}.detail-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;background:var(--cc-border);border:1px solid var(--cc-border)}.detail-grid>div{padding:12px;background:#fff}.detail-grid span{display:block;color:var(--cc-muted);font-size:12px}.detail-grid strong{display:block;margin-top:4px}h3{margin:18px 0 10px;font-size:14px}</style>
@@ -1,60 +1,12 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
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' })
}
import AdminPage from '@/shared/components/AdminPage.vue'; import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'; import FormSection from '@/shared/components/FormSection.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails,type ApiErrorDetails } from '@/shared/api/http'; import { getVehicle } from '../api/overseasMasterData'; import type { VehicleDetail } from '../types'
const route=useRoute(),router=useRouter(),detail=ref<VehicleDetail|null>(null),loading=ref(false),error=ref<ApiErrorDetails|null>(null)
async function load(){loading.value=true;error.value=null;try{detail.value=await getVehicle(String(route.params.vehicleId||''))}catch(e){detail.value=null;error.value=getApiErrorDetails(e,'车辆详情加载失败')}finally{loading.value=false}}
function back(){void router.push({name:'overseas-master-data-vehicles'})} onMounted(load)
</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>
<template><AdminPage width="comfortable"><DetailPageLayout aria-label="车辆详情"><template #header><div class="heading"><div><h1>车辆详情</h1><p>{{detail?.plateNumber||String(route.params.vehicleId||'—')}} <el-tag v-if="detail">{{detail.status}}</el-tag></p></div><el-button @click="back"><ArrowLeft :size="16"/>返回列表</el-button></div></template><LoadErrorState v-if="error" title="车辆详情加载失败" :message="error.message" @retry="load"/><section v-loading="loading" class="surface-panel detail-surface"><FormSection title="车辆信息"><dl v-if="detail" class="detail-grid"><div v-for="item in [['车牌号',detail.plateNumber],['车型',detail.model],['油耗(L/100km)',detail.fuelConsumptionPer100Km],['当前公里码(km)',detail.mileageKm],['司机 / 外派单位',detail.driverOrDispatchCompany||'—'],['联系电话',detail.phone||'—'],['区域',detail.region==='OVERSEAS'?'海外':'国内'],['车辆归属',detail.ownership==='OWNED'?'自有车辆':'外派车辆'],['状态',detail.status],['更新时间',detail.updatedAt],['备注',detail.remark||'—']]" :key="item[0]"><dt>{{item[0]}}</dt><dd>{{item[1]}}</dd></div></dl></FormSection><FormSection title="派送订单明细"><p class="contract-note">当前车辆主数据接口明确不包含派送履约记录。</p></FormSection><FormSection title="派送更换记录" :bordered="false"><p class="contract-note">当前车辆主数据接口明确不包含车辆更换历史。</p></FormSection></section></DetailPageLayout></AdminPage></template>
<style scoped>.heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.heading h1{margin:0;font-size:20px}.heading p{margin:4px 0 0;color:var(--cc-muted)}.detail-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;margin:0;background:var(--cc-border);border:1px solid var(--cc-border)}.detail-grid>div{padding:12px;background:#fff}.detail-grid dt{color:var(--cc-muted);font-size:12px}.detail-grid dd{margin:5px 0 0;overflow-wrap:anywhere}.contract-note{margin:0;padding:16px;color:var(--cc-muted);text-align:center}</style>
@@ -1,125 +1,27 @@
<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()
}
import { onMounted, reactive, ref, watch } from 'vue'
import { Download, Eye, Pencil, Plus, Power } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'; import AppDialog from '@/shared/components/AppDialog.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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails,type ApiErrorDetails } from '@/shared/api/http'; import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { changeVehicleStatus,createVehicle,exportVehicles,getVehicle,listVehicles,updateVehicle } from '../api/overseasMasterData'; import { overseasMasterDataPermissions as permission } from '../permissions'; import { saveCsv } from '../downloadCsv'; import type { VehicleDetail,VehicleOwnership,VehicleRegion,VehicleStatus } from '../types'
import { masterDataErrorMessage } from '../apiError'
const router=useRouter(),access=useAccessPreviewStore(),rows=ref<VehicleDetail[]>([]),total=ref(0),page=ref(1),pageSize=ref(20),loading=ref(false),saving=ref(false),exporting=ref(false),error=ref<ApiErrorDetails|null>(null),editorOpen=ref(false),editingId=ref(''),statusOpen=ref(false),statusRow=ref<VehicleDetail|null>(null),statusTarget=ref<VehicleStatus>('IN_SERVICE')
const ownership=ref<VehicleOwnership>('OWNED'),tabs:[string,VehicleOwnership][]=[['自有车辆','OWNED'],['外派车辆','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 columns:DataTableColumn[]=[{key:'actions',label:'操作',width:250,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:160},{key:'phone',prop:'phone',label:'联系电话',minWidth:145},{key:'status',label:'状态',width:100,slotName:'status'},{key:'region',label:'区域',width:100,slotName:'region'}]
const form=reactive({plateNumber:'',model:'',fuelConsumptionPer100Km:0,mileageKm:0,driverOrDispatchCompany:'',phone:'',region:'OVERSEAS' as VehicleRegion,ownership:'OWNED' as VehicleOwnership,status:'IN_SERVICE' as VehicleStatus,remark:'',recordVersion:0})
function params(){return{page:page.value,size:pageSize.value,keyword:String(filters.value.keyword||'')||undefined,ownership:ownership.value,region:(filters.value.region||undefined) as VehicleRegion|undefined,status:(filters.value.status||undefined) as VehicleStatus|undefined}}
async function load(){loading.value=true;error.value=null;try{const r=await listVehicles(params());rows.value=r.records;total.value=r.total}catch(e){rows.value=[];total.value=0;error.value=getApiErrorDetails(e,'车辆加载失败')}finally{loading.value=false}}
function search(){if(page.value===1)void load();else page.value=1} function selectOwnership(v:VehicleOwnership){if(ownership.value===v)return;ownership.value=v;page.value=1;void load()}
function reset(){Object.assign(form,{plateNumber:'',model:'',fuelConsumptionPer100Km:0,mileageKm:0,driverOrDispatchCompany:'',phone:'',region:'OVERSEAS',ownership:ownership.value,status:'IN_SERVICE',remark:'',recordVersion:0})}
function openCreate(){editingId.value='';reset();editorOpen.value=true} async function openEdit(row:VehicleDetail){try{const d=await getVehicle(row.id);editingId.value=d.id;Object.assign(form,{...d,driverOrDispatchCompany:d.driverOrDispatchCompany||'',phone:d.phone||'',remark:d.remark||''});editorOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆详情加载失败'))}}
async function save(){if(!form.plateNumber||!form.model||form.fuelConsumptionPer100Km<=0){ElMessage.warning('请完整填写车辆必填信息');return}saving.value=true;try{const base={plateNumber:form.plateNumber,model:form.model,fuelConsumptionPer100Km:form.fuelConsumptionPer100Km,mileageKm:form.mileageKm,driverOrDispatchCompany:form.driverOrDispatchCompany||null,phone:form.phone||null,region:form.region,ownership:form.ownership,remark:form.remark||null};if(editingId.value)await updateVehicle(editingId.value,{...base,recordVersion:form.recordVersion});else await createVehicle({...base,status:form.status});ElMessage.success(editingId.value?'车辆已更新':'车辆已创建');editorOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆保存失败'))}finally{saving.value=false}}
function openStatus(row:VehicleDetail){statusRow.value=row;statusTarget.value=row.status;statusOpen.value=true} async function submitStatus(){const row=statusRow.value;if(!row)return;saving.value=true;try{await changeVehicleStatus(row.id,statusTarget.value,row.recordVersion);ElMessage.success('车辆状态已更新');statusOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆状态更新失败'))}finally{saving.value=false}}
async function exportCsv(){exporting.value=true;try{const{page:_,size:__,...q}=params();saveCsv(await exportVehicles(q),'vehicles.csv')}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆导出失败'))}finally{exporting.value=false}}
watch([page,pageSize],load);onMounted(load)
</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>
<template><AdminPage><ListPageLayout aria-label="车辆列表"><template #toolbar><div class="query-panel"><div class="status-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{'is-active':ownership===tab[1]}]" @click="selectOwnership(tab[1])">{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template><template #state><LoadErrorState v-if="error" title="车辆加载失败" :message="error.message" @retry="load"/></template><section class="surface-panel vehicle-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆"><template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出Excel</el-button></template><template #actions="{row}"><el-button link type="primary" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})"><Eye :size="15"/>查看</el-button><el-button v-if="access.can(permission.vehicleEdit)" link type="primary" @click="openEdit(row as VehicleDetail)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can(permission.vehicleStatus)" link type="primary" @click="openStatus(row as VehicleDetail)"><Power :size="15"/>状态</el-button></template><template #status="{row}"><el-tag>{{(row as VehicleDetail).status}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section>
<AppDialog v-model="editorOpen" :title="editingId?'编辑车辆':'添加车辆'" width="min(760px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form label-position="top"><div class="form-grid"><el-form-item label="车辆归属"><el-select v-model="form.ownership"><el-option label="自有车辆" value="OWNED"/><el-option label="外派车辆" value="DISPATCHED"/></el-select></el-form-item><el-form-item label="车牌号"><el-input v-model="form.plateNumber"/></el-form-item><el-form-item label="车型"><el-input v-model="form.model"/></el-form-item><el-form-item label="油耗(L/100km)"><el-input-number v-model="form.fuelConsumptionPer100Km" :min="0.01" :precision="2"/></el-form-item><el-form-item label="当前公里码(km)"><el-input-number v-model="form.mileageKm" :min="0"/></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone"/></el-form-item><el-form-item label="司机 / 外派单位"><el-input v-model="form.driverOrDispatchCompany"/></el-form-item><el-form-item label="区域"><el-select v-model="form.region"><el-option label="国内" value="DOMESTIC"/><el-option label="海外" value="OVERSEAS"/></el-select></el-form-item><el-form-item v-if="!editingId" label="状态"><el-select v-model="form.status"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="s" :value="s"/></el-select></el-form-item><el-form-item label="备注"><el-input v-model="form.remark"/></el-form-item></div></el-form></AppDialog><AppDialog v-model="statusOpen" title="变更车辆状态" width="min(480px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="submitStatus"><el-select v-model="statusTarget" style="width:100%"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="s" :value="s"/></el-select></AppDialog></ListPageLayout></AdminPage></template>
<style scoped>.vehicle-panel{min-width:0;overflow:hidden}.query-panel{overflow:hidden;border:1px solid var(--cc-border);border-radius:8px;background:#fff}.status-tabs{display:flex;border-bottom:1px solid var(--cc-border);background:#f9fafb}.status-tab{min-height:44px;padding:0 18px;border:0;background:transparent;color:var(--cc-muted)}.status-tab.is-active{border-bottom:3px solid var(--cc-primary);color:var(--cc-primary);font-weight:600}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 16px}</style>
@@ -1,6 +1,18 @@
export const overseasMasterDataPermissions = {
quoteList: 'platform:overseas-master-data:quote:list',
quoteCreate: 'platform:overseas-master-data:quote:create',
quoteEdit: 'platform:overseas-master-data:quote:edit',
quoteStatus: 'platform:overseas-master-data:quote:status',
quoteExport: 'platform:overseas-master-data:quote:export',
exchangeRateList: 'platform:overseas-master-data:exchange-rate:list',
exchangeRateCreate: 'platform:overseas-master-data:exchange-rate:create',
exchangeRateEdit: 'platform:overseas-master-data:exchange-rate:edit',
exchangeRateStatus: 'platform:overseas-master-data:exchange-rate:status',
exchangeRateExport: 'platform:overseas-master-data:exchange-rate:export',
vehicleList: 'platform:overseas-master-data:vehicle:list',
vehicleCreate: 'platform:overseas-master-data:vehicle:create',
vehicleEdit: 'platform:overseas-master-data:vehicle:edit',
vehicleStatus: 'platform:overseas-master-data:vehicle:status',
vehicleExport: 'platform:overseas-master-data:vehicle:export',
} as const
+18 -10
View File
@@ -20,10 +20,9 @@ export interface OverseasWarehouseFee {
}
export interface OverseasTransitPrice {
warehouseId: string
fbaWarehouseCode: string
deliveryMethod: string
estimatedMiles: number
estimatedMiles: number | null
price: number
minimumCharge: number | null
remark: string | null
@@ -34,21 +33,30 @@ export interface OverseasWarehouseQuote {
name: string
warehouseId: string
warehouseName: string
warehouseCode: string
countryCode: string
providerName: string
currency: QuoteCurrency
effectiveStart: string
effectiveEnd: string | null
version: string
versionCode: string
status: MasterDataStatus
updatedAt: string
updatedBy: string
recordVersion: number
remark: string | null
warehouseFees: OverseasWarehouseFee[]
transitPrices: OverseasTransitPrice[]
}
export interface OverseasWarehouseQuoteDetail { summary: OverseasWarehouseQuote; warehouseFees: OverseasWarehouseFee[]; transitPrices: OverseasTransitPrice[]; createdBy: string; createdAt: string }
export interface MonthlyExchangeRate {
month: string
baseCurrency: string
quoteCurrency: string
usdToCnyAverage: number
status: MasterDataStatus
recordVersion: number
updatedBy: string
updatedAt: string
remark: string | null
@@ -65,8 +73,10 @@ export interface VehicleSummary {
region: VehicleRegion
ownership: VehicleOwnership
status: VehicleStatus
currentDeliveryOrderNo: string | null
latestReplacementReason: string | null
remark: string | null
recordVersion: number
updatedBy: string
updatedAt: string
}
export interface VehicleDeliveryOrder {
@@ -88,8 +98,6 @@ export interface VehicleReplacementHistory {
operatorName: string
}
export interface VehicleDetail extends VehicleSummary {
remark: string | null
deliveryOrders: VehicleDeliveryOrder[]
replacementHistory: VehicleReplacementHistory[]
}
export type VehicleDetail = VehicleSummary
export interface PartnerWarehouseOption { warehouseId: string; warehouseCode: string; warehouseName: string; countryCode: string }