优化
This commit is contained in:
@@ -60,6 +60,8 @@ function loginResponse(accessToken = token(), refreshToken = 'refresh-next') {
|
||||
expiresIn: 1800,
|
||||
refreshToken,
|
||||
refreshExpiresIn: 604800,
|
||||
overseasAccessToken: 'overseas-token',
|
||||
overseasExpiresIn: 900,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -8,6 +8,7 @@ import {
|
||||
type ApiEnvelope,
|
||||
} from '@/shared/api/http'
|
||||
import type { PlatformPermissionResource } from '@/shared/types/platformPermission'
|
||||
import { clearOverseasAccessToken, replaceOverseasAccessToken } from '@/shared/api/overseasHttp'
|
||||
import { decodeJwtClaims } from './jwt'
|
||||
import { listCurrentPlatformPermissionCodes } from './platformAuthorization'
|
||||
import { coordinateCrossTabRefresh } from './crossTabRefresh'
|
||||
@@ -23,6 +24,8 @@ interface LoginResponse {
|
||||
expiresIn: number
|
||||
refreshToken: string
|
||||
refreshExpiresIn: number
|
||||
overseasAccessToken: string | null
|
||||
overseasExpiresIn: number
|
||||
}
|
||||
|
||||
class ExpiredAccessTokenError extends Error {}
|
||||
@@ -87,6 +90,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
async function applySession(result: LoginResponse, expectedGeneration: number) {
|
||||
const session = sessionFromToken(result.accessToken)
|
||||
ensureCurrentGeneration(expectedGeneration)
|
||||
replaceOverseasAccessToken(result.overseasAccessToken, result.overseasExpiresIn)
|
||||
token.value = result.accessToken
|
||||
refreshToken.value = result.refreshToken
|
||||
user.value = session.user
|
||||
@@ -106,6 +110,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
|
||||
function clearSession() {
|
||||
sessionGeneration += 1
|
||||
clearOverseasAccessToken()
|
||||
token.value = ''
|
||||
refreshToken.value = ''
|
||||
user.value = null
|
||||
@@ -133,6 +138,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
try {
|
||||
const session = sessionFromToken(storedToken)
|
||||
const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown
|
||||
if (token.value !== storedToken) clearOverseasAccessToken()
|
||||
token.value = storedToken
|
||||
refreshToken.value = storedRefreshToken
|
||||
user.value = session.user
|
||||
@@ -187,7 +193,11 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
const storedAccessToken = localStorage.getItem(TOKEN_KEY) || ''
|
||||
const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || ''
|
||||
if (!storedAccessToken || !storedRefreshToken) throw new Error('登录状态已过期')
|
||||
await applySession({ tokenType: 'Bearer', accessToken: storedAccessToken, expiresIn: 0, refreshToken: storedRefreshToken, refreshExpiresIn: 0 }, expectedGeneration)
|
||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||
'/auth/refresh',
|
||||
{ refreshToken: storedRefreshToken },
|
||||
)
|
||||
await applySession(unwrapApiResponse<LoginResponse>(response), expectedGeneration)
|
||||
},
|
||||
refresh: async (currentRefreshToken) => {
|
||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Admin Finance Module Instructions
|
||||
|
||||
## 范围与入口
|
||||
|
||||
- 本文件适用于 `src/modules/finance/`。产品范围和状态读取 `../../../../../docs/财务管理/` 的产品确认与验收契约,后端读取 `../../../../../baiyue-firstleg/finance/AGENTS.md`。
|
||||
- 页面从 `pages/` 进入,统一 API 从 `api/finance.ts` 进入;费用来源和柜次成本快照联查 `firstleg/`,凭证与异步导出联查 `file/`。
|
||||
- 正式范围包括客户账户、客户应收应付、应收款统计、客户对账单、应收结算单和应付结算单;不要从历史原型自动扩大财务功能。
|
||||
|
||||
## 客户筛选与统计
|
||||
|
||||
- 六个正式列表页统一加载全部 `ACTIVE` 客户并使用可筛选下拉框;用户可见选项只显示 `tenantName`,以 `customerId` 作为接口筛选值。
|
||||
- 不恢复自由输入客户关键词,不把客户编码、联系人或手机号拼入下拉标签;业务单号和费用项目继续使用独立筛选字段。
|
||||
- 应收款统计从 `pages/FinanceReceivableStatisticsView.vue`、`api/finance.ts` 进入,只汇总指定生成月份的 CNY 正式应收结算单。
|
||||
- 统计月份使用 `yyyy-MM`;当前页打印留在页面,Excel 通过 `FINANCE_RECEIVABLE_STATISTICS` 进入 File 下载中心异步导出。
|
||||
|
||||
## 权限与验证
|
||||
|
||||
- 列表和详情只读遵守 `*:list` 统一语义;确认、审核、导出、上传等写入或产出文件的动作继续使用独立权限。
|
||||
- 修改筛选、DTO 或请求参数时同步检查页面、API 类型、Mock/Fixture、测试、Finance OpenAPI 和实际消费者。
|
||||
- 至少运行 Finance 模块受影响测试和 `pnpm typecheck`;交付前按项目指引运行 `pnpm test` 与 `pnpm build`。
|
||||
@@ -1,17 +1,16 @@
|
||||
# 海外业务待补契约
|
||||
# 海外业务真实接口接入与剩余缺口
|
||||
|
||||
后端服务边界已经确认:浏览器统一经 `gateway-overseas/`(Audience=`lastleg-api`)访问 `lastleg-service/`;海外采购进入 `procurement/`,本土卡车进入 `trucking/`,本土散板进入该服务的独立稳定子域。现有平台采购与美国卡车 Controller 是接口适配事实来源,不得将这些请求接到国内 `gateway/`、`firstleg/` 或 `warehouse/`。
|
||||
模块通过 `@/shared/api/overseasHttp` 访问海外 Gateway,业务路径只传 `/platform/v1/...`。页面不读取 Token、不调用兑换端点,也不感知区域 Base。已接入:
|
||||
|
||||
模块当前仍只冻结 TypeScript 请求/响应模型和页面结构;在统一海外 Base/Token 选择与页面 DTO 适配完成前,不创建临时 URL,不使用示例数据,也不模拟提交成功。
|
||||
- 海外采购列表、详情及服务端允许字段的 `PATCH` 编辑;
|
||||
- 本土卡车列表及详情数据(详情只用于原型既有编辑页的数据承载,不新增详情路由);
|
||||
- 长整型 ID、金额/重量/体积的字符串归一化,真实状态映射,以及加载、失败、重试和空态。
|
||||
|
||||
后端接入前需冻结:
|
||||
仍缺少或明确不接入:
|
||||
|
||||
- 统一认证层对海外 Gateway Base 与短时海外 Token 的自动选择方式;
|
||||
- 将采购订单映射到 `lastleg-service` 的 `/api/platform/v1/last-leg/overseas-procurements`,将卡车订单映射到 `/api/platform/v1/last-leg/us-truck-orders`,并补齐当前 Admin 原型所需动作与 DTO;
|
||||
- 本土散板的正式平台端点及 DTO;
|
||||
- 分页信封、长整型 ID 字符串透传、金额/币种精度和时间格式;
|
||||
- 状态机、允许编辑状态、乐观锁版本、幂等键及错误码;
|
||||
- 客户、采购仓库等只读候选接口与权限;
|
||||
- 对应 `list/create/edit` 权限资源在海外服务端的鉴权落点。
|
||||
- 海外采购:后端详情只有出库记录,没有原型“进销存明细”所需的完整入库/余额流水;客户与采购仓库候选接口尚未冻结。平台 `review` 与 `outbounds` 不在本专项接入范围。
|
||||
- 本土卡车:平台端只有列表、详情、状态流转和收托盘,没有平台新增或普通编辑接口;新增/编辑提交继续明确阻断,不借用 Merchant、状态流转或收托盘端点。
|
||||
- 本土散板:`lastleg-service` 尚无独立稳定 Controller/DTO/OpenAPI,列表、新增、编辑均保持缺口,不借用卡车端点。
|
||||
- 权限:前端冻结为 `platform:overseas-business:*`,当前海外服务端采购和卡车管理权限分别为 `lastleg:overseas-procurement:manage`、`lastleg:us-trucking:manage`。冲突只记录,页面不绕过;由共享权限总控统一收口。
|
||||
|
||||
接入时应在统一双区域 HTTP/认证层完成 Base 与 Token 选择,业务页面不得自行读写 Token、创建第二套登录或绕回国内 Gateway。
|
||||
任何后续写接口都应继续以海外服务 Controller、DTO、OpenAPI 和状态机为准,不发明路径、不模拟成功、不连接国内 Gateway。
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import type {
|
||||
LocalLooseboardOrder,
|
||||
LocalTransportOrder,
|
||||
LocalTransportQuery,
|
||||
OverseasBusinessPage,
|
||||
OverseasPurchaseOrder,
|
||||
OverseasPurchaseQuery,
|
||||
SaveLocalLooseboardOrderRequest,
|
||||
SaveLocalTransportOrderRequest,
|
||||
UpdateOverseasPurchaseRequest,
|
||||
} from '../types'
|
||||
|
||||
/**
|
||||
* 海外区域 API 的前端边界。
|
||||
*
|
||||
* 服务边界固定为 gateway-overseas -> lastleg-service。当前统一海外 Base/Token
|
||||
* 选择以及部分页面端点/DTO 尚未完成适配,因此这里只保存请求/响应形状,
|
||||
* 不创建模块级 Axios 实例、不拼接临时 URL,也不提供模拟实现。
|
||||
*/
|
||||
export interface OverseasBusinessApi {
|
||||
listPurchases(query: OverseasPurchaseQuery): Promise<OverseasBusinessPage<OverseasPurchaseOrder>>
|
||||
getPurchase(purchaseId: string): Promise<OverseasPurchaseOrder>
|
||||
updatePurchase(purchaseId: string, payload: UpdateOverseasPurchaseRequest): Promise<OverseasPurchaseOrder>
|
||||
listTrucks(query: LocalTransportQuery): Promise<OverseasBusinessPage<LocalTransportOrder>>
|
||||
getTruck(orderId: string): Promise<LocalTransportOrder>
|
||||
createTruck(payload: SaveLocalTransportOrderRequest): Promise<LocalTransportOrder>
|
||||
updateTruck(orderId: string, payload: SaveLocalTransportOrderRequest): Promise<LocalTransportOrder>
|
||||
listLooseboards(query: LocalTransportQuery): Promise<OverseasBusinessPage<LocalLooseboardOrder>>
|
||||
getLooseboard(orderId: string): Promise<LocalLooseboardOrder>
|
||||
createLooseboard(payload: SaveLocalLooseboardOrderRequest): Promise<LocalLooseboardOrder>
|
||||
updateLooseboard(orderId: string, payload: SaveLocalLooseboardOrderRequest): Promise<LocalLooseboardOrder>
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const overseas = vi.hoisted(() => ({ get: vi.fn(), patch: vi.fn() }))
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: overseas }))
|
||||
|
||||
import { getOverseasPurchase, getUsTruckOrder, listOverseasPurchases, listUsTruckOrders, updateOverseasPurchase } from './platformOverseasBusiness'
|
||||
|
||||
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
|
||||
const purchaseSummary = {
|
||||
id: '9007199254740993', orderNo: 'OP-1', customerId: 'C-1', customerName: '客户一', purchaseCategory: '服装', productName: '外套',
|
||||
productSpecification: null, purchaseQuantity: 10, budgetUnitPrice: 12.34, currency: 'USD', purchaseWarehouseId: '9007199254740995',
|
||||
purchaseWarehouseName: '采购仓', inboundQuantity: 5, outboundQuantity: 2, inventoryQuantity: 3, status: 'APPROVED', version: 4,
|
||||
createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
|
||||
}
|
||||
const truckSummary = {
|
||||
id: '9007199254740997', customerId: 'C-2', orderNo: 'TR-1', transportType: 'FTL', origin: 'LA', destination: 'ONT',
|
||||
containerType: '40HQ', palletCount: 2, packageCount: 10, receivedPalletCount: 1, allocatedPalletCount: 0, shippedPalletCount: 0,
|
||||
totalWeightKg: 123.45, totalVolumeCbm: 6.7, budgetAmount: 88.5, currency: 'USD', contactName: 'A', contactInfo: '1',
|
||||
requestedDate: '2026-08-20', status: 'WAITING_INBOUND', version: 2, createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
|
||||
}
|
||||
|
||||
describe('platform overseas business API adapter', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('uses the overseas client and normalizes procurement pages', async () => {
|
||||
overseas.get.mockResolvedValue(envelope({ records: [purchaseSummary], page: 1, size: 20, total: 1, pages: 1 }))
|
||||
const result = await listOverseasPurchases({ page: 1, size: 20, orderNo: ' OP-1 ' })
|
||||
expect(overseas.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements', expect.objectContaining({ params: expect.objectContaining({ orderNo: 'OP-1' }) }))
|
||||
expect(result.records[0]).toMatchObject({ id: '9007199254740993', purchaseWarehouseId: '9007199254740995', budgetUnitPrice: '12.34' })
|
||||
})
|
||||
|
||||
it('loads and patches procurement details with customer isolation', async () => {
|
||||
const detail = { summary: purchaseSummary, productUrl: null, outbounds: [{ id: '9007199254740999', outboundQuantity: 2 }], histories: [] }
|
||||
overseas.get.mockResolvedValue(envelope(detail))
|
||||
expect((await getOverseasPurchase('OP/1', 'C-1')).outbounds[0]?.id).toBe('9007199254740999')
|
||||
expect(overseas.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1', { params: { customerId: 'C-1' } })
|
||||
|
||||
overseas.patch.mockResolvedValue(envelope(detail))
|
||||
const payload = { version: 4, productName: '新商品' }
|
||||
await updateOverseasPurchase('OP-1', 'C-1', payload)
|
||||
expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP-1', payload, { params: { customerId: 'C-1' } })
|
||||
})
|
||||
|
||||
it('normalizes truck list/detail numeric fields without exposing write methods', async () => {
|
||||
overseas.get.mockResolvedValueOnce(envelope({ records: [truckSummary], page: '1', size: '20', total: '1', pages: '1' }))
|
||||
const page = await listUsTruckOrders({ routeKeyword: ' LA ' })
|
||||
expect(overseas.get).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders', expect.objectContaining({ params: expect.objectContaining({ routeKeyword: 'LA' }) }))
|
||||
expect(page.records[0]).toMatchObject({ id: '9007199254740997', totalWeightKg: '123.45', totalVolumeCbm: '6.7', budgetAmount: '88.5' })
|
||||
|
||||
overseas.get.mockResolvedValueOnce(envelope({ summary: truckSummary, pallets: [{ id: '9007199254741001', warehousePalletId: '9007199254741003', palletNo: 'P-1' }], histories: [] }))
|
||||
const detail = await getUsTruckOrder('TR-1', 'C-2')
|
||||
expect(detail.pallets[0]).toMatchObject({ id: '9007199254741001', warehousePalletId: '9007199254741003' })
|
||||
expect(overseas.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/us-truck-orders/TR-1', { params: { customerId: 'C-2' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { LocalTransportDetail, LocalTransportOrder, LocalTransportPallet, LocalTransportQuery, OverseasBusinessHistory, OverseasBusinessPage, OverseasProcurementOutbound, OverseasPurchaseDetail, OverseasPurchaseQuery, OverseasPurchaseSummary, UpdateOverseasPurchaseRequest } from '../types'
|
||||
|
||||
const procurementBasePath = '/platform/v1/last-leg/overseas-procurements'
|
||||
const truckBasePath = '/platform/v1/last-leg/us-truck-orders'
|
||||
type Numeric = number | string
|
||||
interface PagePayload<T> { records?: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
|
||||
interface ProcurementSummaryPayload extends Omit<OverseasPurchaseSummary, 'id' | 'purchaseWarehouseId' | 'budgetUnitPrice'> { id: Numeric; purchaseWarehouseId?: Numeric | null; budgetUnitPrice?: Numeric | null }
|
||||
interface ProcurementOutboundPayload extends Omit<OverseasProcurementOutbound, 'id'> { id: Numeric }
|
||||
interface ProcurementDetailPayload {
|
||||
summary: ProcurementSummaryPayload; productUrl?: string | null; receivingAddress?: string | null
|
||||
contactName?: string | null; contactPhone?: string | null; domesticCarrier?: string | null; domesticTrackingNo?: string | null
|
||||
overseasCarrier?: string | null; overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null
|
||||
createdBy?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; reviewedAt?: string | null
|
||||
outbounds?: ProcurementOutboundPayload[]; histories?: OverseasBusinessHistory[]
|
||||
}
|
||||
interface TruckSummaryPayload extends Omit<LocalTransportOrder, 'id' | 'budgetAmount' | 'totalWeightKg' | 'totalVolumeCbm'> { id: Numeric; budgetAmount?: Numeric | null; totalWeightKg: Numeric; totalVolumeCbm: Numeric }
|
||||
interface TruckPalletPayload extends Omit<LocalTransportPallet, 'id' | 'warehousePalletId'> { id: Numeric; warehousePalletId?: Numeric | null }
|
||||
interface TruckDetailPayload {
|
||||
summary: TruckSummaryPayload; originAddress?: string | null; destinationAddress?: string | null
|
||||
remark?: string | null; createdBy?: string | null; pallets?: TruckPalletPayload[]; histories?: OverseasBusinessHistory[]
|
||||
}
|
||||
|
||||
function normalizePage<TPayload, TResult>(payload: PagePayload<TPayload>, normalize: (value: TPayload) => TResult): OverseasBusinessPage<TResult> {
|
||||
return { records: (payload.records || []).map(normalize), page: Number(payload.page), size: Number(payload.size), total: Number(payload.total), pages: Number(payload.pages) }
|
||||
}
|
||||
function procurementSummary(value: ProcurementSummaryPayload): OverseasPurchaseSummary {
|
||||
return { ...value, id: String(value.id), purchaseWarehouseId: value.purchaseWarehouseId == null ? null : String(value.purchaseWarehouseId), budgetUnitPrice: value.budgetUnitPrice == null ? null : String(value.budgetUnitPrice) }
|
||||
}
|
||||
function procurementDetail(value: ProcurementDetailPayload): OverseasPurchaseDetail {
|
||||
return { ...procurementSummary(value.summary), productUrl: value.productUrl || null, receivingAddress: value.receivingAddress || null, contactName: value.contactName || null, contactPhone: value.contactPhone || null, domesticCarrier: value.domesticCarrier || null, domesticTrackingNo: value.domesticTrackingNo || null, overseasCarrier: value.overseasCarrier || null, overseasTrackingNo: value.overseasTrackingNo || null, estimatedArrivalAt: value.estimatedArrivalAt || null, remark: value.remark || null, createdBy: value.createdBy || null, reviewedBy: value.reviewedBy || null, reviewRemark: value.reviewRemark || null, reviewedAt: value.reviewedAt || null, outbounds: (value.outbounds || []).map((item) => ({ ...item, id: String(item.id) })), histories: value.histories || [] }
|
||||
}
|
||||
function truckSummary(value: TruckSummaryPayload): LocalTransportOrder {
|
||||
return { ...value, id: String(value.id), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) }
|
||||
}
|
||||
function truckDetail(value: TruckDetailPayload): LocalTransportDetail {
|
||||
return { ...truckSummary(value.summary), originAddress: value.originAddress || null, destinationAddress: value.destinationAddress || null, remark: value.remark || null, createdBy: value.createdBy || null, pallets: (value.pallets || []).map((item) => ({ ...item, id: String(item.id), warehousePalletId: item.warehousePalletId == null ? null : String(item.warehousePalletId) })), histories: value.histories || [] }
|
||||
}
|
||||
|
||||
export async function listOverseasPurchases(query: OverseasPurchaseQuery = {}) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<PagePayload<ProcurementSummaryPayload>>, ApiEnvelope<PagePayload<ProcurementSummaryPayload>>>(procurementBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, customerKeyword: query.customerKeyword?.trim() || undefined, purchaseCategory: query.purchaseCategory?.trim() || undefined, productName: query.productName?.trim() || undefined, status: query.status || undefined, purchaseWarehouseId: query.purchaseWarehouseId || undefined } })
|
||||
return normalizePage(unwrapApiResponse(response), procurementSummary)
|
||||
}
|
||||
export async function getOverseasPurchase(orderNo: string, customerId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } })
|
||||
return procurementDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function updateOverseasPurchase(orderNo: string, customerId: string, payload: UpdateOverseasPurchaseRequest) {
|
||||
const response = await overseasHttp.patch<ApiEnvelope<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } })
|
||||
return procurementDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function listUsTruckOrders(query: LocalTransportQuery = {}) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<PagePayload<TruckSummaryPayload>>, ApiEnvelope<PagePayload<TruckSummaryPayload>>>(truckBasePath, { params: { ...query, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, transportType: query.transportType || undefined, customerId: query.customerId || undefined } })
|
||||
return normalizePage(unwrapApiResponse(response), truckSummary)
|
||||
}
|
||||
export async function getUsTruckOrder(orderNo: string, customerId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<TruckDetailPayload>, ApiEnvelope<TruckDetailPayload>>(`${truckBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } })
|
||||
return truckDetail(unwrapApiResponse(response))
|
||||
}
|
||||
@@ -4,8 +4,8 @@ withDefaults(defineProps<{ domain: string; compact?: boolean }>(), { compact: fa
|
||||
|
||||
<template>
|
||||
<el-alert class="contract-pending" type="warning" :closable="false" show-icon>
|
||||
<template #title>{{ domain }}海外区域接口待接入</template>
|
||||
<p v-if="!compact">后端边界已固定为 gateway-overseas → lastleg-service;统一海外 Base/Token 选择及本页面端点、DTO 与权限适配完成前,不展示演示数据,也不会模拟查询或写入成功。</p>
|
||||
<template #title>{{ domain }}接口能力待后端补齐</template>
|
||||
<p v-if="!compact">页面请求边界固定为 gateway-overseas → lastleg-service。当前没有可用于该操作的平台端点或稳定业务子域,页面保持阻断,不会借用 Merchant、状态推进或其他相邻接口模拟成功。</p>
|
||||
</el-alert>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -4,50 +4,63 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
const read = (path: string) => readFileSync(resolve(process.cwd(), `src/modules/overseas-business/${path}`), 'utf8')
|
||||
|
||||
describe('overseas business pending contract pages', () => {
|
||||
describe('overseas business real contract pages', () => {
|
||||
it('keeps operation columns first and renders no sample records', () => {
|
||||
const purchase = read('pages/PurchaseOrdersView.vue')
|
||||
const transport = read('pages/TransportOrdersView.vue')
|
||||
const purchaseColumns = purchase.slice(purchase.indexOf('const columns'))
|
||||
const transportColumns = transport.slice(transport.indexOf('const baseColumns'))
|
||||
const truckColumns = transport.slice(transport.indexOf('const truckColumns'))
|
||||
const looseboardColumns = transport.slice(transport.indexOf('const looseboardColumns'))
|
||||
expect(purchaseColumns.indexOf("key: 'actions'")).toBeLessThan(purchaseColumns.indexOf("key: 'orderNo'"))
|
||||
expect(transportColumns.indexOf("key: 'actions'")).toBeLessThan(transportColumns.indexOf("key: 'orderNo'"))
|
||||
expect(purchase).toContain('const rows = ref<OverseasPurchaseOrder[]>([])')
|
||||
expect(truckColumns.indexOf("key: 'actions'")).toBeLessThan(truckColumns.indexOf("key: 'orderNo'"))
|
||||
expect(looseboardColumns.indexOf("key: 'actions'")).toBeLessThan(looseboardColumns.indexOf("key: 'orderNo'"))
|
||||
expect(purchase).toContain('const rows = ref<OverseasPurchaseSummary[]>([])')
|
||||
expect(transport).toContain('const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([])')
|
||||
expect(`${purchase}${transport}`).not.toMatch(/OPO\d|TK\d|LB\d/)
|
||||
})
|
||||
|
||||
it('gives every formal list a stable query preference key without sharing transport preferences', () => {
|
||||
it('gives every formal list a stable query preference key', () => {
|
||||
const purchase = read('pages/PurchaseOrdersView.vue')
|
||||
const transport = read('pages/TransportOrdersView.vue')
|
||||
const purchaseToolbar = purchase.match(/<QueryToolbar[^>]*\/>/)?.[0]
|
||||
const transportToolbar = transport.match(/<QueryToolbar[^>]*\/>/)?.[0]
|
||||
|
||||
expect(purchaseToolbar).toContain('preference-key="overseas-business-purchases-query"')
|
||||
expect(transportToolbar).toContain(':preference-key="queryPreferenceKey"')
|
||||
expect(purchase.match(/<QueryToolbar[^>]*\/>/)?.[0]).toContain('preference-key="overseas-business-purchases-query"')
|
||||
expect(transport.match(/<QueryToolbar[^>]*\/>/)?.[0]).toContain(':preference-key="queryPreferenceKey"')
|
||||
expect(transport).toContain("'overseas-business-truck-query'")
|
||||
expect(transport).toContain("'overseas-business-looseboard-query'")
|
||||
})
|
||||
|
||||
it('does not invent an overseas endpoint or success fallback', () => {
|
||||
const apiContract = read('api/contracts.ts')
|
||||
const allPages = ['PurchaseOrdersView.vue', 'PurchaseOrderDetailView.vue', 'PurchaseOrderEditView.vue', 'TransportOrdersView.vue', 'TransportOrderFormView.vue']
|
||||
.map((file) => read(`pages/${file}`)).join('\n')
|
||||
expect(apiContract).not.toContain("from '@/shared/api/http'")
|
||||
expect(apiContract).not.toMatch(/\/platform\/v1|\/overseas\/v1|axios\.create/)
|
||||
expect(allPages).not.toMatch(/ElMessage\.success|模拟成功|提交成功/)
|
||||
expect(allPages).toContain('海外区域写端点待冻结')
|
||||
expect(allPages).toContain('海外区域更新端点待冻结')
|
||||
it('loads procurement list/detail and saves only the backend update contract', () => {
|
||||
const list = read('pages/PurchaseOrdersView.vue')
|
||||
const detail = read('pages/PurchaseOrderDetailView.vue')
|
||||
const edit = read('pages/PurchaseOrderEditView.vue')
|
||||
expect(list).toContain('listOverseasPurchases')
|
||||
expect(list).toContain("query: { customerId: row.customerId }")
|
||||
expect(detail).toContain('getOverseasPurchase(purchaseId.value, customerId.value)')
|
||||
expect(edit).toContain('updateOverseasPurchase(purchaseId.value, customerId.value, payload)')
|
||||
expect(edit).toContain('inboundQuantity: inboundEditable.value ? form.inboundQuantity : undefined')
|
||||
expect(edit).toContain("form.status === 'APPROVED'")
|
||||
expect(edit).not.toMatch(/payload[^\n]*(outboundQuantity|status):/)
|
||||
expect(`${list}${detail}${edit}`).toContain('LoadErrorState')
|
||||
})
|
||||
|
||||
it('keeps prototype-backed forms as routes with validation and unsaved-work registration', () => {
|
||||
it('loads truck list/detail but keeps unsupported writes and looseboard blocked', () => {
|
||||
const list = read('pages/TransportOrdersView.vue')
|
||||
const form = read('pages/TransportOrderFormView.vue')
|
||||
expect(list).toContain('listUsTruckOrders')
|
||||
expect(list).toContain("query: { customerId: row.customerId }")
|
||||
expect(form).toContain('getUsTruckOrder(orderId.value, customerId.value)')
|
||||
expect(form).not.toMatch(/createUsTruck|updateUsTruck/)
|
||||
expect(form).toContain('平台创建/普通编辑端点待后端补齐')
|
||||
expect(list).toContain('本土散板接口待后端补齐')
|
||||
expect(form).toContain('disabled><Save')
|
||||
})
|
||||
|
||||
it('keeps unsaved-work protection on both prototype-backed forms', () => {
|
||||
const purchaseEdit = read('pages/PurchaseOrderEditView.vue')
|
||||
const transportForm = read('pages/TransportOrderFormView.vue')
|
||||
expect(purchaseEdit).toContain('useUnsavedWorkRegistration')
|
||||
expect(purchaseEdit).toContain('出库量不能大于入库量')
|
||||
expect(purchaseEdit).toContain(':min="form.outboundQuantity"')
|
||||
expect(transportForm).toContain('useUnsavedWorkRegistration')
|
||||
expect(transportForm).toContain("message: '请输入起运地'")
|
||||
expect(transportForm).toContain("message: '请输入目的地'")
|
||||
expect(transportForm).toContain('disabled><Save')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,48 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import type { InventoryTransaction } from '../types'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getOverseasPurchase } from '../api/platformOverseasBusiness'
|
||||
import type { OverseasProcurementOutbound, OverseasPurchaseDetail, OverseasPurchaseStatus } from '../types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const route = useRoute(); const router = useRouter()
|
||||
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
|
||||
const fields = ['客户名称', '采购仓库', '采购品类', '商品名称', '商品规格', '商品链接']
|
||||
const stockFields = ['入库量', '出库量', '库存量', '库存状态']
|
||||
const orderFields = ['订单编号', '采购数量', '预算单价', '预算总价', '订单状态', '联系人', '联系电话', '收货地址', '订单备注']
|
||||
const courierFields = ['国内承运商', '国内运单号', '海外承运商', '海外运单号', '预计到达时间']
|
||||
const transactionColumns: DataTableColumn[] = [
|
||||
{ key: 'occurredAt', prop: 'occurredAt', label: '时间', minWidth: 155 },
|
||||
{ key: 'documentType', prop: 'documentType', label: '单据类型', minWidth: 120 },
|
||||
{ key: 'documentNo', prop: 'documentNo', label: '关联单号', minWidth: 160 },
|
||||
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库量', width: 95, align: 'right' },
|
||||
{ key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库量', width: 95, align: 'right' },
|
||||
{ key: 'balanceQuantity', prop: 'balanceQuantity', label: '结存量', width: 95, align: 'right' },
|
||||
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
const detail = ref<OverseasPurchaseDetail | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝' }
|
||||
const productFields = computed(() => [['客户名称', detail.value?.customerName], ['采购仓库', detail.value?.purchaseWarehouseName], ['采购品类', detail.value?.purchaseCategory], ['商品名称', detail.value?.productName], ['商品规格', detail.value?.productSpecification], ['商品链接', detail.value?.productUrl]] as const)
|
||||
const stockFields = computed(() => [['入库量', detail.value?.inboundQuantity], ['出库量', detail.value?.outboundQuantity], ['库存量', detail.value?.inventoryQuantity], ['库存状态', detail.value ? (detail.value.inventoryQuantity > 0 ? '在库' : '无库存') : null]] as const)
|
||||
const orderFields = computed(() => [['订单编号', detail.value?.orderNo], ['采购数量', detail.value?.purchaseQuantity], ['预算单价', detail.value ? formatAmount(detail.value.budgetUnitPrice, detail.value.currency) : null], ['订单状态', detail.value ? statusLabels[detail.value.status] : null], ['联系人', detail.value?.contactName], ['联系电话', detail.value?.contactPhone], ['收货地址', detail.value?.receivingAddress], ['订单备注', detail.value?.remark]] as const)
|
||||
const courierFields = computed(() => [['国内承运商', detail.value?.domesticCarrier], ['国内运单号', detail.value?.domesticTrackingNo], ['海外承运商', detail.value?.overseasCarrier], ['海外运单号', detail.value?.overseasTrackingNo], ['预计到达时间', formatDateTime(detail.value?.estimatedArrivalAt)]] as const)
|
||||
const outboundColumns: DataTableColumn[] = [
|
||||
{ key: 'outboundAt', label: '时间', minWidth: 155, formatter: (row: OverseasProcurementOutbound) => formatDateTime(row.outboundAt) },
|
||||
{ key: 'documentType', label: '单据类型', width: 110, formatter: () => '采购出库' },
|
||||
{ key: 'outboundNo', prop: 'outboundNo', label: '关联单号', minWidth: 160 },
|
||||
{ key: 'quantity', prop: 'quantity', label: '出库量', width: 95, align: 'right' },
|
||||
{ key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 120 },
|
||||
{ key: 'trackingNo', prop: 'trackingNo', label: '运单号', minWidth: 150 },
|
||||
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
|
||||
]
|
||||
const transactions: InventoryTransaction[] = []
|
||||
function display(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function formatAmount(value: string | null, currency: string) { return value == null ? '—' : `${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}` }
|
||||
async function load() {
|
||||
detail.value = null; loadError.value = null
|
||||
if (!purchaseId.value || !customerId.value) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; return }
|
||||
loading.value = true
|
||||
try { detail.value = await getOverseasPurchase(purchaseId.value, customerId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '海外采购订单详情加载失败') } finally { loading.value = false }
|
||||
}
|
||||
watch([purchaseId, customerId], () => { void load() }, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><DetailPageLayout aria-label="海外采购订单详情">
|
||||
<template #header><div class="detail-header"><div><h2>海外采购订单详情</h2><span>订单标识:{{ purchaseId || '—' }}</span></div><el-button @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回</el-button></div></template>
|
||||
<ContractPendingNotice domain="海外采购订单详情" />
|
||||
<div class="detail-surface surface-panel">
|
||||
<template #header><div class="detail-header"><div><h2>海外采购订单详情</h2><span>订单编号:{{ detail?.orderNo || purchaseId || '—' }}</span></div><el-button @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回</el-button></div></template>
|
||||
<LoadErrorState v-if="loadError" title="采购订单详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<div v-else v-loading="loading" class="detail-surface surface-panel">
|
||||
<div class="detail-grid">
|
||||
<section><h3>商品信息</h3><dl><div v-for="label in fields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl></section>
|
||||
<section><h3>库存信息</h3><dl><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl></section>
|
||||
<section><h3>订单信息</h3><dl><div v-for="label in orderFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl></section>
|
||||
<section><h3>快递信息</h3><dl><div v-for="label in courierFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl></section>
|
||||
<section class="full"><h3>进销存明细</h3><DataTable :data="transactions" :columns="transactionColumns" row-key="id" empty-text="进销存明细接口待接入" /></section>
|
||||
<section><h3>商品信息</h3><dl><div v-for="field in productFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>库存信息</h3><dl><div v-for="field in stockFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>订单信息</h3><dl><div v-for="field in orderFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>快递信息</h3><dl><div v-for="field in courierFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
|
||||
<section class="full"><div class="section-heading"><h3>进销存明细</h3><span>当前接口仅返回采购出库记录,完整入库流水与结存流水字段待后端补齐。</span></div><DataTable :data="detail?.outbounds || []" :columns="outboundColumns" row-key="id" empty-text="暂无采购出库记录" /></section>
|
||||
</div>
|
||||
</div>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.detail-header h2 { margin:0; font-size:16px; }.detail-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.detail-surface { padding:20px; }.detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:0 24px; }.detail-grid section { padding:0 0 18px; border-bottom:1px solid var(--cc-border); }.detail-grid section:nth-last-child(-n+2) { border-bottom:0; }.detail-grid h3 { margin:0 0 12px; font-size:14px; }.detail-grid .full { grid-column:1 / -1; padding-top:18px; }.detail-grid dl { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px 18px; margin:0; }.detail-grid dl div { min-width:0; }.detail-grid dt { color:var(--cc-muted); font-size:12px; }.detail-grid dd { margin:4px 0 0; color:var(--cc-text); font-size:13px; }
|
||||
.detail-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.detail-header h2 { margin:0; font-size:16px; }.detail-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.detail-surface { min-height:240px; padding:20px; }.detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:0 24px; }.detail-grid section { padding:0 0 18px; border-bottom:1px solid var(--cc-border); }.detail-grid section:nth-child(3), .detail-grid section:nth-child(4) { padding-top:18px; border-bottom:0; }.detail-grid h3 { margin:0 0 12px; font-size:14px; }.detail-grid .full { grid-column:1 / -1; padding-top:18px; border-top:1px solid var(--cc-border); border-bottom:0; }.detail-grid dl { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px 18px; margin:0; }.detail-grid dl div { min-width:0; }.detail-grid dt { color:var(--cc-muted); font-size:12px; }.detail-grid dd { margin:4px 0 0; overflow-wrap:anywhere; color:var(--cc-text); font-size:13px; }.section-heading { display:flex; align-items:baseline; justify-content:space-between; gap:16px; }.section-heading span { color:var(--cc-muted); font-size:12px; }
|
||||
</style>
|
||||
|
||||
@@ -1,47 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Save } from '@lucide/vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import { getOverseasPurchase, updateOverseasPurchase } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { OverseasPurchaseStatus, UpdateOverseasPurchaseRequest } from '../types'
|
||||
|
||||
interface PurchaseEditModel extends Omit<UpdateOverseasPurchaseRequest, 'expectedVersion'> { orderNo: string }
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
interface PurchaseEditModel {
|
||||
orderNo: string; purchaseCategory: string; productName: string; productSpecification: string | null; productUrl: string | null
|
||||
inboundQuantity: number; outboundQuantity: number; status: OverseasPurchaseStatus; purchaseWarehouseId: string | null; purchaseWarehouseName: string | null
|
||||
domesticCarrier: string | null; domesticTrackingNo: string | null; overseasCarrier: string | null; overseasTrackingNo: string | null
|
||||
estimatedArrivalAt: string | null; remark: string | null; version: number
|
||||
}
|
||||
const route = useRoute(); const router = useRouter()
|
||||
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive<PurchaseEditModel>({ orderNo: '', category: '', productName: '', specification: null, productUrl: null, inboundQuantity: 0, outboundQuantity: 0, status: 'PENDING_REVIEW', domesticCarrier: null, domesticTrackingNo: null, overseasCarrier: null, overseasTrackingNo: null, estimatedArrivalAt: null, remark: null })
|
||||
const validateOutbound = (_rule: unknown, value: number, callback: (error?: Error) => void) => value > form.inboundQuantity ? callback(new Error('出库量不能大于入库量')) : callback()
|
||||
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
const formRef = ref<FormInstance>(); const loading = ref(false); const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null); const submitError = ref<ApiErrorDetails | null>(null)
|
||||
const initial = (): PurchaseEditModel => ({ orderNo: '', purchaseCategory: '', productName: '', productSpecification: null, productUrl: null, inboundQuantity: 0, outboundQuantity: 0, status: 'PENDING_REVIEW', purchaseWarehouseId: null, purchaseWarehouseName: null, domesticCarrier: null, domesticTrackingNo: null, overseasCarrier: null, overseasTrackingNo: null, estimatedArrivalAt: null, remark: null, version: 0 })
|
||||
const form = reactive<PurchaseEditModel>(initial())
|
||||
const rules: FormRules<PurchaseEditModel> = {
|
||||
category: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
|
||||
purchaseCategory: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
|
||||
productName: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
|
||||
inboundQuantity: [{ required: true, type: 'number', min: 0, message: '入库量不能小于 0', trigger: 'change' }],
|
||||
outboundQuantity: [{ required: true, type: 'number', min: 0, message: '出库量不能小于 0', trigger: 'change' }, { validator: validateOutbound, trigger: 'change' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
}
|
||||
const statusOptions: Array<{ value: OverseasPurchaseStatus; label: string }> = [
|
||||
{ value: 'PENDING_REVIEW', label: '待审核' }, { value: 'CONFIRMED', label: '已确认' }, { value: 'PURCHASING', label: '采购中' }, { value: 'COMPLETED', label: '已完成' },
|
||||
]
|
||||
useUnsavedWorkRegistration({ key: 'overseas-purchase-editor', label: computed(() => '编辑海外采购订单'), isOpen: computed(() => true), model: form, requiredPermission: computed(() => overseasBusinessPermissions.purchaseEdit) })
|
||||
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝' }
|
||||
const inboundEditable = computed(() => form.status === 'APPROVED')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'overseas-purchase-editor', label: computed(() => '编辑海外采购订单'), isOpen: computed(() => true), model: form, requiredPermission: computed(() => overseasBusinessPermissions.purchaseEdit) })
|
||||
function optional(value: string | null) { return value?.trim() || null }
|
||||
async function load() {
|
||||
Object.assign(form, initial()); loadError.value = null; submitError.value = null
|
||||
if (!purchaseId.value || !customerId.value) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; captureBaseline(); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const detail = await getOverseasPurchase(purchaseId.value, customerId.value)
|
||||
Object.assign(form, { orderNo: detail.orderNo, purchaseCategory: detail.purchaseCategory, productName: detail.productName, productSpecification: detail.productSpecification, productUrl: detail.productUrl, inboundQuantity: detail.inboundQuantity, outboundQuantity: detail.outboundQuantity, status: detail.status, purchaseWarehouseId: detail.purchaseWarehouseId, purchaseWarehouseName: detail.purchaseWarehouseName, domesticCarrier: detail.domesticCarrier, domesticTrackingNo: detail.domesticTrackingNo, overseasCarrier: detail.overseasCarrier, overseasTrackingNo: detail.overseasTrackingNo, estimatedArrivalAt: detail.estimatedArrivalAt, remark: detail.remark, version: detail.version })
|
||||
captureBaseline()
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '海外采购订单加载失败') } finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
saving.value = true; submitError.value = null
|
||||
const payload: UpdateOverseasPurchaseRequest = { version: form.version, purchaseCategory: form.purchaseCategory.trim(), productName: form.productName.trim(), productSpecification: optional(form.productSpecification), productUrl: optional(form.productUrl), inboundQuantity: inboundEditable.value ? form.inboundQuantity : undefined, purchaseWarehouseId: optional(form.purchaseWarehouseId), purchaseWarehouseName: optional(form.purchaseWarehouseName), domesticCarrier: optional(form.domesticCarrier), domesticTrackingNo: optional(form.domesticTrackingNo), overseasCarrier: optional(form.overseasCarrier), overseasTrackingNo: optional(form.overseasTrackingNo), estimatedArrivalAt: form.estimatedArrivalAt || null, remark: optional(form.remark) }
|
||||
try {
|
||||
const updated = await updateOverseasPurchase(purchaseId.value, customerId.value, payload)
|
||||
form.version = updated.version; captureBaseline(); ElMessage.success('海外采购订单已保存')
|
||||
await router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId: updated.orderNo }, query: { customerId: updated.customerId } })
|
||||
} catch (error) { submitError.value = getApiErrorDetails(error, '海外采购订单保存失败') } finally { saving.value = false }
|
||||
}
|
||||
watch([purchaseId, customerId], () => { void load() }, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage width="comfortable"><DetailPageLayout aria-label="编辑海外采购订单">
|
||||
<template #header><div class="form-header"><div><h2>编辑海外采购订单</h2><span>订单标识:{{ purchaseId || '—' }}</span></div><el-button @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId } })"><ArrowLeft :size="16" />返回</el-button></div></template>
|
||||
<ContractPendingNotice domain="海外采购订单编辑" />
|
||||
<section class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<FormSection title="商品信息"><div class="form-grid three"><el-form-item label="订单编号"><el-input v-model="form.orderNo" disabled placeholder="接口接入后回填" /></el-form-item><el-form-item label="采购品类" prop="category"><el-input v-model="form.category" maxlength="100" /></el-form-item><el-form-item label="商品名称" prop="productName"><el-input v-model="form.productName" maxlength="160" /></el-form-item><el-form-item class="span-2" label="商品规格" prop="specification"><el-input v-model="form.specification" maxlength="255" /></el-form-item><el-form-item class="span-3" label="商品链接" prop="productUrl"><el-input v-model="form.productUrl" maxlength="1000" /></el-form-item></div></FormSection>
|
||||
<FormSection title="库存与快递信息"><div class="form-grid three"><el-form-item label="入库量" prop="inboundQuantity"><el-input-number v-model="form.inboundQuantity" :min="0" controls-position="right" /></el-form-item><el-form-item label="出库量" prop="outboundQuantity"><el-input-number v-model="form.outboundQuantity" :min="0" controls-position="right" /></el-form-item><el-form-item label="状态" prop="status"><el-select v-model="form.status"><el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></el-form-item><el-form-item label="国内承运商"><el-input v-model="form.domesticCarrier" maxlength="120" /></el-form-item><el-form-item label="国内运单号"><el-input v-model="form.domesticTrackingNo" maxlength="120" /></el-form-item><el-form-item label="海外承运商"><el-input v-model="form.overseasCarrier" maxlength="120" /></el-form-item><el-form-item label="海外运单号"><el-input v-model="form.overseasTrackingNo" maxlength="120" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item class="span-3" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></div></FormSection>
|
||||
</el-form><footer class="form-actions"><el-tooltip content="海外区域更新端点待冻结"><span><el-button type="primary" disabled><Save :size="16" />保存</el-button></span></el-tooltip><el-button @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId } })">取消</el-button></footer></section>
|
||||
<AdminPage width="comfortable"><OperationErrorToast :details="submitError" /><DetailPageLayout aria-label="编辑海外采购订单">
|
||||
<template #header><div class="form-header"><div><h2>编辑海外采购订单</h2><span>订单编号:{{ form.orderNo || purchaseId || '—' }}</span></div><el-button @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId }, query: { customerId } })"><ArrowLeft :size="16" />返回</el-button></div></template>
|
||||
<LoadErrorState v-if="loadError" title="采购订单暂时无法编辑" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<section v-else v-loading="loading" class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving">
|
||||
<FormSection title="商品信息"><div class="form-grid three"><el-form-item label="订单编号"><el-input v-model="form.orderNo" disabled /></el-form-item><el-form-item label="采购品类" prop="purchaseCategory"><el-input v-model="form.purchaseCategory" maxlength="128" /></el-form-item><el-form-item label="商品名称" prop="productName"><el-input v-model="form.productName" maxlength="255" /></el-form-item><el-form-item class="span-2" label="商品规格"><el-input v-model="form.productSpecification" maxlength="500" /></el-form-item><el-form-item class="span-3" label="商品链接"><el-input v-model="form.productUrl" maxlength="2000" /></el-form-item></div></FormSection>
|
||||
<FormSection title="库存与快递信息"><div class="form-grid three"><el-form-item label="入库量" prop="inboundQuantity"><el-input-number v-model="form.inboundQuantity" :min="form.outboundQuantity" :disabled="!inboundEditable" controls-position="right" /><small v-if="!inboundEditable">服务端仅允许已通过订单修改入库量</small></el-form-item><el-form-item label="出库量"><el-input-number v-model="form.outboundQuantity" disabled /></el-form-item><el-form-item label="状态"><el-input :model-value="statusLabels[form.status]" disabled /></el-form-item><el-form-item label="采购仓库编码"><el-input v-model="form.purchaseWarehouseId" maxlength="128" /></el-form-item><el-form-item label="采购仓库"><el-input v-model="form.purchaseWarehouseName" maxlength="255" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="国内承运商"><el-input v-model="form.domesticCarrier" maxlength="128" /></el-form-item><el-form-item label="国内运单号"><el-input v-model="form.domesticTrackingNo" maxlength="128" /></el-form-item><el-form-item label="海外承运商"><el-input v-model="form.overseasCarrier" maxlength="128" /></el-form-item><el-form-item label="海外运单号"><el-input v-model="form.overseasTrackingNo" maxlength="128" /></el-form-item><el-form-item class="span-3" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></div></FormSection>
|
||||
</el-form><footer class="form-actions"><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存</el-button><el-button :disabled="saving" @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId }, query: { customerId } })">取消</el-button></footer></section>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { padding:20px 24px; }.form-grid { display:grid; gap:0 20px; }.form-grid.three { grid-template-columns:repeat(3,minmax(0,1fr)); }.span-2 { grid-column:span 2; }.span-3 { grid-column:1 / -1; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
|
||||
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { padding:20px 24px; }.form-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:0 20px; }.span-2 { grid-column:span 2; }.span-3 { grid-column:1 / -1; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.form-grid small { display:block; margin-top:5px; color:var(--cc-muted); font-size:12px; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
|
||||
</style>
|
||||
|
||||
@@ -1,48 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Eye, Pencil } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import type { OverseasPurchaseOrder } from '../types'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listOverseasPurchases } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { OverseasPurchaseQuery, OverseasPurchaseStatus, OverseasPurchaseSummary } from '../types'
|
||||
|
||||
const rows = ref<OverseasPurchaseOrder[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = ref<QueryModel>({ orderNo: '', category: '', productName: '' })
|
||||
const router = useRouter(); const access = useAccessPreviewStore()
|
||||
const rows = ref<OverseasPurchaseSummary[]>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const filters = ref<QueryModel>({ orderNo: '', purchaseCategory: '', productName: '' }); const applied = ref({ ...filters.value })
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'orderNo', type: 'input', label: '订单编号', placeholder: '请输入订单编号', width: 210 },
|
||||
{ key: 'category', type: 'input', label: '采购品类', placeholder: '请输入采购品类', width: 190 },
|
||||
{ key: 'purchaseCategory', type: 'input', label: '采购品类', placeholder: '请输入采购品类', width: 190 },
|
||||
{ key: 'productName', type: 'input', label: '商品名称', placeholder: '请输入商品名称', width: 210 },
|
||||
]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 180 },
|
||||
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160 },
|
||||
{ key: 'category', prop: 'category', label: '采购品类', minWidth: 130 },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 180, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
|
||||
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160, formatter: (row: OverseasPurchaseSummary) => row.purchaseWarehouseName || '—' },
|
||||
{ key: 'purchaseCategory', prop: 'purchaseCategory', label: '采购品类', minWidth: 130 },
|
||||
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 160 },
|
||||
{ key: 'specification', prop: 'specification', label: '商品规格', minWidth: 180 },
|
||||
{ key: 'unitPrice', prop: 'unitPrice', label: '采购单价', width: 120, align: 'right' },
|
||||
{ key: 'productSpecification', prop: 'productSpecification', label: '商品规格', minWidth: 180, formatter: (row: OverseasPurchaseSummary) => row.productSpecification || '—' },
|
||||
{ key: 'budgetUnitPrice', label: '采购单价', width: 120, align: 'right', formatter: (row: OverseasPurchaseSummary) => formatAmount(row.budgetUnitPrice, row.currency) },
|
||||
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库量', width: 95, align: 'right' },
|
||||
{ key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库量', width: 95, align: 'right' },
|
||||
{ key: 'stockQuantity', prop: 'stockQuantity', label: '库存量', width: 95, align: 'right' },
|
||||
{ key: 'inventoryQuantity', prop: 'inventoryQuantity', label: '库存量', width: 95, align: 'right' },
|
||||
{ key: 'status', label: '状态', width: 105, formatter: (row: OverseasPurchaseSummary) => purchaseStatusLabel(row.status) },
|
||||
]
|
||||
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝' }
|
||||
function purchaseStatusLabel(status: OverseasPurchaseStatus) { return statusLabels[status] || status }
|
||||
function formatAmount(value: string | null, currency: string) { if (value == null) return '—'; const amount = Number(value); return `${Number.isFinite(amount) ? amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : value} ${currency}` }
|
||||
async function load() {
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const result = await listOverseasPurchases({ page: page.value, size: pageSize.value, orderNo: String(applied.value.orderNo || ''), purchaseCategory: String(applied.value.purchaseCategory || ''), productName: String(applied.value.productName || '') } satisfies OverseasPurchaseQuery)
|
||||
rows.value = result.records; total.value = result.total
|
||||
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '海外采购订单加载失败,请稍后重试') } finally { loading.value = false }
|
||||
}
|
||||
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
function open(row: OverseasPurchaseSummary, edit = false) { void router.push({ name: edit ? 'overseas-business-purchase-edit' : 'overseas-business-purchase-detail', params: { purchaseId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
watch([page, pageSize], () => { void load() }); onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><ListPageLayout aria-label="海外采购订单列表">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="海外采购订单筛选" preference-key="overseas-business-purchases-query" @search="page = 1" /></template>
|
||||
<template #state><ContractPendingNotice domain="海外采购订单" /></template>
|
||||
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-business-purchases" row-key="id" empty-text="海外采购订单查询契约待接入">
|
||||
<template #actions><div class="table-actions"><el-button size="small" type="primary" plain disabled><Eye :size="15" />详情</el-button><el-button size="small" disabled><Pencil :size="15" />编辑</el-button></div></template>
|
||||
<template #pagination-left>共 0 条海外采购订单</template>
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="海外采购订单筛选" preference-key="overseas-business-purchases-query" @search="search" /></template>
|
||||
<template v-if="loadError" #state><LoadErrorState title="海外采购订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-business-purchases" row-key="id" empty-text="暂无符合筛选条件的海外采购订单">
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row as OverseasPurchaseSummary)"><Eye :size="15" />详情</el-button><el-button v-if="access.can(overseasBusinessPermissions.purchaseEdit)" size="small" @click="open(row as OverseasPurchaseSummary, true)"><Pencil :size="15" />编辑</el-button></div></template>
|
||||
<template #pagination-left>共 {{ total }} 条海外采购订单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-panel { overflow: hidden; }.table-actions { display: flex; gap: 6px; }.table-actions :deep(.el-button) { margin-left: 0; }
|
||||
.table-panel { overflow:hidden; }.table-actions { display:flex; gap:6px; }.table-actions :deep(.el-button) { margin-left:0; }
|
||||
</style>
|
||||
|
||||
@@ -1,62 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Save } from '@lucide/vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.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 { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import { getUsTruckOrder } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { LocalTransportStatus, SaveLocalLooseboardOrderRequest } from '../types'
|
||||
|
||||
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const formRef = ref<FormInstance>()
|
||||
const route = useRoute(); const router = useRouter(); const formRef = ref<FormInstance>()
|
||||
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
|
||||
const editing = computed(() => Boolean(orderId.value))
|
||||
const isLooseboard = computed(() => props.domain === 'looseboard')
|
||||
const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车')
|
||||
const listRouteName = computed(() => isLooseboard.value ? 'overseas-business-looseboards' : 'overseas-business-trucks')
|
||||
const requiredPermission = computed(() => {
|
||||
if (isLooseboard.value) return editing.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.looseboardCreate
|
||||
return editing.value ? overseasBusinessPermissions.truckEdit : overseasBusinessPermissions.truckCreate
|
||||
})
|
||||
interface TransportFormModel extends SaveLocalLooseboardOrderRequest { status: LocalTransportStatus }
|
||||
const form = reactive<TransportFormModel>({ transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactPhone: null, containerType: '20GP', pieces: 0, grossWeightKg: null, volumeCbm: null, budgetAmount: null, currency: 'CNY', requiredDate: null, status: 'PENDING_ACCEPTANCE' })
|
||||
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
const editing = computed(() => Boolean(orderId.value)); const isLooseboard = computed(() => props.domain === 'looseboard')
|
||||
const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车'); const listRouteName = computed(() => isLooseboard.value ? 'overseas-business-looseboards' : 'overseas-business-trucks')
|
||||
const requiredPermission = computed(() => isLooseboard.value ? (editing.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.looseboardCreate) : (editing.value ? overseasBusinessPermissions.truckEdit : overseasBusinessPermissions.truckCreate))
|
||||
interface TransportFormModel extends SaveLocalLooseboardOrderRequest { orderNo: string; status: LocalTransportStatus }
|
||||
const initial = (): TransportFormModel => ({ orderNo: '', transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactInfo: null, containerType: '20GP', palletCount: 1, packageCount: 0, totalWeightKg: null, totalVolumeCbm: null, budgetAmount: null, currency: 'CNY', requestedDate: null, remark: null, status: 'PENDING_REVIEW', version: 0 })
|
||||
const form = reactive<TransportFormModel>(initial()); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const rules: FormRules<TransportFormModel> = {
|
||||
transportMode: [{ validator: (_rule, value, callback) => !isLooseboard.value || value ? callback() : callback(new Error('请选择运输方式')), trigger: 'change' }],
|
||||
palletRequired: [{ validator: (_rule, value, callback) => !isLooseboard.value || value ? callback() : callback(new Error('请选择是否打盘')), trigger: 'change' }],
|
||||
transportType: [{ required: true, message: '请选择运输类型', trigger: 'change' }],
|
||||
origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }],
|
||||
destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
|
||||
pieces: [{ required: true, type: 'number', min: 0, message: '件数不能小于 0', trigger: 'change' }],
|
||||
grossWeightKg: [{ pattern: /^\d+(\.\d{1,3})?$/, message: '总重量最多保留 3 位小数', trigger: 'blur' }],
|
||||
volumeCbm: [{ pattern: /^\d+(\.\d{1,3})?$/, message: '总体积最多保留 3 位小数', trigger: 'blur' }],
|
||||
budgetAmount: [{ pattern: /^\d+(\.\d{1,2})?$/, message: '预算总价最多保留 2 位小数', trigger: 'blur' }],
|
||||
transportMode: [{ validator: (_rule, value, callback) => (!isLooseboard.value || value) ? callback() : callback(new Error('请选择运输方式')), trigger: 'change' }],
|
||||
palletRequired: [{ validator: (_rule, value, callback) => (!isLooseboard.value || value) ? callback() : callback(new Error('请选择是否打盘')), trigger: 'change' }],
|
||||
transportType: [{ required: true, message: '请选择运输类型', trigger: 'change' }], origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }], destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
|
||||
palletCount: [{ required: true, type: 'number', min: 1, message: '托数至少为 1', trigger: 'change' }], packageCount: [{ required: true, type: 'number', min: 0, message: '件数不能小于 0', trigger: 'change' }],
|
||||
totalWeightKg: [{ pattern: /^\d+(\.\d{1,3})?$/, message: '总重量最多保留 3 位小数', trigger: 'blur' }], totalVolumeCbm: [{ pattern: /^\d+(\.\d{1,3})?$/, message: '总体积最多保留 3 位小数', trigger: 'blur' }], budgetAmount: [{ pattern: /^\d+(\.\d{1,2})?$/, message: '预算总价最多保留 2 位小数', trigger: 'blur' }],
|
||||
}
|
||||
const transportTypes = [{ value: 'PORT_PICKUP', label: '⚓ 港口提柜' }, { value: 'WAREHOUSE_TO_WAREHOUSE', label: '🏭 仓到仓' }, { value: 'WAREHOUSE_TO_DOOR', label: '🚛 仓到门' }]
|
||||
const transportModes = [{ value: 'SHARED_TRUCK', label: '🚐 拼车' }, { value: 'PRIORITY_CONSOLIDATION', label: '📦 优先拼载' }]
|
||||
const statusOptions: Array<{ value: LocalTransportStatus; label: string }> = [{ value: 'PENDING_ACCEPTANCE', label: '待接单' }, { value: 'DISPATCHED', label: '已派车' }, { value: 'IN_TRANSIT', label: '运输中' }, { value: 'COMPLETED', label: '已完成' }]
|
||||
useUnsavedWorkRegistration({ key: computed(() => `overseas-${props.domain}-editor`).value, label: computed(() => `${editing.value ? '编辑' : '新增'}${domainLabel.value}订单`), isOpen: computed(() => true), model: form, requiredPermission })
|
||||
const statusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: `overseas-${props.domain}-editor`, label: computed(() => `${editing.value ? '编辑' : '新增'}${domainLabel.value}订单`), isOpen: computed(() => true), model: form, requiredPermission })
|
||||
async function load() {
|
||||
Object.assign(form, initial()); loadError.value = null
|
||||
if (isLooseboard.value || !editing.value) { captureBaseline(); return }
|
||||
if (!customerId.value) { loadError.value = { message: '缺少客户标识,请从本土卡车列表重新进入' }; captureBaseline(); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const detail = await getUsTruckOrder(orderId.value, customerId.value)
|
||||
Object.assign(form, { orderNo: detail.orderNo, transportType: detail.transportType || '', origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, palletCount: detail.palletCount, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
|
||||
captureBaseline()
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '本土卡车订单详情加载失败') } finally { loading.value = false }
|
||||
}
|
||||
function back() { void router.push({ name: listRouteName.value }) }
|
||||
watch([orderId, customerId, isLooseboard], () => { void load() }, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage width="comfortable"><DetailPageLayout :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
|
||||
<template #header><div class="form-header"><div><h2>{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h2><span v-if="editing">订单标识:{{ orderId }}</span></div><el-button @click="back"><ArrowLeft :size="16" />返回列表</el-button></div></template>
|
||||
<ContractPendingNotice :domain="`${domainLabel}订单${editing ? '编辑' : '新增'}`" />
|
||||
<section class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<template #header><div class="form-header"><div><h2>{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h2><span v-if="editing">订单编号:{{ form.orderNo || orderId }}</span></div><el-button @click="back"><ArrowLeft :size="16" />返回列表</el-button></div></template>
|
||||
<LoadErrorState v-if="loadError" :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-else><ContractPendingNotice :domain="isLooseboard ? '本土散板订单' : (editing ? '本土卡车普通编辑' : '本土卡车平台新增')" />
|
||||
<section v-loading="loading" class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<FormSection v-if="isLooseboard" title="散板方式"><div class="choice-block"><el-form-item label="运输方式" prop="transportMode"><el-radio-group v-model="form.transportMode"><el-radio-button v-for="item in transportModes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><el-form-item label="是否打盘" prop="palletRequired"><el-radio-group v-model="form.palletRequired"><el-radio-button value="YES">是</el-radio-button><el-radio-button value="NO">否</el-radio-button></el-radio-group></el-form-item></div></FormSection>
|
||||
<FormSection title="运输信息"><el-form-item label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="160" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="160" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="255" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="255" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="80" placeholder="联系人姓名" /></el-form-item><el-form-item label="联系方式"><el-input v-model="form.contactPhone" maxlength="40" placeholder="联系电话" /></el-form-item></div></FormSection>
|
||||
<FormSection title="📦 货物信息"><div class="form-grid four"><el-form-item label="柜型"><el-select v-model="form.containerType"><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item><el-form-item label="件数" prop="pieces"><el-input-number v-model="form.pieces" :min="0" controls-position="right" /></el-form-item><el-form-item label="总重量 (kg)" prop="grossWeightKg"><el-input v-model="form.grossWeightKg" inputmode="decimal" /></el-form-item><el-form-item label="总体积 (m³)" prop="volumeCbm"><el-input v-model="form.volumeCbm" inputmode="decimal" /></el-form-item></div></FormSection>
|
||||
<FormSection title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价(¥)" prop="budgetAmount"><el-input v-model="form.budgetAmount" inputmode="decimal" /></el-form-item><el-form-item label="要求时间"><el-date-picker v-model="form.requiredDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item v-if="editing" label="状态"><el-select v-model="form.status"><el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></div></FormSection>
|
||||
</el-form><footer class="form-actions"><el-button @click="back">取消</el-button><el-tooltip content="海外区域写端点待冻结"><span><el-button type="primary" disabled><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></span></el-tooltip></footer></section>
|
||||
<FormSection title="运输信息"><el-form-item label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="500" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="500" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="128" /></el-form-item><el-form-item label="联系方式"><el-input v-model="form.contactInfo" maxlength="128" /></el-form-item></div></FormSection>
|
||||
<FormSection title="📦 货物信息"><div class="form-grid five"><el-form-item label="柜型"><el-select v-model="form.containerType"><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item><el-form-item label="托数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" controls-position="right" /></el-form-item><el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.packageCount" :min="0" controls-position="right" /></el-form-item><el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input v-model="form.totalWeightKg" inputmode="decimal" /></el-form-item><el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input v-model="form.totalVolumeCbm" inputmode="decimal" /></el-form-item></div></FormSection>
|
||||
<FormSection title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价"><el-input v-model="form.budgetAmount" inputmode="decimal"><template #append>{{ form.currency }}</template></el-input></el-form-item><el-form-item label="要求时间"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item v-if="editing" label="状态"><el-input :model-value="statusLabels[form.status]" disabled /></el-form-item><el-form-item class="span-2" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></div></FormSection>
|
||||
</el-form><footer class="form-actions"><el-button @click="back">取消</el-button><el-tooltip :content="isLooseboard ? '本土散板接口待后端补齐' : '平台创建/普通编辑端点待后端补齐'"><span><el-button type="primary" disabled><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></span></el-tooltip></footer></section></template>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { max-width:760px; padding:20px 24px; }.form-grid { display:grid; gap:0 24px; }.form-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }.form-grid.four { grid-template-columns:repeat(4,minmax(0,1fr)); gap:0 16px; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.choice-block { display:grid; grid-template-columns:1fr 1fr; gap:24px; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
|
||||
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { max-width:900px; padding:20px 24px; }.form-grid { display:grid; gap:0 24px; }.form-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }.form-grid.five { grid-template-columns:repeat(5,minmax(0,1fr)); gap:0 16px; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.choice-block { display:grid; grid-template-columns:1fr 1fr; gap:24px; }.span-2 { grid-column:1 / -1; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
|
||||
</style>
|
||||
|
||||
@@ -1,67 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Pencil, Plus } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import { listUsTruckOrders } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { LocalLooseboardOrder, LocalTransportOrder, LocalTransportStatus } from '../types'
|
||||
import type { LocalLooseboardOrder, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType } from '../types'
|
||||
|
||||
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const isLooseboard = computed(() => props.domain === 'looseboard')
|
||||
const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车')
|
||||
const queryPreferenceKey = computed(() => isLooseboard.value
|
||||
? 'overseas-business-looseboard-query'
|
||||
: 'overseas-business-truck-query')
|
||||
const router = useRouter(); const access = useAccessPreviewStore()
|
||||
const isLooseboard = computed(() => props.domain === 'looseboard'); const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车')
|
||||
const queryPreferenceKey = computed(() => isLooseboard.value ? 'overseas-business-looseboard-query' : 'overseas-business-truck-query')
|
||||
const createPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardCreate : overseasBusinessPermissions.truckCreate)
|
||||
const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = ref<QueryModel>({ status: '', routeKeyword: '' })
|
||||
const statusOptions: Array<{ value: LocalTransportStatus; label: string }> = [
|
||||
{ value: 'PENDING_ACCEPTANCE', label: '待接单' }, { value: 'DISPATCHED', label: '已派车' }, { value: 'IN_TRANSIT', label: '运输中' }, { value: 'COMPLETED', label: '已完成' },
|
||||
]
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: statusOptions },
|
||||
const editPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.truckEdit)
|
||||
const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const filters = ref<QueryModel>({ status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
|
||||
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
|
||||
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 170, options: isLooseboard.value ? ['待接单', '已派车', '运输中', '已完成'].map((label) => ({ label, value: label })) : Object.entries(truckStatusLabels).map(([value, label]) => ({ value, label })) },
|
||||
{ key: 'routeKeyword', type: 'input', label: '运输路线', placeholder: '起运地/目的地', width: 240 },
|
||||
]
|
||||
const baseColumns: DataTableColumn[] = [
|
||||
])
|
||||
const truckColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
|
||||
{ key: 'transportType', prop: 'transportType', label: '运输类型', minWidth: 120 },
|
||||
{ key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
|
||||
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 },
|
||||
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 },
|
||||
{ key: 'contactPhone', prop: 'contactPhone', label: '联系方式', minWidth: 145 },
|
||||
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 90 },
|
||||
{ key: 'grossWeightKg', prop: 'grossWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
|
||||
{ key: 'budgetAmount', prop: 'budgetAmount', label: '预算总价', width: 115, align: 'right' },
|
||||
{ key: 'requiredDate', prop: 'requiredDate', label: '要求时间', width: 120 },
|
||||
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalTransportOrder) => row.transportType ? transportTypeLabels[row.transportType] : '—' },
|
||||
{ key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 }, { key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 },
|
||||
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 }, { key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 },
|
||||
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 90 }, { key: 'palletCount', prop: 'palletCount', label: '托数', width: 80, align: 'right' },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
|
||||
{ key: 'budgetAmount', label: '预算总价', width: 120, align: 'right', formatter: (row: LocalTransportOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
|
||||
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 120, formatter: (row: LocalTransportOrder) => truckStatusLabels[row.status] || row.status },
|
||||
]
|
||||
const looseboardColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
|
||||
{ key: 'transportMode', prop: 'transportMode', label: '运输方式', minWidth: 120 }, { key: 'palletRequired', prop: 'palletRequired', label: '是否打盘', width: 100 },
|
||||
{ key: 'transportType', prop: 'transportType', label: '运输类型', minWidth: 120 }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
|
||||
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 },
|
||||
{ key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
|
||||
{ key: 'budgetAmount', prop: 'budgetAmount', label: '预算总价', width: 115, align: 'right' }, { key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 },
|
||||
{ key: 'status', prop: 'status', label: '状态', width: 105 },
|
||||
]
|
||||
const columns = computed<DataTableColumn[]>(() => isLooseboard.value ? [
|
||||
baseColumns[0]!, baseColumns[1]!,
|
||||
{ key: 'transportMode', prop: 'transportMode', label: '运输方式', minWidth: 120 },
|
||||
{ key: 'palletRequired', prop: 'palletRequired', label: '是否打盘', width: 100 },
|
||||
...baseColumns.slice(2),
|
||||
] : baseColumns)
|
||||
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
|
||||
const columns = computed(() => isLooseboard.value ? looseboardColumns : truckColumns)
|
||||
async function load() {
|
||||
rows.value = []; total.value = 0; loadError.value = null
|
||||
if (isLooseboard.value) return
|
||||
loading.value = true
|
||||
try { const result = await listUsTruckOrders({ page: page.value, size: pageSize.value, routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalTransportStatus | '' } satisfies LocalTransportQuery); rows.value = result.records; total.value = result.total } catch (error) { loadError.value = getApiErrorDetails(error, '本土卡车订单加载失败,请稍后重试') } finally { loading.value = false }
|
||||
}
|
||||
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (isLooseboard.value) return; void router.push({ name: 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page.value = 1; applied.value = { status: '', routeKeyword: '' }; filters.value = { ...applied.value }; void load() }); onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><ListPageLayout :aria-label="`${domainLabel}订单列表`">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :ariaLabel="`${domainLabel}订单筛选`" :preference-key="queryPreferenceKey" @search="page = 1" /></template>
|
||||
<template #state><ContractPendingNotice :domain="`${domainLabel}订单`" /></template>
|
||||
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" :preference-key="`overseas-business-${domain}`" row-key="id" :empty-text="`${domainLabel}订单查询契约待接入`">
|
||||
<template #toolbar-actions><el-tooltip v-if="access.can(createPermission)" content="海外区域创建端点待冻结"><span><el-button type="primary" disabled @click="openCreate"><Plus :size="16" />新增订单</el-button></span></el-tooltip></template>
|
||||
<template #actions><el-button size="small" disabled><Pencil :size="15" />编辑</el-button></template>
|
||||
<template #pagination-left>共 0 条{{ domainLabel }}订单</template>
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :ariaLabel="`${domainLabel}订单筛选`" :preference-key="queryPreferenceKey" @search="search" /></template>
|
||||
<template v-if="isLooseboard" #state><ContractPendingNotice domain="本土散板订单" /></template>
|
||||
<template v-else-if="loadError" #state><LoadErrorState title="本土卡车订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<section v-if="isLooseboard || !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="`overseas-business-${domain}`" row-key="id" :empty-text="isLooseboard ? '本土散板稳定子域与接口待后端补齐' : '暂无符合筛选条件的本土卡车订单'">
|
||||
<template #toolbar-actions><el-tooltip v-if="access.can(createPermission)" :content="isLooseboard ? '本土散板接口待后端补齐' : '平台创建端点待后端补齐'"><span><el-button type="primary" disabled><Plus :size="16" />新增订单</el-button></span></el-tooltip></template>
|
||||
<template #actions="{ row }"><el-button v-if="access.can(editPermission)" size="small" :disabled="isLooseboard" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)"><Pencil :size="15" />编辑</el-button></template>
|
||||
<template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -1,137 +1,70 @@
|
||||
export interface OverseasBusinessPage<T> {
|
||||
records: T[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
export interface OverseasBusinessPage<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
||||
|
||||
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED'
|
||||
export interface OverseasPurchaseQuery {
|
||||
page?: number
|
||||
size?: number
|
||||
orderNo?: string
|
||||
category?: string
|
||||
productName?: string
|
||||
page?: number; size?: number; customerId?: string; orderNo?: string; customerKeyword?: string
|
||||
purchaseCategory?: string; productName?: string; status?: OverseasPurchaseStatus | ''
|
||||
purchaseWarehouseId?: string; createdFrom?: string; createdTo?: string
|
||||
}
|
||||
|
||||
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'CONFIRMED' | 'PURCHASING' | 'COMPLETED'
|
||||
|
||||
export interface InventoryTransaction {
|
||||
id: string
|
||||
occurredAt: string
|
||||
documentType: string
|
||||
documentNo: string
|
||||
inboundQuantity: number
|
||||
outboundQuantity: number
|
||||
balanceQuantity: number
|
||||
remark: string | null
|
||||
export interface OverseasPurchaseSummary {
|
||||
id: string; orderNo: string; customerId: string; customerName: string | null
|
||||
purchaseCategory: string; productName: string; productSpecification: string | null
|
||||
purchaseQuantity: number; budgetUnitPrice: string | null; currency: string
|
||||
purchaseWarehouseId: string | null; purchaseWarehouseName: string | null
|
||||
inboundQuantity: number; outboundQuantity: number; inventoryQuantity: number
|
||||
status: OverseasPurchaseStatus; version: number; createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
export interface OverseasPurchaseOrder {
|
||||
id: string
|
||||
orderNo: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
purchaseWarehouseId: string
|
||||
purchaseWarehouseName: string
|
||||
category: string
|
||||
productName: string
|
||||
specification: string | null
|
||||
productUrl: string | null
|
||||
purchaseQuantity: number
|
||||
unitPrice: string | null
|
||||
currency: string
|
||||
inboundQuantity: number
|
||||
outboundQuantity: number
|
||||
stockQuantity: number
|
||||
receivingAddress: string | null
|
||||
domesticCarrier: string | null
|
||||
domesticTrackingNo: string | null
|
||||
overseasCarrier: string | null
|
||||
overseasTrackingNo: string | null
|
||||
estimatedArrivalAt: string | null
|
||||
contactName: string | null
|
||||
contactPhone: string | null
|
||||
status: OverseasPurchaseStatus
|
||||
remark: string | null
|
||||
inventoryTransactions: InventoryTransaction[]
|
||||
version: number
|
||||
export interface OverseasProcurementOutbound {
|
||||
id: string; quantity: number; outboundNo: string; carrier: string | null; trackingNo: string | null
|
||||
outboundAt: string | null; remark: string | null; createdBy: string | null; createdAt: string
|
||||
}
|
||||
export interface OverseasBusinessHistory {
|
||||
fromStatus: string | null; toStatus: string; operatorId: string | null; remark: string | null; createdAt: string
|
||||
}
|
||||
export interface OverseasPurchaseDetail extends OverseasPurchaseSummary {
|
||||
productUrl: string | null; receivingAddress: string | null; contactName: string | null; contactPhone: string | null
|
||||
domesticCarrier: string | null; domesticTrackingNo: string | null; overseasCarrier: string | null; overseasTrackingNo: string | null
|
||||
estimatedArrivalAt: string | null; remark: string | null; createdBy: string | null; reviewedBy: string | null
|
||||
reviewRemark: string | null; reviewedAt: string | null; outbounds: OverseasProcurementOutbound[]; histories: OverseasBusinessHistory[]
|
||||
}
|
||||
|
||||
export interface UpdateOverseasPurchaseRequest {
|
||||
category: string
|
||||
productName: string
|
||||
specification: string | null
|
||||
productUrl: string | null
|
||||
inboundQuantity: number
|
||||
outboundQuantity: number
|
||||
status: OverseasPurchaseStatus
|
||||
domesticCarrier: string | null
|
||||
domesticTrackingNo: string | null
|
||||
overseasCarrier: string | null
|
||||
overseasTrackingNo: string | null
|
||||
estimatedArrivalAt: string | null
|
||||
remark: string | null
|
||||
expectedVersion: number
|
||||
version: number; purchaseCategory?: string | null; productName?: string | null; productSpecification?: string | null
|
||||
productUrl?: string | null; inboundQuantity?: number | null; purchaseWarehouseId?: string | null
|
||||
purchaseWarehouseName?: string | null; domesticCarrier?: string | null; domesticTrackingNo?: string | null
|
||||
overseasCarrier?: string | null; overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null
|
||||
}
|
||||
|
||||
export type LocalTransportType = 'PORT_PICKUP' | 'WAREHOUSE_TO_WAREHOUSE' | 'WAREHOUSE_TO_DOOR'
|
||||
export type LocalTransportStatus = 'PENDING_ACCEPTANCE' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED'
|
||||
export type LocalTransportStatus = 'PENDING_REVIEW' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PALLET_OVERAGE_REVIEW' | 'PARTIALLY_ALLOCATED' | 'ALLOCATED' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'REJECTED' | 'CANCELLED'
|
||||
export interface LocalTransportQuery {
|
||||
page?: number; size?: number; orderNo?: string; routeKeyword?: string; status?: LocalTransportStatus | ''
|
||||
transportType?: LocalTransportType | ''; customerId?: string; requestedFrom?: string; requestedTo?: string
|
||||
}
|
||||
export interface LocalTransportOrder {
|
||||
id: string; customerId: string; orderNo: string; transportType: LocalTransportType | null
|
||||
origin: string; destination: string; containerType: string | null; palletCount: number; packageCount: number
|
||||
receivedPalletCount: number; allocatedPalletCount: number; shippedPalletCount: number
|
||||
totalWeightKg: string; totalVolumeCbm: string; budgetAmount: string | null; currency: string
|
||||
contactName: string; contactInfo: string; requestedDate: string; status: LocalTransportStatus
|
||||
version: number; createdAt: string; updatedAt: string
|
||||
}
|
||||
export interface LocalTransportPallet {
|
||||
id: string; palletNo: string; sequenceNo: number; source: string; status: string
|
||||
warehousePalletId: string | null; packageCount: number | null; receivedAt: string | null
|
||||
}
|
||||
export interface LocalTransportDetail extends LocalTransportOrder {
|
||||
originAddress: string | null; destinationAddress: string | null; remark: string | null; createdBy: string | null
|
||||
pallets: LocalTransportPallet[]; histories: OverseasBusinessHistory[]
|
||||
}
|
||||
export interface SaveLocalTransportOrderRequest {
|
||||
version?: number; transportType: LocalTransportType | ''; origin: string; destination: string
|
||||
originAddress: string | null; destinationAddress: string | null; contactName: string | null; contactInfo: string | null
|
||||
containerType: string | null; palletCount: number; packageCount: number; totalWeightKg: string | null
|
||||
totalVolumeCbm: string | null; budgetAmount: string | null; currency: string; requestedDate: string | null; remark: string | null
|
||||
}
|
||||
|
||||
// 本土散板后端稳定子域尚不存在;仅保留原型表单模型,不映射到卡车端点。
|
||||
export type LooseboardTransportMode = 'SHARED_TRUCK' | 'PRIORITY_CONSOLIDATION'
|
||||
export type PalletRequired = 'YES' | 'NO'
|
||||
|
||||
export interface LocalTransportOrder {
|
||||
id: string
|
||||
orderNo: string
|
||||
transportType: LocalTransportType
|
||||
origin: string
|
||||
destination: string
|
||||
originAddress: string | null
|
||||
destinationAddress: string | null
|
||||
contactName: string | null
|
||||
contactPhone: string | null
|
||||
containerType: string | null
|
||||
pieces: number
|
||||
grossWeightKg: string | null
|
||||
volumeCbm: string | null
|
||||
budgetAmount: string | null
|
||||
currency: string
|
||||
requiredDate: string | null
|
||||
status: LocalTransportStatus
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface LocalLooseboardOrder extends LocalTransportOrder {
|
||||
transportMode: LooseboardTransportMode
|
||||
palletRequired: PalletRequired
|
||||
}
|
||||
|
||||
export interface LocalTransportQuery {
|
||||
page?: number
|
||||
size?: number
|
||||
status?: LocalTransportStatus | ''
|
||||
routeKeyword?: string
|
||||
}
|
||||
|
||||
export interface SaveLocalTransportOrderRequest {
|
||||
transportType: LocalTransportType | ''
|
||||
origin: string
|
||||
destination: string
|
||||
originAddress: string | null
|
||||
destinationAddress: string | null
|
||||
contactName: string | null
|
||||
contactPhone: string | null
|
||||
containerType: string | null
|
||||
pieces: number
|
||||
grossWeightKg: string | null
|
||||
volumeCbm: string | null
|
||||
budgetAmount: string | null
|
||||
currency: string
|
||||
requiredDate: string | null
|
||||
expectedVersion?: number
|
||||
}
|
||||
|
||||
export interface SaveLocalLooseboardOrderRequest extends SaveLocalTransportOrderRequest {
|
||||
transportMode: LooseboardTransportMode | ''
|
||||
palletRequired: PalletRequired | ''
|
||||
}
|
||||
export interface LocalLooseboardOrder extends LocalTransportOrder { transportMode: LooseboardTransportMode; palletRequired: PalletRequired }
|
||||
export interface SaveLocalLooseboardOrderRequest extends SaveLocalTransportOrderRequest { transportMode: LooseboardTransportMode | ''; palletRequired: PalletRequired | '' }
|
||||
|
||||
@@ -1,13 +1,44 @@
|
||||
# 海外通用资料接口缺口
|
||||
|
||||
海外浏览器请求的统一入口已确认为 `gateway-overseas/`(Audience=`lastleg-api`)。国内 `warehouse/` 只维护“海外仓合作仓”公共主数据类型;实际海外仓、地址、能力、库位、实物托盘和资源锁归 `warehouse-overseas-service/`。海外仓候选和仓储资源不得继续从国内 Warehouse API 读取。
|
||||
本文件记录 Admin 海外通用资料与当前 Java 契约之间的实际差距。前端页面类型只是原型展示模型,不是已经发布的服务端 DTO。
|
||||
|
||||
当前仍缺少海外仓报价、汇率管理和车辆管理的已确认归属端点、请求/响应 DTO、错误码或写操作权限资源;统一海外 Base/Token 前端选择机制也尚未接入。
|
||||
## 已确认的服务入口
|
||||
|
||||
因此本模块当前只冻结正式路由、页面结构、字段、承载方式和只读权限,不包含内存示例数据或模拟成功:
|
||||
- `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`
|
||||
|
||||
新增、编辑、启停、删除、导出等写操作继续按动作使用独立权限,但编码与接口需等海外区域契约确认后再接入。列表权限只包含对应列表、详情和只读辅助数据访问。
|
||||
新增、编辑、启停、删除、导出等写操作继续按动作使用独立权限。列表权限只包含对应列表、详情和只读辅助数据访问。
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
| 页面 | 操作 | 原型证据 | 原型承载方式 | 原型尺寸/布局 | 当前实现 | 结论 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 合作仓 `gen-partner-warehouse` | 列表/筛选 | `10-general.html:50-56` | 现有仓库管理页内分类页签 | 自有仓筛选、代码/地址、国家;操作列在表格末尾(与仓库稳定页的全局操作列第一约定冲突时服从全局约定) | 不新增重复页面 | 建议类型码 `OVERSEAS_PARTNER` 未生效,不得冒充 `OVERSEAS` |
|
||||
| 合作仓 | 新增/编辑 | `overlays.html:1731-1756`、`app.js:1655-1701` | 居中弹框 `modal-fba-add` | 最大约 560px;单列标签表单 | 现有仓库页暂不开放该类型 | 后端正式接受合作仓类型后复用现有仓库弹框和写权限 |
|
||||
| 合作仓 `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;基础信息、费用明细、派送价格;正文滚动 | 工具栏写按钮禁用 | 等写接口与动作权限确认后接入,不模拟保存成功 |
|
||||
|
||||
@@ -73,4 +73,14 @@ describe('overseas master data pending-contract pages', () => {
|
||||
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('原型未定义车辆文件预览、审核/确认流程')
|
||||
})
|
||||
|
||||
it('records audited service boundaries without treating truck orders as vehicle master data', () => {
|
||||
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('保持结构化空态')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,10 +19,9 @@ describe('overseas master data routes', () => {
|
||||
expect(overseasMasterDataRoutes.some((route) => route.path.includes('partner-warehouse'))).toBe(false)
|
||||
})
|
||||
|
||||
it('documents but does not activate the suggested partner warehouse type', () => {
|
||||
it('activates the confirmed partner type in the existing warehouse domain without a duplicate route', () => {
|
||||
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'")
|
||||
expect(apiSource).toContain("'OVERSEAS_PARTNER'")
|
||||
expect(overseasMasterDataRoutes.some((route) => route.path.includes('partner-warehouse'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* 四期原型展示模型。
|
||||
*
|
||||
* 报价、汇率和车辆主数据尚无已确认的海外服务端 DTO;这些类型不能用于猜测
|
||||
* API 路径或直接序列化请求。真实接口落地后应按 Controller/OpenAPI 单独建立
|
||||
* API DTO,并在适配层转换为以下页面模型。
|
||||
*/
|
||||
export type MasterDataStatus = 'ACTIVE' | 'INACTIVE'
|
||||
export type QuoteCurrency = 'USD' | 'CNY'
|
||||
export type VehicleRegion = 'DOMESTIC' | 'OVERSEAS'
|
||||
@@ -86,4 +93,3 @@ export interface VehicleDetail extends VehicleSummary {
|
||||
deliveryOrders: VehicleDeliveryOrder[]
|
||||
replacementHistory: VehicleReplacementHistory[]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# 海外仓真实接口接入矩阵
|
||||
|
||||
浏览器只通过共享 `overseasHttp` 请求海外网关,模块路径统一从 `/platform/v1/...` 开始。长整型 ID 全程按字符串处理。以下结论来自 `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service` 当前 Controller/DTO;三者当前没有静态 OpenAPI YAML,本轮未把 Springdoc 注解之外的路径推断为正式接口。
|
||||
|
||||
| 原型页面 | 页面 ID | 当前真实接口 | 接入状态 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| 尾程订单列表 | `fba-last-mile-order` | `GET /platform/v1/last-leg/orders` | 已接入 | 显式 `customerId`;支持 `page/size/orderNo/inboundCode/containerNo/status`。原型其余筛选保留但禁用,不发送无效参数。 |
|
||||
| 尾程订单详情 | `fba-last-mile-order-detail` | `GET /platform/v1/last-leg/orders/{id}` | 已接入 | 展示订单、货物、状态历史和既有单据编号;出库/派送编号只读,不提供离仓动作。 |
|
||||
| 入库单列表/详情 | `fba-inbound-order*` | 仅有 `GET /platform/v1/last-leg/inbound-plans/{planNo}` | 阻断 | 没有分页列表,无法由详情接口反推列表;当前页面继续明确显示契约待接入。 |
|
||||
| 换标单列表/详情 | `fba-relabel-order*` | `GET /platform/v1/last-leg/relabel-applications`、`GET /{relabelNo}` | 已接入只读 | 列表 DTO 可展示单号、SKU、箱数和状态;详情补产品名、标签要求、文件。预算/费用继续显示空值并记录缺口,未伪造金额。 |
|
||||
| 库存/进销存/详情 | `fba-inventory*` | `GET /platform/v1/last-leg/inventory/summary|balances|ledger` | 阻断 | API 是入库单/包裹余额口径,原型是客户/仓库/SKU/产品口径,缺 SKU、产品名及原型指标。 |
|
||||
| 托盘列表/详情 | `fba-pallet*` | `GET /platform/v1/warehouse-admin/{warehouseId}/pallets`、`GET /{id}` | 阻断 | API 强制 `customerId + warehouseId` 且资源客户绑定;产品契约要求平台公共资源、独立查询全部客户/仓库。字段还缺 FBA 仓和采购价。 |
|
||||
| 箱号列表/详情 | `fba-carton*` | 无平台查询接口 | 阻断 | 只有内部接收事件产生快照,浏览器不得调用内部端点。 |
|
||||
| 产品库列表/详情 | `fba-product*` | 无平台查询接口 | 阻断 | 只有内部一件代发快照写入,未提供平台查询/维护端点。 |
|
||||
|
||||
状态映射仅使用服务端已有事实:`PENDING_ASSIGNMENT`(待分配)、`DESTINATION_ASSIGNED`(已分配目的仓)、`INBOUND_PLANNED`(已生成入库计划)。未定义的原型状态不发送到接口。
|
||||
|
||||
当前所有上述海外业务 Controller 均未声明与前端 `platform:overseas-warehouse:*` 对应的服务端 `@PreAuthorize`。因此本轮只接入尾程只读查询;已有写端点在服务端权限、幂等和原型字段未一致前不向 Admin 暴露。
|
||||
@@ -1,11 +1,10 @@
|
||||
# 四期海外仓前端待补契约
|
||||
|
||||
第一批正式页面只冻结路由、权限、字段与操作承载方式。后端服务边界现已确认:浏览器请求统一进入 `gateway-overseas/`(RS256/JWKS,Audience=`lastleg-api`);尾程订单、入仓计划、换标、产品/箱号快照和库存业务归 `lastleg-service/`;海外仓库、地址、能力、库位、实物托盘和资源锁归 `warehouse-overseas-service/`。国内 `warehouse/` 只维护“海外仓合作仓”公共主数据类型。
|
||||
正式页面已开始接入真实海外接口。后端服务边界现已确认:浏览器请求统一进入 `gateway-overseas/`(RS256/JWKS,Audience=`lastleg-api`);尾程订单、入仓计划、换标、产品/箱号快照和库存业务归 `lastleg-service/`;海外仓库、地址、能力、库位、实物托盘和资源锁归 `warehouse-overseas-service/`。国内 `warehouse/` 只维护“海外仓合作仓”公共主数据类型。逐页现状见 `API_INTEGRATION_MATRIX.md`。
|
||||
|
||||
以下前端适配或页面级契约补齐前,页面不得用演示数据或本地成功提示替代真实调用:
|
||||
|
||||
- 统一认证/HTTP 层对海外 API Base、短时海外 Access Token 的存储、刷新和自动选择,以及与现有国内 `http` 客户端一致的错误处理;业务页面不得自行读写 Token,也不得把请求发往国内 `gateway/`、`firstleg/` 或 `warehouse/`。
|
||||
- 尾程订单分页查询、详情、导入模板、批量导入、异步导出、批量转仓和入库计划接口;分页查询需明确“全部 / 待分配”页签的请求映射,以及出库单号、派送单号只读关联字段。
|
||||
- 尾程订单导入模板、批量导入、异步导出任务及对应独立权限。现有同步 CSV 导出不等同于 Admin 下载中心异步导出。尾程分页与详情已经接入;“待分配”映射为 `PENDING_ASSIGNMENT`,出库单号、派送单号仅展示既有只读关联事实。
|
||||
- 入库单分页查询、详情、创建、编辑、预约、智能排车接口,以及状态机、幂等键和操作权限。
|
||||
- 库存汇总、分页查询、详情、库位库存、托盘箱号和进销存记录接口。
|
||||
- 换标单分页、详情、创建、编辑/审核、文件上传与费用明细接口,以及状态机和幂等约定。
|
||||
@@ -15,6 +14,16 @@
|
||||
- 四期权限资源在 IAM 初始化数据中的正式登记。前端暂按本模块 `permissions.ts` 的稳定编码装配。
|
||||
- 所有分页响应、错误码、`requestId`/`traceId`、时间/金额精度和长整型 ID 字符串传输约定。
|
||||
|
||||
## 当前缺口的精确请求形状
|
||||
|
||||
- 入库单列表:缺 `GET /api/platform/v1/last-leg/inbound-plans`;至少需要 `customerId,page,size,planNo,inboundCode,warehouseId,fbaWarehouseCode,status,appointmentStatus`,响应需返回 `records/page/size/total/pages` 及原型列表字段。现有状态起点为 `PENDING_ASSIGNMENT`,转换端点 `POST /convert` 不能代替列表。
|
||||
- 产品库:缺平台 `GET/POST/PUT /api/platform/v1/last-leg/products[/{id}]`;查询/响应至少覆盖客户、SKU、中英文品名、品类、长宽高、重量,写入需 SKU 唯一性、精度、幂等和状态约束。
|
||||
- 箱号:缺平台 `GET/POST/PUT /api/platform/v1/last-leg/cartons[/{id}]`;查询/响应至少覆盖客户、箱号、FBA 仓、SKU、产品名、数量、重量、库存状态、托盘、入库单及只读出库关联。内部 `/internal/v1/lastleg/dropship/receive` 不得暴露给浏览器。
|
||||
- 库存:现有 `GET /api/platform/v1/last-leg/inventory/balances|ledger` 需增加 SKU/产品快照维度,或另提供与原型一致的 SKU 聚合端点;响应需覆盖现有量、可用量、暂扣、散板、在途、采购、状态和更新时间。状态流转仍只能由入库/库内事实驱动,不增加离仓操作。
|
||||
- 换标:当前列表响应需补 `productName,labelRequirement,relabelFile,budgetTotal/relabelCost`,或产品明确调整原型字段;写接口必须补正式 `create/edit` 权限。现有状态机为 `PENDING_PROCESSING -> PROCESSING/REJECTED/CANCELLED`、`PROCESSING -> COMPLETED/REJECTED/CANCELLED`。
|
||||
- 托盘:需先收敛资源作用域。产品要求平台公共查询全部客户/仓库,现接口 `GET /api/platform/v1/warehouse-admin/{warehouseId}/pallets?customerId=...` 强制客户绑定;响应还需明确 FBA 仓与采购价如何对应当前 `locationId/unitPrice`。在此冲突解决前不把客户筛选错误地当作公共资源范围。
|
||||
- 权限:尾程、入库、换标、库存、海外仓、库位、托盘相关 Controller 均需为列表/详情使用各资源 `*:list`,写操作分别使用 `*:create`、`*:edit` 等稳定权限;当前仅有应用范围与客户隔离,不能以页面按钮权限替代服务端鉴权。
|
||||
|
||||
范围排除:海外仓出库、退货出库、海外派送和其他库存离仓接口。
|
||||
|
||||
`lastleg-service` 已提供尾程订单、入仓计划、库存查询等 Controller,`warehouse-overseas-service` 已提供平台海外仓、库位和托盘 Controller;接入时以这些服务的 Controller/DTO/OpenAPI 为准。当前 `types.ts` 只冻结原型派生的页面展示字段,不是海外 API DTO;不得据此推导未覆盖页面的分页包络、URL、请求参数、时间/金额精度或写接口。统一区域客户端和对应页面契约完成后再建立实际 API 适配层,并逐页替换空态数据源。
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { getLastMileOrder, listLastMileOrders } from './lastMileOrders'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
|
||||
|
||||
describe('last-mile order overseas API', () => {
|
||||
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
|
||||
|
||||
it('uses the gateway-relative platform list endpoint and explicit customer scope', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 2, size: 20, total: 0, pages: 0 } })
|
||||
await expect(listLastMileOrders({ customerId: '190000000000000001', page: 2, size: 20, status: 'PENDING_ASSIGNMENT' })).resolves.toMatchObject({ page: 2 })
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', {
|
||||
params: { customerId: '190000000000000001', page: 2, size: 20, status: 'PENDING_ASSIGNMENT' },
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a long order id as a string in the detail path', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { order: { id: '190000000000000099' }, cargo: [], statusHistory: [] } })
|
||||
await getLastMileOrder('190000000000000099', '190000000000000001')
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/190000000000000099', {
|
||||
params: { customerId: '190000000000000001' },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call outbound or delivery endpoints', () => {
|
||||
const source = `${listLastMileOrders}${getLastMileOrder}`
|
||||
expect(source).not.toMatch(/outbound|delivery|dispatch/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export interface OverseasPageResult<T> {
|
||||
records: T[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface LastMileOrderQuery {
|
||||
customerId: string
|
||||
page: number
|
||||
size: number
|
||||
orderNo?: string
|
||||
inboundCode?: string
|
||||
containerNo?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface LastMileOrderSummaryDto {
|
||||
id: string
|
||||
orderNo: string
|
||||
orderType?: string | null
|
||||
inboundCode?: string | null
|
||||
sourceOrderNo?: string | null
|
||||
customerName?: string | null
|
||||
overseasWarehouseName?: string | null
|
||||
transferWarehouseName?: string | null
|
||||
fbaWarehouseCode?: string | null
|
||||
containerNo?: string | null
|
||||
containerStatus?: string | null
|
||||
abnormal?: boolean | null
|
||||
detained?: boolean | null
|
||||
palletCount?: number | null
|
||||
packageCount?: number | null
|
||||
volumeCbm?: number | null
|
||||
productQuantity?: number | null
|
||||
volumeWeightKg?: number | null
|
||||
estimatedArrivalAt?: string | null
|
||||
actualArrivalAt?: string | null
|
||||
estimatedWarehouseAt?: string | null
|
||||
inboundOrderNo?: string | null
|
||||
outboundOrderNo?: string | null
|
||||
deliveryOrderNo?: string | null
|
||||
relabelOrderNo?: string | null
|
||||
relabelStatus?: string | null
|
||||
palletMergeFlag?: string | null
|
||||
currentStatus?: string | null
|
||||
privateRemark?: string | null
|
||||
customerRemark?: string | null
|
||||
warehouseRemark?: string | null
|
||||
}
|
||||
|
||||
export interface LastMileOrderDto extends LastMileOrderSummaryDto {
|
||||
customerId: string
|
||||
sourceOrderNo?: string | null
|
||||
subOrderStatus?: string | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
export interface LastMileCargoDto {
|
||||
id: string
|
||||
skuCode?: string | null
|
||||
productNameZh?: string | null
|
||||
productNameEn?: string | null
|
||||
packageCount?: number | null
|
||||
productQuantity?: number | null
|
||||
palletCount?: number | null
|
||||
grossWeightKg?: number | null
|
||||
dimensionalWeightKg?: number | null
|
||||
volumeCbm?: number | null
|
||||
destinationWarehouseName?: string | null
|
||||
fbaTrackingNo?: string | null
|
||||
shipmentNo?: string | null
|
||||
}
|
||||
|
||||
export interface LastMileStatusHistoryDto {
|
||||
id: string
|
||||
fromStatus?: string | null
|
||||
toStatus?: string | null
|
||||
eventId?: string | null
|
||||
remark?: string | null
|
||||
createdAt?: string | null
|
||||
}
|
||||
|
||||
export interface LastMileOrderDetailDto {
|
||||
order: LastMileOrderDto
|
||||
cargo: LastMileCargoDto[]
|
||||
statusHistory: LastMileStatusHistoryDto[]
|
||||
}
|
||||
|
||||
export async function listLastMileOrders(query: LastMileOrderQuery) {
|
||||
const { customerId, ...params } = query
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<LastMileOrderSummaryDto>>, ApiEnvelope<OverseasPageResult<LastMileOrderSummaryDto>>>(
|
||||
'/platform/v1/last-leg/orders',
|
||||
{ params: { customerId, ...params } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getLastMileOrder(orderId: string, customerId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<LastMileOrderDetailDto>, ApiEnvelope<LastMileOrderDetailDto>>(
|
||||
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}`,
|
||||
{ params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { getRelabelOrder, listRelabelOrders } from './relabelOrders'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
|
||||
|
||||
describe('relabel overseas API', () => {
|
||||
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
|
||||
|
||||
it('uses explicit customer scope for the real page endpoint', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
|
||||
await listRelabelOrders({ customerId: '190000000000000001', page: 1, size: 20, skuCode: 'SKU-1' })
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', { params: { customerId: '190000000000000001', page: 1, size: 20, skuCode: 'SKU-1' } })
|
||||
})
|
||||
|
||||
it('encodes the relabel number and does not call a mutation endpoint', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { application: { id: '1', relabelNo: 'RL/1' }, items: [], history: [] } })
|
||||
await getRelabelOrder('RL/1', '190000000000000001')
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications/RL%2F1', { params: { customerId: '190000000000000001' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { OverseasPageResult } from './lastMileOrders'
|
||||
|
||||
export interface RelabelOrderQuery {
|
||||
customerId: string
|
||||
page: number
|
||||
size: number
|
||||
skuCode?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface RelabelSummaryDto {
|
||||
id: string
|
||||
relabelNo: string
|
||||
lastlegOrderId?: string | null
|
||||
lastlegOrderNo?: string | null
|
||||
customerOrderNo?: string | null
|
||||
overseasWarehouseId?: string | null
|
||||
labelDeadline?: string | null
|
||||
contactName?: string | null
|
||||
contactPhone?: string | null
|
||||
status?: string | null
|
||||
itemCount?: number | null
|
||||
totalPackageCount?: number | null
|
||||
notifiedPackageCount?: number | null
|
||||
skuCodes?: string | null
|
||||
createdAt?: string | null
|
||||
}
|
||||
|
||||
export interface RelabelItemDto {
|
||||
id: string
|
||||
skuCode?: string | null
|
||||
productNameZh?: string | null
|
||||
totalPackageCount?: number | null
|
||||
notifiedPackageCount?: number | null
|
||||
newSkuCode?: string | null
|
||||
labelRequirement?: string | null
|
||||
relabelFileName?: string | null
|
||||
relabelFileUrl?: string | null
|
||||
}
|
||||
|
||||
export interface RelabelHistoryDto {
|
||||
fromStatus?: string | null
|
||||
toStatus?: string | null
|
||||
operatorId?: string | null
|
||||
remark?: string | null
|
||||
createdAt?: string | null
|
||||
}
|
||||
|
||||
export interface RelabelDetailDto {
|
||||
application: RelabelSummaryDto
|
||||
remark?: string | null
|
||||
items: RelabelItemDto[]
|
||||
history: RelabelHistoryDto[]
|
||||
}
|
||||
|
||||
export async function listRelabelOrders(query: RelabelOrderQuery) {
|
||||
const { customerId, ...params } = query
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<RelabelSummaryDto>>, ApiEnvelope<OverseasPageResult<RelabelSummaryDto>>>(
|
||||
'/platform/v1/last-leg/relabel-applications', { params: { customerId, ...params } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getRelabelOrder(relabelNo: string, customerId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<RelabelDetailDto>, ApiEnvelope<RelabelDetailDto>>(
|
||||
`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`, { params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -3,13 +3,15 @@ import { ArrowLeft } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ContractPendingNotice from './ContractPendingNotice.vue'
|
||||
|
||||
defineProps<{
|
||||
withDefaults(defineProps<{
|
||||
title: string
|
||||
referenceLabel: string
|
||||
referenceValue: string
|
||||
backLabel: string
|
||||
domain: string
|
||||
}>()
|
||||
pending?: boolean
|
||||
statusText?: string
|
||||
}>(), { pending: true, statusText: '接口待接入' })
|
||||
|
||||
defineEmits<{ back: [] }>()
|
||||
</script>
|
||||
@@ -22,10 +24,10 @@ defineEmits<{ back: [] }>()
|
||||
<h1>{{ title }}</h1>
|
||||
<p>{{ referenceLabel }} <strong>{{ referenceValue }}</strong></p>
|
||||
</div>
|
||||
<span class="overseas-detail-status" role="status">接口待接入</span>
|
||||
<span class="overseas-detail-status" :class="{ 'is-live': !pending }" role="status">{{ statusText }}</span>
|
||||
</header>
|
||||
|
||||
<ContractPendingNotice :domain="domain" />
|
||||
<ContractPendingNotice v-if="pending" :domain="domain" />
|
||||
<slot />
|
||||
</AdminPage>
|
||||
</template>
|
||||
@@ -38,4 +40,5 @@ defineEmits<{ back: [] }>()
|
||||
.overseas-detail-title p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.overseas-detail-title strong { color: var(--cc-text); overflow-wrap: anywhere; }
|
||||
.overseas-detail-status { display: inline-flex; min-height: 26px; align-items: center; padding: 3px 10px; border: 1px solid #f3d19e; border-radius: 999px; background: #fdf6ec; color: #b88230; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
||||
.overseas-detail-status.is-live { border-color: var(--el-color-success-light-7); background: var(--el-color-success-light-9); color: var(--el-color-success-dark-2); }
|
||||
</style>
|
||||
|
||||
@@ -13,6 +13,11 @@ defineProps<{
|
||||
backLabel: string
|
||||
domain: string
|
||||
groups: DetailGroup[]
|
||||
fieldValues?: Record<string, string | number>
|
||||
pending?: boolean
|
||||
statusText?: string
|
||||
loading?: boolean
|
||||
showContent?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ back: [] }>()
|
||||
@@ -25,12 +30,15 @@ defineEmits<{ back: [] }>()
|
||||
:reference-value="referenceValue"
|
||||
:back-label="backLabel"
|
||||
:domain="domain"
|
||||
:pending="pending"
|
||||
:status-text="statusText"
|
||||
@back="$emit('back')"
|
||||
>
|
||||
<section class="read-only-detail" :aria-label="title">
|
||||
<slot name="state" />
|
||||
<section v-if="showContent !== false" v-loading="loading" class="read-only-detail" :aria-label="title" :aria-busy="loading">
|
||||
<section v-for="group in groups" :key="group.title" class="detail-group" :aria-label="group.title">
|
||||
<h2>{{ group.title }}</h2>
|
||||
<dl class="detail-fields"><div v-for="field in group.fields" :key="field"><dt>{{ field }}</dt><dd>—</dd></div></dl>
|
||||
<dl class="detail-fields"><div v-for="field in group.fields" :key="field"><dt>{{ field }}</dt><dd>{{ fieldValues?.[field] ?? '—' }}</dd></div></dl>
|
||||
</section>
|
||||
</section>
|
||||
</OverseasDetailShell>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toLastMileOrderListItem } from './lastMilePresentation'
|
||||
|
||||
describe('last-mile order presentation adapter', () => {
|
||||
it('maps backend codes, associations and long ids without inventing data', () => {
|
||||
const row = toLastMileOrderListItem({
|
||||
id: '190000000000000099', orderNo: 'LL-001', orderType: 'ONE_CLICK_DROPSHIP', currentStatus: 'PENDING_ASSIGNMENT',
|
||||
abnormal: true, detained: false, packageCount: 12, deliveryOrderNo: 'DEL-READ-ONLY', outboundOrderNo: 'OUT-READ-ONLY',
|
||||
}, '190000000000000001')
|
||||
expect(row).toMatchObject({
|
||||
id: '190000000000000099', customerId: '190000000000000001', orderType: '一件代发', status: '待分配',
|
||||
warning: '异常', abnormal: '是', held: '否', cartons: 12, dispatchOrderNo: 'DEL-READ-ONLY', outboundOrderNo: 'OUT-READ-ONLY',
|
||||
})
|
||||
expect(row.rejectedAt).toBe('—')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { LastMileOrderSummaryDto } from './api/lastMileOrders'
|
||||
import type { LastMileOrderListItem } from './types'
|
||||
|
||||
const orderTypeLabels: Record<string, string> = {
|
||||
ONE_CLICK_DROPSHIP: '一件代发',
|
||||
FIRST_LEG: '头程',
|
||||
REJECTED: '拒收订单',
|
||||
EXPRESS: '快递订单',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
PENDING_ASSIGNMENT: '待分配',
|
||||
DESTINATION_ASSIGNED: '已分配目的仓',
|
||||
INBOUND_PLANNED: '已生成入库计划',
|
||||
PENDING_PROCESSING: '待处理',
|
||||
PROCESSING: '处理中',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
|
||||
function text(value: unknown) {
|
||||
const normalized = String(value ?? '').trim()
|
||||
return normalized || '—'
|
||||
}
|
||||
|
||||
function count(value: number | null | undefined) {
|
||||
return Number.isFinite(value) ? Number(value) : 0
|
||||
}
|
||||
|
||||
function yesNo(value: boolean | null | undefined) {
|
||||
return value == null ? '—' : value ? '是' : '否'
|
||||
}
|
||||
|
||||
export function lastMileStatusLabel(value: unknown) {
|
||||
const code = String(value ?? '').trim()
|
||||
return statusLabels[code] || text(code)
|
||||
}
|
||||
|
||||
export function lastMileOrderTypeLabel(value: unknown) {
|
||||
const code = String(value ?? '').trim()
|
||||
return orderTypeLabels[code] || text(code)
|
||||
}
|
||||
|
||||
export function formatOverseasDateTime(value: unknown) {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return '—'
|
||||
const date = new Date(raw)
|
||||
return Number.isNaN(date.getTime()) ? raw : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto, customerId: string): LastMileOrderListItem {
|
||||
return {
|
||||
id: String(dto.id),
|
||||
customerId,
|
||||
orderNo: text(dto.orderNo),
|
||||
warning: dto.abnormal ? '异常' : '—',
|
||||
orderType: lastMileOrderTypeLabel(dto.orderType),
|
||||
rejectedAt: '—',
|
||||
inboundCode: text(dto.inboundCode),
|
||||
owner: text(dto.customerName),
|
||||
warehouse: text(dto.overseasWarehouseName),
|
||||
transferWarehouse: text(dto.transferWarehouseName),
|
||||
fbaWarehouse: text(dto.fbaWarehouseCode),
|
||||
containerNo: text(dto.containerNo),
|
||||
containerStatus: text(dto.containerStatus),
|
||||
abnormal: yesNo(dto.abnormal),
|
||||
held: yesNo(dto.detained),
|
||||
pallets: count(dto.palletCount),
|
||||
cartons: count(dto.packageCount),
|
||||
volume: count(dto.volumeCbm),
|
||||
quantity: count(dto.productQuantity),
|
||||
volumeWeight: count(dto.volumeWeightKg),
|
||||
portEta: formatOverseasDateTime(dto.estimatedArrivalAt),
|
||||
portAta: formatOverseasDateTime(dto.actualArrivalAt),
|
||||
warehouseEta: formatOverseasDateTime(dto.estimatedWarehouseAt),
|
||||
inboundOrderNo: text(dto.inboundOrderNo),
|
||||
outboundOrderNo: text(dto.outboundOrderNo),
|
||||
dispatchOrderNo: text(dto.deliveryOrderNo),
|
||||
relabelOrderNo: text(dto.relabelOrderNo),
|
||||
relabelStatus: lastMileStatusLabel(dto.relabelStatus),
|
||||
consolidated: text(dto.palletMergeFlag),
|
||||
status: lastMileStatusLabel(dto.currentStatus),
|
||||
internalRemark: text(dto.privateRemark),
|
||||
customerRemark: text(dto.customerRemark),
|
||||
warehouseRemark: text(dto.warehouseRemark),
|
||||
}
|
||||
}
|
||||
@@ -1,76 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getLastMileOrder, type LastMileOrderDetailDto } from '../api/lastMileOrders'
|
||||
import { formatOverseasDateTime, lastMileOrderTypeLabel, lastMileStatusLabel } from '../lastMilePresentation'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
|
||||
interface DetailField { label: string; value: string | number }
|
||||
interface DocumentRow { type: string; number: string; createdAt: string; quote: string }
|
||||
interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number }
|
||||
interface LogRow { id: string; occurredAt: string; node: string; operator: string; result: string }
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const orderId = computed(() => String(route.params.orderId || '—'))
|
||||
const orderId = computed(() => String(route.params.orderId || ''))
|
||||
const customerId = computed(() => String(route.query.customerId || ''))
|
||||
const detail = ref<LastMileOrderDetailDto | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||
function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' }
|
||||
|
||||
const referenceValue = computed(() => text(detail.value?.order.orderNo || orderId.value))
|
||||
const orderFields = computed<DetailField[]>(() => {
|
||||
const order = detail.value?.order
|
||||
return [
|
||||
{ label: '订单类型', value: lastMileOrderTypeLabel(order?.orderType) },
|
||||
{ label: '拒收时间', value: '—' },
|
||||
{ label: '实际入库时间', value: '—' },
|
||||
{ label: '头程单号', value: text(order?.sourceOrderNo) },
|
||||
{ label: '货主', value: text(order?.customerName) },
|
||||
{ label: '海外仓', value: text(order?.overseasWarehouseName) },
|
||||
{ label: '转仓目的仓', value: text(order?.transferWarehouseName) },
|
||||
{ label: 'FBA仓库', value: text(order?.fbaWarehouseCode) },
|
||||
{ label: '集装箱号', value: text(order?.containerNo) },
|
||||
{ label: '货柜异常', value: yesNo(order?.abnormal) },
|
||||
{ label: '异常原因', value: order?.abnormal ? text(order.subOrderStatus) : '—' },
|
||||
{ label: '运输方式', value: '—' },
|
||||
{ label: '预计到仓', value: formatOverseasDateTime(order?.estimatedWarehouseAt) },
|
||||
{ label: '清关状态', value: text(order?.containerStatus) },
|
||||
]
|
||||
})
|
||||
const detailFields = computed<DetailField[]>(() => {
|
||||
const order = detail.value?.order
|
||||
return [
|
||||
{ label: '订单类型', value: lastMileOrderTypeLabel(order?.orderType) },
|
||||
{ label: '入仓码', value: text(order?.inboundCode) },
|
||||
{ label: '目的仓', value: text(order?.transferWarehouseName) },
|
||||
{ label: '托数', value: Number(order?.palletCount || 0) },
|
||||
{ label: '箱数', value: Number(order?.packageCount || 0) },
|
||||
{ label: '数量', value: Number(order?.productQuantity || 0) },
|
||||
{ label: '体积重量', value: Number(order?.volumeWeightKg || 0) },
|
||||
{ label: '预计到港时间', value: formatOverseasDateTime(order?.estimatedArrivalAt) },
|
||||
{ label: '实际到港时间', value: formatOverseasDateTime(order?.actualArrivalAt) },
|
||||
{ label: '预计到仓', value: formatOverseasDateTime(order?.estimatedWarehouseAt) },
|
||||
{ label: '是否暂扣', value: yesNo(order?.detained) },
|
||||
{ label: '备注(仅自己)', value: text(order?.privateRemark) },
|
||||
{ label: '备注(客户)', value: text(order?.customerRemark) },
|
||||
{ label: '备注(仓库)', value: text(order?.warehouseRemark) },
|
||||
]
|
||||
})
|
||||
const planFields = computed<DetailField[]>(() => {
|
||||
const order = detail.value?.order
|
||||
return [
|
||||
{ label: '入仓码', value: text(order?.inboundCode) },
|
||||
{ label: '计划入库仓', value: text(order?.transferWarehouseName || order?.overseasWarehouseName) },
|
||||
{ label: '合托批次', value: text(order?.palletMergeFlag) },
|
||||
{ label: '合托后托数', value: Number(order?.palletCount || 0) },
|
||||
{ label: 'FBA预约号', value: '—' },
|
||||
{ label: '预约状态', value: '—' },
|
||||
{ label: '换标状态', value: lastMileStatusLabel(order?.relabelStatus) },
|
||||
]
|
||||
})
|
||||
const documents = computed<DocumentRow[]>(() => {
|
||||
const order = detail.value?.order
|
||||
if (!order) return []
|
||||
return [
|
||||
['入库单', order.inboundOrderNo],
|
||||
['出库单(只读关联)', order.outboundOrderNo],
|
||||
['派送单(只读关联)', order.deliveryOrderNo],
|
||||
['换标单', order.relabelOrderNo],
|
||||
].filter((item) => item[1]).map(([type, number]) => ({ type: String(type), number: String(number), createdAt: '—', quote: '—' }))
|
||||
})
|
||||
const cargo = computed<CargoRow[]>(() => (detail.value?.cargo || []).map((item) => ({
|
||||
id: String(item.id), sku: text(item.skuCode), productName: text(item.productNameZh), pallets: Number(item.palletCount || 0),
|
||||
cartons: Number(item.packageCount || 0), quantity: Number(item.productQuantity || 0), weight: Number(item.grossWeightKg || 0), volume: Number(item.volumeCbm || 0),
|
||||
})))
|
||||
const logs = computed<LogRow[]>(() => (detail.value?.statusHistory || []).map((item) => ({
|
||||
id: String(item.id), occurredAt: formatOverseasDateTime(item.createdAt), node: lastMileStatusLabel(item.toStatus), operator: '—',
|
||||
result: text(item.remark || `${lastMileStatusLabel(item.fromStatus)} → ${lastMileStatusLabel(item.toStatus)}`),
|
||||
})))
|
||||
|
||||
const orderFields = ['订单类型', '拒收时间', '实际入库时间', '头程单号', '货主', '海外仓', '转仓目的仓', 'FBA仓库', '集装箱号', '货柜异常', '异常原因', '运输方式', '预计到仓', '清关状态']
|
||||
const detailFields = ['订单类型', '入仓码', '目的仓', '托数', '箱数', '数量', '体积重量', '预计到港时间', '实际到港时间', '预计到仓', '是否暂扣', '备注(仅自己)', '备注(客户)', '备注(仓库)']
|
||||
const planFields = ['入仓码', '计划入库仓', '合托批次', '合托后托数', 'FBA预约号', '预约状态', '换标状态']
|
||||
const documentColumns: DataTableColumn[] = [
|
||||
{ key: 'type', label: '单据类型', minWidth: 120 },
|
||||
{ key: 'number', label: '单据编号', minWidth: 180 },
|
||||
{ key: 'createdAt', label: '生成时间', minWidth: 160 },
|
||||
{ key: 'quote', label: '报价', minWidth: 140 },
|
||||
{ key: 'type', prop: 'type', label: '单据类型', minWidth: 120 }, { key: 'number', prop: 'number', label: '单据编号', minWidth: 180 },
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '生成时间', minWidth: 160 }, { key: 'quote', prop: 'quote', label: '报价', minWidth: 140 },
|
||||
]
|
||||
const cargoColumns: DataTableColumn[] = [
|
||||
{ key: 'sku', label: 'SKU', minWidth: 150 },
|
||||
{ key: 'productName', label: '中文品名', minWidth: 180 },
|
||||
{ key: 'pallets', label: '托数', width: 90, align: 'right' },
|
||||
{ key: 'cartons', label: '箱数', width: 90, align: 'right' },
|
||||
{ key: 'quantity', label: '件数', width: 90, align: 'right' },
|
||||
{ key: 'weight', label: '重量(kg)', width: 110, align: 'right' },
|
||||
{ key: 'volume', label: '体积(m³)', width: 110, align: 'right' },
|
||||
{ key: 'sku', prop: 'sku', label: 'SKU', minWidth: 150 }, { key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 },
|
||||
{ key: 'pallets', prop: 'pallets', label: '托数', width: 90, align: 'right' }, { key: 'cartons', prop: 'cartons', label: '箱数', width: 90, align: 'right' },
|
||||
{ key: 'quantity', prop: 'quantity', label: '件数', width: 90, align: 'right' }, { key: 'weight', prop: 'weight', label: '重量(kg)', width: 110, align: 'right' },
|
||||
{ key: 'volume', prop: 'volume', label: '体积(m³)', width: 110, align: 'right' },
|
||||
]
|
||||
const logColumns: DataTableColumn[] = [
|
||||
{ key: 'occurredAt', label: '时间', minWidth: 160 },
|
||||
{ key: 'node', label: '操作节点', minWidth: 140 },
|
||||
{ key: 'operator', label: '操作人', minWidth: 130 },
|
||||
{ key: 'result', label: '处理结果', minWidth: 220 },
|
||||
{ key: 'occurredAt', prop: 'occurredAt', label: '时间', minWidth: 160 }, { key: 'node', prop: 'node', label: '操作节点', minWidth: 140 },
|
||||
{ key: 'operator', prop: 'operator', label: '操作人', minWidth: 130 }, { key: 'result', prop: 'result', label: '处理结果', minWidth: 220 },
|
||||
]
|
||||
|
||||
function back() {
|
||||
void router.push({ name: 'overseas-last-mile-orders' })
|
||||
async function load() {
|
||||
if (!orderId.value || !customerId.value) {
|
||||
loadError.value = { message: '详情缺少客户范围,请从尾程订单列表重新进入' }
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try { detail.value = await getLastMileOrder(orderId.value, customerId.value) }
|
||||
catch (error) { loadError.value = getApiErrorDetails(error, '尾程订单详情加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function back() { void router.push({ name: 'overseas-last-mile-orders' }) }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OverseasDetailShell title="尾程订单详情" reference-label="尾程单号" :reference-value="orderId" back-label="返回尾程订单" domain="尾程订单详情" @back="back">
|
||||
<div class="detail-grid">
|
||||
<section class="detail-section" aria-labelledby="last-mile-order-heading">
|
||||
<h2 id="last-mile-order-heading">订单信息</h2>
|
||||
<dl class="detail-fields"><div v-for="label in orderFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
|
||||
</section>
|
||||
<section class="detail-section" aria-labelledby="last-mile-lines-heading">
|
||||
<h2 id="last-mile-lines-heading">订单明细</h2>
|
||||
<dl class="detail-fields"><div v-for="label in detailFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
|
||||
</section>
|
||||
<section class="detail-section" aria-labelledby="last-mile-plan-heading">
|
||||
<h2 id="last-mile-plan-heading">计划信息</h2>
|
||||
<dl class="detail-fields"><div v-for="label in planFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
|
||||
</section>
|
||||
<section class="detail-section table-section" aria-labelledby="last-mile-documents-heading">
|
||||
<h2 id="last-mile-documents-heading">关联单据</h2>
|
||||
<DataTable :data="[]" :columns="documentColumns" column-settings-mode="hide" empty-text="海外区域关联单据契约待接入" />
|
||||
</section>
|
||||
<section class="detail-section table-section is-wide" aria-labelledby="last-mile-cargo-heading">
|
||||
<h2 id="last-mile-cargo-heading">货物信息</h2>
|
||||
<DataTable :data="[]" :columns="cargoColumns" column-settings-mode="hide" empty-text="海外区域货物明细契约待接入" />
|
||||
</section>
|
||||
<section class="detail-section table-section" aria-labelledby="last-mile-logs-heading">
|
||||
<h2 id="last-mile-logs-heading">操作记录</h2>
|
||||
<DataTable :data="[]" :columns="logColumns" column-settings-mode="hide" empty-text="海外区域操作记录契约待接入" />
|
||||
</section>
|
||||
<OverseasDetailShell title="尾程订单详情" reference-label="尾程单号" :reference-value="referenceValue" back-label="返回尾程订单" domain="尾程订单详情" :pending="false" :status-text="detail ? lastMileStatusLabel(detail.order.currentStatus) : '加载中'" @back="back">
|
||||
<LoadErrorState v-if="loadError" title="尾程订单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<div v-else v-loading="loading" class="detail-grid" :aria-busy="loading">
|
||||
<section class="detail-section" aria-labelledby="last-mile-order-heading"><h2 id="last-mile-order-heading">订单信息</h2><dl class="detail-fields"><div v-for="field in orderFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
|
||||
<section class="detail-section" aria-labelledby="last-mile-lines-heading"><h2 id="last-mile-lines-heading">订单明细</h2><dl class="detail-fields"><div v-for="field in detailFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
|
||||
<section class="detail-section" aria-labelledby="last-mile-plan-heading"><h2 id="last-mile-plan-heading">计划信息</h2><dl class="detail-fields"><div v-for="field in planFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
|
||||
<section class="detail-section table-section" aria-labelledby="last-mile-documents-heading"><h2 id="last-mile-documents-heading">关联单据</h2><DataTable :data="documents" :columns="documentColumns" column-settings-mode="hide" empty-text="暂无关联单据" /></section>
|
||||
<section class="detail-section table-section is-wide" aria-labelledby="last-mile-cargo-heading"><h2 id="last-mile-cargo-heading">货物信息</h2><DataTable :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" empty-text="暂无货物明细" /></section>
|
||||
<section class="detail-section table-section" aria-labelledby="last-mile-logs-heading"><h2 id="last-mile-logs-heading">操作记录</h2><DataTable :data="logs" :columns="logColumns" column-settings-mode="hide" row-key="id" empty-text="暂无操作记录" /></section>
|
||||
</div>
|
||||
</OverseasDetailShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-grid { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--cc-border); background: #fff; }
|
||||
.detail-grid { display: grid; min-width: 0; min-height: 240px; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--cc-border); background: #fff; }
|
||||
.detail-section { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-section:nth-child(even) { border-right: 0; }
|
||||
.detail-section.is-wide { grid-column: 1 / -1; border-right: 0; }
|
||||
|
||||
@@ -1,32 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Eye } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import { listLastMileOrders } from '../api/lastMileOrders'
|
||||
import { toLastMileOrderListItem } from '../lastMilePresentation'
|
||||
import type { LastMileOrderListItem } from '../types'
|
||||
|
||||
const rows = ref<LastMileOrderListItem[]>([])
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const loading = ref(false)
|
||||
const customersLoading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const customersError = ref<ApiErrorDetails | null>(null)
|
||||
const router = useRouter()
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const statusTab = ref<'全部' | '待分配'>('全部')
|
||||
const filters = ref<QueryModel>({ orderNo: '', inboundCode: '', warehouse: '', fbaWarehouse: '', orderType: '', owner: '', containerNo: '', status: '', consolidated: '', portEta: [] })
|
||||
const fields: QueryField[] = [
|
||||
const filters = ref<QueryModel>({ customerId: '', orderNo: '', inboundCode: '', warehouse: '', fbaWarehouse: '', orderType: '', owner: '', containerNo: '', status: '', consolidated: '', portEta: [] })
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户名称', width: 240, placeholder: '请选择客户', props: { filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||||
{ key: 'orderNo', type: 'input', label: '尾程单号', placeholder: '请输入尾程单号' },
|
||||
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码' },
|
||||
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: [] },
|
||||
{ key: 'fbaWarehouse', type: 'select', label: 'FBA仓库', placeholder: '全部FBA仓库', options: [] },
|
||||
{ key: 'orderType', type: 'select', label: '订单类型', placeholder: '全部订单类型', options: ['一件代发', '头程', '拒收订单', '快递订单'].map((value) => ({ label: value, value })) },
|
||||
{ key: 'owner', type: 'input', label: '货主', placeholder: '请输入货主' },
|
||||
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '接口暂不支持', options: [], props: { disabled: true } },
|
||||
{ key: 'fbaWarehouse', type: 'select', label: 'FBA仓库', placeholder: '接口暂不支持', options: [], props: { disabled: true } },
|
||||
{ key: 'orderType', type: 'select', label: '订单类型', placeholder: '接口暂不支持', options: ['一件代发', '头程', '拒收订单', '快递订单'].map((value) => ({ label: value, value, disabled: true })), props: { disabled: true } },
|
||||
{ key: 'owner', type: 'input', label: '货主', placeholder: '请通过客户名称选择', props: { disabled: true } },
|
||||
{ key: 'containerNo', type: 'input', label: '集装箱号', placeholder: '请输入集装箱号' },
|
||||
{ key: 'status', type: 'select', label: '当前状态', placeholder: '全部状态', options: ['待入库计划', '待清关', '清关完成', '已合托', '待预约', '预约中', '预约成功'].map((value) => ({ label: value, value })) },
|
||||
{ key: 'consolidated', type: 'select', label: '是否合托', placeholder: '全部', options: [{ label: '是', value: 'yes' }, { label: '否', value: 'no' }] },
|
||||
{ key: 'portEta', type: 'daterange', label: '预计到港时间' },
|
||||
]
|
||||
{ key: 'status', type: 'select', label: '当前状态', placeholder: '全部状态', options: [
|
||||
{ label: '待分配', value: 'PENDING_ASSIGNMENT' },
|
||||
{ label: '已分配目的仓', value: 'DESTINATION_ASSIGNED' },
|
||||
{ label: '已生成入库计划', value: 'INBOUND_PLANNED' },
|
||||
] },
|
||||
{ key: 'consolidated', type: 'select', label: '是否合托', placeholder: '接口暂不支持', options: [{ label: '是', value: 'yes', disabled: true }, { label: '否', value: 'no', disabled: true }], props: { disabled: true } },
|
||||
{ key: 'portEta', type: 'daterange', label: '预计到港时间', props: { disabled: true, startPlaceholder: '接口暂不支持', endPlaceholder: '接口暂不支持' } },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 },
|
||||
@@ -62,27 +78,90 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 },
|
||||
]
|
||||
|
||||
async function loadCustomers() {
|
||||
customersLoading.value = true
|
||||
customersError.value = null
|
||||
try {
|
||||
const records: PlatformTenantResource[] = []
|
||||
let currentPage = 1
|
||||
let pages = 1
|
||||
do {
|
||||
const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })
|
||||
records.push(...result.records)
|
||||
pages = Math.max(1, result.pages)
|
||||
currentPage += 1
|
||||
} while (currentPage <= pages)
|
||||
customers.value = records
|
||||
} catch (error) {
|
||||
customersError.value = getApiErrorDetails(error, '客户列表加载失败,请重试')
|
||||
} finally {
|
||||
customersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
const customerId = String(filters.value.customerId || '').trim()
|
||||
if (!customerId) {
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadError.value = null
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const result = await listLastMileOrders({
|
||||
customerId,
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
orderNo: String(filters.value.orderNo || '').trim() || undefined,
|
||||
inboundCode: String(filters.value.inboundCode || '').trim() || undefined,
|
||||
containerNo: String(filters.value.containerNo || '').trim() || undefined,
|
||||
status: statusTab.value === '待分配' ? 'PENDING_ASSIGNMENT' : String(filters.value.status || '').trim() || undefined,
|
||||
})
|
||||
rows.value = result.records.map((item) => toLastMileOrderListItem(item, customerId))
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadError.value = getApiErrorDetails(error, '尾程订单加载失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function changeStatusTab() { page.value = 1; void load() }
|
||||
|
||||
function openDetail(source: object) {
|
||||
const row = source as LastMileOrderListItem
|
||||
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.orderNo } })
|
||||
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id }, query: { customerId: row.customerId } })
|
||||
}
|
||||
|
||||
onMounted(loadCustomers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><ListPageLayout aria-label="尾程订单列表">
|
||||
<template #toolbar>
|
||||
<section class="surface-panel last-mile-toolbar" aria-label="尾程订单状态与筛选">
|
||||
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="page = 1">
|
||||
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="changeStatusTab">
|
||||
<el-radio-button value="全部">全部</el-radio-button>
|
||||
<el-radio-button value="待分配">待分配</el-radio-button>
|
||||
</el-radio-group>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" @search="page = 1" />
|
||||
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" @search="search" @reset="reset" />
|
||||
</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-last-mile-orders" row-key="orderNo" empty-text="海外区域尾程订单查询契约待接入">
|
||||
<template v-if="customersError || loadError" #state>
|
||||
<LoadErrorState title="尾程订单加载失败" :message="(customersError || loadError)?.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()">
|
||||
<template #details><ApiErrorMeta :details="(customersError || loadError)!" /></template>
|
||||
</LoadErrorState>
|
||||
</template>
|
||||
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" :empty-text="filters.customerId ? '暂无尾程订单' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
|
||||
<template #pagination-left>共 0 条尾程订单</template>
|
||||
<template #pagination-left>共 {{ total }} 条尾程订单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -1,18 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getRelabelOrder, type RelabelDetailDto } from '../api/relabelOrders'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const orderId = computed(() => String(route.params.orderId || '—'))
|
||||
const orderId = computed(() => String(route.params.orderId || ''))
|
||||
const customerId = computed(() => String(route.query.customerId || ''))
|
||||
const detail = ref<RelabelDetailDto | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const groups = [
|
||||
{ title: '单据信息', fields: ['换标单号', '关联订单', '状态', '创建时间', '更新时间'] },
|
||||
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '预算总价'] },
|
||||
{ title: '作业要求', fields: ['贴标要求', '上传文件', '备注'] },
|
||||
{ title: '费用明细', fields: ['开箱费', '挑货费', '换标费', '换箱唛标费', '换标费总计'] },
|
||||
]
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||
function status(value: unknown) {
|
||||
const labels: Record<string, string> = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
|
||||
return labels[String(value || '')] || text(value)
|
||||
}
|
||||
const referenceValue = computed(() => text(detail.value?.application.relabelNo || orderId.value))
|
||||
const fieldValues = computed<Record<string, string | number>>(() => {
|
||||
const value = detail.value
|
||||
const items = value?.items || []
|
||||
return {
|
||||
换标单号: text(value?.application.relabelNo),
|
||||
关联订单: text(value?.application.lastlegOrderNo || value?.application.customerOrderNo),
|
||||
状态: status(value?.application.status),
|
||||
创建时间: formatOverseasDateTime(value?.application.createdAt),
|
||||
更新时间: value?.history.length ? formatOverseasDateTime(value.history[value.history.length - 1]?.createdAt) : '—',
|
||||
商品SKU: items.map((item) => text(item.skuCode)).join('、') || '—',
|
||||
中文品名: items.map((item) => text(item.productNameZh)).join('、') || '—',
|
||||
箱数: Number(value?.application.totalPackageCount || 0),
|
||||
预算总价: '—',
|
||||
贴标要求: items.map((item) => text(item.labelRequirement)).join(';') || '—',
|
||||
上传文件: items.map((item) => text(item.relabelFileName)).filter((item) => item !== '—').join('、') || '—',
|
||||
备注: text(value?.remark),
|
||||
开箱费: '—', 挑货费: '—', 换标费: '—', 换箱唛标费: '—', 换标费总计: '—',
|
||||
}
|
||||
})
|
||||
async function load() {
|
||||
if (!orderId.value || !customerId.value) { loadError.value = { message: '详情缺少客户范围,请从换标单列表重新进入' }; return }
|
||||
loading.value = true; loadError.value = null
|
||||
try { detail.value = await getRelabelOrder(orderId.value, customerId.value) }
|
||||
catch (error) { loadError.value = getApiErrorDetails(error, '换标单详情加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function back() { void router.push({ name: 'overseas-relabel-orders' }) }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template><OverseasReadOnlyDetail title="换标单详情" reference-label="换标单号" :reference-value="orderId" back-label="返回换标单" domain="换标单详情" :groups="groups" @back="back" /></template>
|
||||
<template><OverseasReadOnlyDetail title="换标单详情" reference-label="换标单号" :reference-value="referenceValue" back-label="返回换标单" domain="换标单详情" :groups="groups" :field-values="fieldValues" :pending="false" :status-text="detail ? status(detail.application.status) : '加载中'" :loading="loading" :show-content="!loadError" @back="back"><template #state><LoadErrorState v-if="loadError" title="换标单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></OverseasReadOnlyDetail></template>
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Eye, Pencil, Plus, Upload } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import ContractPendingFormDialog from '../components/ContractPendingFormDialog.vue'
|
||||
import { listRelabelOrders } from '../api/relabelOrders'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import type { RelabelOrderListItem } from '../types'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const router = useRouter()
|
||||
const rows = ref<RelabelOrderListItem[]>([])
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const loading = ref(false)
|
||||
const customersLoading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const customersError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = ref<QueryModel>({ sku: '', status: '' })
|
||||
const fields: QueryField[] = [
|
||||
const total = ref(0)
|
||||
const filters = ref<QueryModel>({ customerId: '', sku: '', status: '' })
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户名称', placeholder: '请选择客户', width: 240, props: { filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||||
{ key: 'sku', type: 'input', label: '商品SKU', placeholder: '请输入商品SKU', width: 180 },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: ['待处理', '处理中', '已完成'].map((value) => ({ label: value, value })) },
|
||||
]
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderNo', label: '换标单号', minWidth: 170 },
|
||||
@@ -48,18 +59,50 @@ function openForm(mode: 'create' | 'edit', source?: object) {
|
||||
|
||||
function openDetail(source: object) {
|
||||
const row = source as RelabelOrderListItem
|
||||
void router.push({ name: 'overseas-relabel-order-detail', params: { orderId: String(row.orderNo) } })
|
||||
void router.push({ name: 'overseas-relabel-order-detail', params: { orderId: String(row.orderNo) }, query: { customerId: row.customerId } })
|
||||
}
|
||||
|
||||
function relabelStatus(value: unknown) {
|
||||
const labels: Record<string, string> = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
|
||||
return labels[String(value || '')] || String(value || '—')
|
||||
}
|
||||
async function loadCustomers() {
|
||||
customersLoading.value = true; customersError.value = null
|
||||
try {
|
||||
const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1
|
||||
do { const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages)
|
||||
customers.value = records
|
||||
} catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败,请重试') }
|
||||
finally { customersLoading.value = false }
|
||||
}
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
const customerId = String(filters.value.customerId || '').trim()
|
||||
if (!customerId) { rows.value = []; total.value = 0; loadError.value = null; return }
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const result = await listRelabelOrders({ customerId, page: page.value, size: pageSize.value, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined })
|
||||
rows.value = result.records.map((item) => ({
|
||||
customerId, orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
|
||||
productName: '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, labelRequirement: '—', fileName: '—', status: relabelStatus(item.status), remark: '',
|
||||
}))
|
||||
total.value = result.total
|
||||
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '换标单加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
onMounted(loadCustomers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><ListPageLayout aria-label="换标单列表">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-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-relabel-orders" row-key="orderNo" empty-text="海外区域换标单查询契约待接入">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-query" @search="search" @reset="reset" /></template>
|
||||
<template v-if="customersError || loadError" #state><LoadErrorState title="换标单加载失败" :message="(customersError || loadError)?.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customersError || loadError)!" /></template></LoadErrorState></template>
|
||||
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" :empty-text="filters.customerId ? '暂无换标单' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.relabelOrderCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增换标单</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderEdit)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template>
|
||||
<template #pagination-left>共 0 条换标单</template>
|
||||
<template #pagination-left>共 {{ total }} 条换标单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<ContractPendingFormDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" domain="换标单" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'">
|
||||
|
||||
@@ -104,8 +104,13 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(value).not.toContain('海外派送')
|
||||
})
|
||||
|
||||
it('shows contract-pending empty states instead of demo data or fake success actions', () => {
|
||||
for (const fileName of ['LastMileOrderDetailView.vue', 'InboundOrderDetailView.vue', 'InventoryDetailView.vue']) {
|
||||
it('connects the tail-leg detail to the real overseas API and keeps missing contracts pending', () => {
|
||||
const tailLeg = source('LastMileOrderDetailView.vue')
|
||||
expect(tailLeg).toContain('getLastMileOrder')
|
||||
expect(tailLeg).toContain(':data="cargo"')
|
||||
expect(tailLeg).toContain('<LoadErrorState')
|
||||
expect(tailLeg).not.toContain('ElMessage.success')
|
||||
for (const fileName of ['InboundOrderDetailView.vue', 'InventoryDetailView.vue']) {
|
||||
const value = source(fileName)
|
||||
expect(value).toContain(':data="[]"')
|
||||
expect(value).toContain('契约待接入')
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* identifiers, timestamps, money precision, or mutation request contracts.
|
||||
*/
|
||||
export interface LastMileOrderListItem {
|
||||
id: string
|
||||
customerId: string
|
||||
orderNo: string
|
||||
warning: string
|
||||
orderType: string
|
||||
@@ -71,12 +73,13 @@ export interface InventoryListItem {
|
||||
}
|
||||
|
||||
export interface RelabelOrderListItem {
|
||||
customerId: string
|
||||
orderNo: string
|
||||
relatedOrderNo: string
|
||||
sku: string
|
||||
productName: string
|
||||
cartons: number
|
||||
budgetTotal: number
|
||||
budgetTotal: number | null
|
||||
labelRequirement: string
|
||||
relabelCost?: number
|
||||
fileName: string
|
||||
|
||||
@@ -17,7 +17,11 @@ const query = {
|
||||
trackingType: 'CONTAINER_NO',
|
||||
trackingNo: 'CSNU1234567',
|
||||
status: 'MANUAL_REQUIRED',
|
||||
accessMode: 'DYNAMIC_IFRAME',
|
||||
attemptCount: '2',
|
||||
lastAttemptStatus: 'MANUAL_REQUIRED',
|
||||
lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED',
|
||||
lastErrorMessage: '必须人工查询',
|
||||
resultPortScheduleId: 404,
|
||||
version: '3',
|
||||
}
|
||||
@@ -29,7 +33,7 @@ describe('platformCarrierScheduleQueries', () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ query, expired: true, maxAttempts: '3', nextAllowedAt: null }], page: '1', size: '200', total: '1', pages: '1' } })
|
||||
const result = await listCarrierScheduleQueries({ page: 1, size: 200, oceanBookingId: '202', containerBookingId: '303' })
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries', { params: { page: 1, size: 200, oceanBookingId: '202', containerBookingId: '303' } })
|
||||
expect(result.records[0]).toMatchObject({ query: { id: '9007199254740993', customerId: '101', oceanBookingId: '202', containerBookingId: '303', resultPortScheduleId: '404', attemptCount: 2, version: 3 }, expired: true, maxAttempts: 3 })
|
||||
expect(result.records[0]).toMatchObject({ query: { id: '9007199254740993', customerId: '101', oceanBookingId: '202', containerBookingId: '303', resultPortScheduleId: '404', accessMode: 'DYNAMIC_IFRAME', attemptCount: 2, lastAttemptStatus: 'MANUAL_REQUIRED', lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询', version: 3 }, expired: true, maxAttempts: 3 })
|
||||
})
|
||||
|
||||
it('normalizes attempts and immutable history without creating query operations', async () => {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import CarrierScheduleQueryPanel from './CarrierScheduleQueryPanel.vue'
|
||||
import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
|
||||
import { getPortSchedule } from '../api/platformPortSchedules'
|
||||
|
||||
vi.mock('../api/platformCarrierScheduleQueries', () => ({
|
||||
listCarrierScheduleQueries: vi.fn(),
|
||||
getCarrierScheduleQuery: vi.fn(),
|
||||
}))
|
||||
vi.mock('../api/platformPortSchedules', () => ({ getPortSchedule: vi.fn() }))
|
||||
|
||||
const queryView = {
|
||||
query: {
|
||||
id: '11', queryNo: 'CSQ11', customerId: '1', oceanBookingId: '2', containerBookingId: '3',
|
||||
carrierCode: 'COSCO', trackingType: 'CONTAINER_NO', trackingNo: 'CSNU1234567',
|
||||
officialUrl: 'https://elines.coscoshipping.com/ebusiness/', accessMode: 'DYNAMIC_IFRAME',
|
||||
status: 'MANUAL_REQUIRED' as const, attemptCount: 2, lastAttemptStatus: 'MANUAL_REQUIRED',
|
||||
lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询',
|
||||
resultPortScheduleId: '21', version: 1,
|
||||
},
|
||||
expired: true,
|
||||
maxAttempts: 3,
|
||||
nextAllowedAt: '2026-08-22T10:00:00',
|
||||
}
|
||||
|
||||
describe('CarrierScheduleQueryPanel loading states', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('keeps list facts visible when history and linked schedule both fail', async () => {
|
||||
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [queryView], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(getCarrierScheduleQuery).mockRejectedValue(new Error('detail unavailable'))
|
||||
vi.mocked(getPortSchedule).mockRejectedValue(new Error('schedule unavailable'))
|
||||
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(listCarrierScheduleQueries).toHaveBeenCalledWith({ page: 1, size: 200, oceanBookingId: '2', containerBookingId: '3' })
|
||||
expect(getCarrierScheduleQuery).toHaveBeenCalledWith('11')
|
||||
expect(getPortSchedule).toHaveBeenCalledWith('21')
|
||||
expect(wrapper.text()).toContain('CSQ11')
|
||||
expect(wrapper.text()).toContain('查询尝试与变更历史暂不可用')
|
||||
expect(wrapper.text()).toContain('关联船期结果暂不可用')
|
||||
expect(wrapper.text()).toContain('DYNAMIC_IFRAME_REQUIRED')
|
||||
})
|
||||
|
||||
it('shows an explicit non-fabricated empty state and performs no detail reads', async () => {
|
||||
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('不会模拟节点或自动创建任务')
|
||||
expect(getCarrierScheduleQuery).not.toHaveBeenCalled()
|
||||
expect(getPortSchedule).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -15,15 +15,25 @@ describe('CarrierScheduleQueryPanel', () => {
|
||||
|
||||
it('shows source, observation/update times, history, failures and expiry truthfully', () => {
|
||||
expect(source).toContain('最新来源')
|
||||
expect(source).toContain('访问方式')
|
||||
expect(source).toContain('查询次数')
|
||||
expect(source).toContain('观测时间')
|
||||
expect(source).toContain('来源更新时间')
|
||||
expect(source).toContain('任务更新时间')
|
||||
expect(source).toContain('查询尝试')
|
||||
expect(source).toContain('变更历史')
|
||||
expect(source).toContain('query.lastErrorMessage')
|
||||
expect(source).toContain('query.lastErrorCode')
|
||||
expect(source).toContain("return '结果已过期'")
|
||||
})
|
||||
|
||||
it('keeps query-history and linked-schedule partial failures independently visible', () => {
|
||||
expect(source).toContain('queryDetailError')
|
||||
expect(source).toContain('scheduleError')
|
||||
expect(source).toContain('查询尝试与变更历史暂不可用')
|
||||
expect(source).toContain('关联船期结果暂不可用')
|
||||
})
|
||||
|
||||
it('keeps the missing node-event contract explicit instead of fabricating data', () => {
|
||||
expect(source).toContain('待接口提供独立节点事实')
|
||||
expect(source).toContain('不会模拟节点或自动创建任务')
|
||||
|
||||
@@ -10,7 +10,8 @@ import type { CarrierScheduleQueryDetail, CarrierScheduleQueryView, PortSchedule
|
||||
const props = defineProps<{ oceanBookingId?: string | null; containerBookingId?: string | null; portScheduleId?: string | null }>()
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const queryDetailError = ref<ApiErrorDetails | null>(null)
|
||||
const scheduleError = ref<ApiErrorDetails | null>(null)
|
||||
const selected = ref<CarrierScheduleQueryView | null>(null)
|
||||
const detail = ref<CarrierScheduleQueryDetail | null>(null)
|
||||
const latestSchedule = ref<PortScheduleView | null>(null)
|
||||
@@ -30,12 +31,13 @@ const histories = computed(() => [...(detail.value?.histories || [])].reverse())
|
||||
const attempts = computed(() => [...(detail.value?.attempts || [])].reverse())
|
||||
|
||||
function formatTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function trackingTypeText(value: string) { return ({ BOOKING_NO: '订舱号', BILL_OF_LADING: '提单号', CONTAINER_NO: '集装箱号' } as Record<string, string>)[value] || value }
|
||||
function attemptText(status: string) { return ({ SUCCESS: '成功', FAILED: '失败', MANUAL_REQUIRED: '需人工补充' } as Record<string, string>)[status] || status }
|
||||
function historyText(action: string) { return ({ CREATE: '建立查询任务', ATTEMPT: '执行查询', MANUAL_RESULT: '关联人工船期结果' } as Record<string, string>)[action] || action }
|
||||
function latestTimestamp(item: CarrierScheduleQueryView) { return Date.parse(item.query.updatedAt || item.query.createdAt || '') || 0 }
|
||||
|
||||
async function load() {
|
||||
selected.value = null; detail.value = null; latestSchedule.value = null; error.value = null; detailError.value = null
|
||||
selected.value = null; detail.value = null; latestSchedule.value = null; error.value = null; queryDetailError.value = null; scheduleError.value = null
|
||||
if (!props.oceanBookingId && !props.containerBookingId) return
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -43,15 +45,14 @@ async function load() {
|
||||
const matching = props.portScheduleId ? page.records.filter((item) => item.query.resultPortScheduleId === String(props.portScheduleId)) : page.records
|
||||
selected.value = [...matching].sort((left, right) => latestTimestamp(right) - latestTimestamp(left))[0] || null
|
||||
if (!selected.value) return
|
||||
const tasks: [Promise<CarrierScheduleQueryDetail>, Promise<{ portSchedule: PortScheduleView } | null>] = [
|
||||
getCarrierScheduleQuery(selected.value.query.id),
|
||||
selected.value.query.resultPortScheduleId ? getPortSchedule(selected.value.query.resultPortScheduleId) : Promise.resolve(null),
|
||||
]
|
||||
const [queryResult, scheduleResult] = await Promise.allSettled(tasks)
|
||||
const [queryResult] = await Promise.allSettled([getCarrierScheduleQuery(selected.value.query.id)])
|
||||
if (queryResult.status === 'fulfilled') detail.value = queryResult.value
|
||||
else detailError.value = getApiErrorDetails(queryResult.reason, '查询历史加载失败')
|
||||
if (scheduleResult.status === 'fulfilled') latestSchedule.value = scheduleResult.value?.portSchedule || null
|
||||
else detailError.value = getApiErrorDetails(scheduleResult.reason, '最新船期结果加载失败')
|
||||
else queryDetailError.value = getApiErrorDetails(queryResult.reason, '查询历史加载失败')
|
||||
const resultPortScheduleId = detail.value?.query.query.resultPortScheduleId || selected.value.query.resultPortScheduleId
|
||||
if (resultPortScheduleId) {
|
||||
try { latestSchedule.value = (await getPortSchedule(resultPortScheduleId)).portSchedule }
|
||||
catch (cause) { scheduleError.value = getApiErrorDetails(cause, '最新船期结果加载失败') }
|
||||
}
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '船节点查询状态加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
@@ -67,14 +68,17 @@ watch(() => [props.oceanBookingId, props.containerBookingId, props.portScheduleI
|
||||
<el-empty v-else-if="!query" :image-size="52" description="尚未建立关联的船节点查询任务;当前页面不会模拟节点或自动创建任务。" />
|
||||
<template v-else>
|
||||
<el-alert v-if="query.lastErrorMessage || query.status === 'MANUAL_REQUIRED'" type="error" :closable="false" show-icon :title="query.lastErrorMessage || query.manualReason || '自动查询暂不可用,需要人工补充可靠结果。'" />
|
||||
<el-alert v-if="detailError" type="warning" :closable="false" show-icon title="部分查询详情暂不可用"><template #default><ApiErrorMeta :details="detailError" /></template></el-alert>
|
||||
<el-alert v-if="queryDetailError" type="warning" :closable="false" show-icon title="查询尝试与变更历史暂不可用"><template #default><ApiErrorMeta :details="queryDetailError" /></template></el-alert>
|
||||
<el-alert v-if="scheduleError" type="warning" :closable="false" show-icon title="关联船期结果暂不可用"><template #default><ApiErrorMeta :details="scheduleError" /></template></el-alert>
|
||||
<dl class="query-facts">
|
||||
<div><dt>查询任务</dt><dd>{{ query.queryNo || query.id }}</dd></div><div><dt>跟踪标识</dt><dd>{{ query.trackingNo || '—' }}</dd></div>
|
||||
<div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div><div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div>
|
||||
<div><dt>查询任务</dt><dd>{{ query.queryNo || query.id }}</dd></div><div><dt>跟踪标识</dt><dd>{{ trackingTypeText(query.trackingType) }} · {{ query.trackingNo || '—' }}</dd></div>
|
||||
<div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div><div><dt>访问方式</dt><dd>{{ query.accessMode || '—' }}</dd></div>
|
||||
<div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div><div><dt>查询次数</dt><dd>{{ query.attemptCount }} / {{ queryView?.maxAttempts ?? '—' }}</dd></div>
|
||||
<div><dt>最新节点</dt><dd>{{ latestSchedule ? '船期快照已更新' : '待接口提供独立节点事实' }}</dd></div><div><dt>观测时间</dt><dd>{{ formatTime(latestObservation) }}</dd></div>
|
||||
<div><dt>来源更新时间</dt><dd>{{ formatTime(query.sourceUpdatedAt) }}</dd></div><div><dt>任务更新时间</dt><dd>{{ formatTime(query.updatedAt) }}</dd></div>
|
||||
<div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div><div><dt>最近尝试</dt><dd>{{ formatTime(query.lastAttemptAt) }}</dd></div>
|
||||
<div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div><div><dt>最近尝试</dt><dd>{{ formatTime(query.lastAttemptAt) }} · {{ query.lastAttemptStatus ? attemptText(query.lastAttemptStatus) : '—' }}</dd></div>
|
||||
<div><dt>结果过期时间</dt><dd>{{ formatTime(query.expiresAt) }}</dd></div><div><dt>下次允许查询</dt><dd>{{ formatTime(queryView?.nextAllowedAt) }}</dd></div>
|
||||
<div v-if="query.lastErrorCode"><dt>最近错误码</dt><dd>{{ query.lastErrorCode }}</dd></div>
|
||||
</dl>
|
||||
<div v-if="latestSchedule" class="latest-result"><strong>{{ latestSchedule.schedule.vesselName }} / {{ latestSchedule.schedule.voyageNo }}</strong><span>{{ latestSchedule.schedule.departurePortName || latestSchedule.schedule.departurePortCode }} → {{ latestSchedule.schedule.destinationPortName || latestSchedule.schedule.destinationPortCode }}</span><span>预计开船 {{ formatTime(latestSchedule.schedule.etdAt) }} · 预计到港 {{ formatTime(latestSchedule.schedule.etaAt) }}</span></div>
|
||||
<div class="history-grid">
|
||||
|
||||
@@ -50,6 +50,8 @@ describe('TruckAppointmentForm interaction contract', () => {
|
||||
it('keeps booking, cabinet and loading plan optional and only uses them for completion hints', () => {
|
||||
expect(source).toContain('订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约')
|
||||
expect(source).toContain('class="relation-note"')
|
||||
expect(source).toContain('<slot name="relation-note-actions" />')
|
||||
expect(source).toContain('justify-content:space-between')
|
||||
expect(source).not.toContain('class="relation-alert">历史关联快照')
|
||||
expect(source).toContain('<el-divider content-position="left">可选关联</el-divider>')
|
||||
expect(source).toContain('label="关联柜次(可选)"')
|
||||
|
||||
@@ -121,7 +121,7 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
|
||||
|
||||
<template>
|
||||
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
|
||||
<p class="relation-note">订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约;选择后仅用于补全快照和一致性检查。</p>
|
||||
<div class="relation-note"><span>订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约;选择后仅用于补全快照和一致性检查。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
|
||||
<el-alert v-if="relationWarning" type="warning" :closable="false" show-icon :title="relationWarning" class="relation-alert" />
|
||||
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
|
||||
<el-row :gutter="16">
|
||||
@@ -168,7 +168,10 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
|
||||
|
||||
<style scoped>
|
||||
.truck-form { padding-bottom: 4px; }
|
||||
.relation-note { margin:0 0 8px; padding:8px 10px; border-left:3px solid var(--cc-primary); background:var(--el-fill-color-extra-light); color:var(--cc-muted); font-size:12px; line-height:1.5; }
|
||||
.relation-note { display:flex; min-height:40px; align-items:center; justify-content:space-between; gap:16px; margin:0 0 8px; padding:6px 8px 6px 10px; border-left:3px solid var(--cc-primary); background:var(--el-fill-color-extra-light); color:var(--cc-muted); font-size:12px; line-height:1.5; }
|
||||
.relation-note > span { min-width:0; }
|
||||
.relation-note__actions { display:flex; flex:0 0 auto; align-items:center; }
|
||||
.relation-note__actions :deep(.el-button) { margin-left:0; }
|
||||
.relation-alert { margin-bottom: 16px; }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
:deep(.el-divider) { margin: 14px 0 10px; }
|
||||
@@ -177,6 +180,8 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
|
||||
.field-hint { margin-top:4px; color:var(--cc-muted); font-size:12px; }
|
||||
@media (max-width: 480px) {
|
||||
.truck-form { padding-inline: 2px; }
|
||||
.relation-note { align-items:flex-start; flex-direction:column; }
|
||||
.relation-note__actions { width:100%; }
|
||||
.form-actions { position: sticky; bottom: 0; z-index: 2; margin: 8px -2px -4px; padding: 10px 2px; background: var(--el-bg-color); border-top: 1px solid var(--el-border-color-lighter); }
|
||||
.form-actions :deep(.el-button) { flex: 1; min-height: 40px; }
|
||||
}
|
||||
|
||||
@@ -17,4 +17,11 @@ describe('TruckAppointmentCreateView independent creation contract', () => {
|
||||
expect(source).toContain('<ApiErrorMeta :details="error" />')
|
||||
expect(source).toContain('<TruckAppointmentForm v-model="form"')
|
||||
})
|
||||
|
||||
it('places the return action beside the relation note instead of in a separate header row', () => {
|
||||
expect(source).toContain('<template #relation-note-actions>')
|
||||
expect(source).toContain('<ArrowLeft :size="16" />返回列表')
|
||||
expect(source).not.toContain('<template #header>')
|
||||
expect(source).not.toContain('class="editor-actions"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,11 +19,10 @@ async function submit() { saving.value = true; error.value = null; try { await c
|
||||
<template>
|
||||
<AdminPage as="div" class="truck-editor-page">
|
||||
<DetailPageLayout aria-label="新增拖车预约">
|
||||
<template #header><div class="editor-actions"><el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button></div></template>
|
||||
<LoadErrorState v-if="error" title="拖车预约创建失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section>
|
||||
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true"><template #relation-note-actions><el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button></template></TruckAppointmentForm></section>
|
||||
<TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" />
|
||||
</DetailPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>.truck-editor-page { min-width: 0; }.editor-actions { display:flex; justify-content:flex-end; }.form-panel { padding:24px; } @media (max-width:720px) { .editor-actions { justify-content:flex-start; }.form-panel { padding:16px; } }</style>
|
||||
<style scoped>.truck-editor-page { min-width: 0; }.form-panel { padding:24px; } @media (max-width:720px) { .form-panel { padding:16px; } }</style>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# 海外合作仓类型契约缺口
|
||||
|
||||
四期原型把海外合作仓作为现有仓库主数据中的一种仓库类型,而不是独立库存领域。
|
||||
|
||||
- 建议类型码:`OVERSEAS_PARTNER`。
|
||||
- 当前状态:仅为前端与后端对齐用的建议值,尚未生效。
|
||||
- 当前 Warehouse Service 的 DTO、校验与 OpenAPI 只接受 `DOMESTIC`、`FBA`、`OVERSEAS`。
|
||||
- 在服务端正式接受并返回合作仓类型前,Admin 不得把合作仓按 `OVERSEAS` 创建或更新,也不新增一套重复的合作仓列表。
|
||||
- 合作仓列表和详情继续复用 `platform:warehouse:list`;未来创建、编辑、启停分别复用现有 `platform:warehouse:create`、`platform:warehouse:edit`、`platform:warehouse:status`。
|
||||
- 服务端契约补齐时需同步核对仓库 DTO、Controller、OpenAPI、初始化 SQL、导入模板及全部消费者。
|
||||
|
||||
@@ -110,6 +110,34 @@ describe('platform warehouse API', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the confirmed overseas partner warehouse type through list and save contracts', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({
|
||||
code: '200',
|
||||
data: { records: [], page: 1, size: 20, total: 0, pages: 0 },
|
||||
})
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: '19020' })
|
||||
const partnerPayload: SavePlatformWarehouseRequest = {
|
||||
...payload,
|
||||
warehouseCode: 'PARTNER-US-01',
|
||||
warehouseName: '洛杉矶合作仓',
|
||||
warehouseType: 'OVERSEAS_PARTNER',
|
||||
countryCode: 'US',
|
||||
timezone: 'UTC-8',
|
||||
address: { ...payload.address, countryCode: 'US', stateProvince: 'CA', city: 'Los Angeles' },
|
||||
}
|
||||
|
||||
await listPlatformWarehouses({ page: 1, size: 20, warehouseType: 'OVERSEAS_PARTNER' })
|
||||
await expect(createPlatformWarehouse(partnerPayload)).resolves.toBe('19020')
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin', {
|
||||
params: { page: 1, size: 20, warehouseType: 'OVERSEAS_PARTNER' },
|
||||
})
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin', {
|
||||
...partnerPayload,
|
||||
tenantId: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('loads every warehouse page for complete resource selectors', async () => {
|
||||
const firstRecord = { id: '1', warehouseCode: 'ABE2', warehouseType: 'FBA' }
|
||||
const domesticRecord = { id: '201', warehouseCode: 'CN-YIWU-01', warehouseType: 'DOMESTIC' }
|
||||
|
||||
@@ -2,7 +2,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type PlatformWarehouseStatus = 'ACTIVE' | 'INACTIVE'
|
||||
|
||||
export type WarehouseType = 'DOMESTIC' | 'FBA' | 'OVERSEAS'
|
||||
export type WarehouseType = 'DOMESTIC' | 'FBA' | 'OVERSEAS' | 'OVERSEAS_PARTNER'
|
||||
|
||||
export interface PlatformWarehouseResource {
|
||||
id: number | string
|
||||
|
||||
@@ -126,6 +126,20 @@ const fbaDetail: PlatformWarehouseDetailResource = {
|
||||
capabilities: [],
|
||||
}
|
||||
|
||||
const partnerWarehouse: PlatformWarehouseSummaryResource = {
|
||||
...fbaWarehouse,
|
||||
id: '19021',
|
||||
warehouseCode: 'PARTNER-US-01',
|
||||
warehouseName: '洛杉矶合作仓',
|
||||
warehouseType: 'OVERSEAS_PARTNER',
|
||||
}
|
||||
|
||||
const partnerDetail: PlatformWarehouseDetailResource = {
|
||||
warehouse: partnerWarehouse,
|
||||
addresses: [{ ...fbaAddress, id: '19022', warehouseId: '19021', stateProvince: 'CA', city: 'Los Angeles' }],
|
||||
capabilities: [],
|
||||
}
|
||||
|
||||
const detail: PlatformWarehouseDetailResource = {
|
||||
warehouse: domesticWarehouse,
|
||||
addresses: [domesticAddress],
|
||||
@@ -413,6 +427,69 @@ describe('WarehousesView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('creates and filters the confirmed overseas partner warehouse type through the existing warehouse page', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
wrapper.findComponent(QueryToolbar).vm.$emit('search', {
|
||||
keyword: '',
|
||||
warehouseType: 'OVERSEAS_PARTNER',
|
||||
status: 'ACTIVE',
|
||||
})
|
||||
await flushPromises()
|
||||
expect(listPlatformWarehouses).toHaveBeenLastCalledWith({
|
||||
page: 1,
|
||||
size: 20,
|
||||
keyword: undefined,
|
||||
warehouseType: 'OVERSEAS_PARTNER',
|
||||
status: 'ACTIVE',
|
||||
})
|
||||
|
||||
wrapper.findComponent({ name: 'ElDropdown' }).vm.$emit('command', 'OVERSEAS_PARTNER')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.warehouse-editor-dialog')?.textContent).toContain('新增海外仓合作仓')
|
||||
|
||||
const stateSelect = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
.find((select) => select.props('placeholder') === '请选择州')
|
||||
setField('input[placeholder="请输入仓库名称"]', '洛杉矶合作仓')
|
||||
stateSelect!.vm.$emit('update:modelValue', 'CA')
|
||||
stateSelect!.vm.$emit('change', 'CA')
|
||||
setField('input[placeholder="例如 Long Beach"]', 'Los Angeles')
|
||||
setField('textarea[placeholder="请输入街道、园区、楼栋及门牌信息"]', '100 Partner Ave')
|
||||
|
||||
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认新增')
|
||||
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(createPlatformWarehouse).toHaveBeenCalledWith(expect.objectContaining({
|
||||
warehouseCode: expect.stringMatching(/^PARTNER-WH-/),
|
||||
warehouseName: '洛杉矶合作仓',
|
||||
warehouseType: 'OVERSEAS_PARTNER',
|
||||
countryCode: 'US',
|
||||
timezone: 'UTC-8',
|
||||
address: expect.objectContaining({ stateProvince: 'CA', city: 'Los Angeles' }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('edits an existing overseas partner warehouse without converting it to an ordinary overseas warehouse', async () => {
|
||||
vi.mocked(listPlatformWarehouses).mockResolvedValueOnce({ records: [partnerWarehouse], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformWarehouse).mockResolvedValueOnce(partnerDetail)
|
||||
const wrapper = await mountView()
|
||||
|
||||
await wrapper.find('.el-table__body-wrapper .table-actions button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.warehouse-editor-dialog')?.textContent).toContain('编辑海外仓合作仓')
|
||||
|
||||
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '保存修改')
|
||||
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(updatePlatformWarehouse).toHaveBeenCalledWith('19021', expect.objectContaining({
|
||||
warehouseCode: 'PARTNER-US-01',
|
||||
warehouseName: '洛杉矶合作仓',
|
||||
warehouseType: 'OVERSEAS_PARTNER',
|
||||
}))
|
||||
})
|
||||
|
||||
it('edits FBA warehouses with selectors for country, timezone and state', async () => {
|
||||
vi.mocked(listPlatformWarehouses).mockResolvedValueOnce({ records: [fbaWarehouse, domesticWarehouse], page: 1, size: 20, total: 2, pages: 1 })
|
||||
vi.mocked(getPlatformWarehouse).mockResolvedValueOnce(fbaDetail)
|
||||
|
||||
@@ -34,6 +34,7 @@ const warehouseTypeLabels: Record<WarehouseType, string> = {
|
||||
DOMESTIC: '国内收货仓',
|
||||
FBA: 'FBA 地址',
|
||||
OVERSEAS: '海外仓',
|
||||
OVERSEAS_PARTNER: '海外仓合作仓',
|
||||
}
|
||||
const timezoneOptions = [
|
||||
'UTC-12', 'UTC-11', 'UTC-10', 'UTC-9', 'UTC-8', 'UTC-7', 'UTC-6', 'UTC-5', 'UTC-4', 'UTC-3',
|
||||
@@ -145,6 +146,7 @@ const queryFields: QueryField[] = [
|
||||
{ label: warehouseTypeLabels.DOMESTIC, value: 'DOMESTIC' },
|
||||
{ label: warehouseTypeLabels.FBA, value: 'FBA' },
|
||||
{ label: warehouseTypeLabels.OVERSEAS, value: 'OVERSEAS' },
|
||||
{ label: warehouseTypeLabels.OVERSEAS_PARTNER, value: 'OVERSEAS_PARTNER' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -188,7 +190,7 @@ type WarehouseForm = Omit<Warehouse, 'updatedAt'> & {
|
||||
}
|
||||
|
||||
function generatedCode(type: WarehouseType) {
|
||||
const prefix = type === 'DOMESTIC' ? 'CN' : type === 'FBA' ? 'FBA' : 'US'
|
||||
const prefix = type === 'DOMESTIC' ? 'CN' : type === 'FBA' ? 'FBA' : type === 'OVERSEAS_PARTNER' ? 'PARTNER' : 'US'
|
||||
return `${prefix}-WH-${Date.now().toString(36).slice(-7).toUpperCase()}`
|
||||
}
|
||||
|
||||
@@ -708,7 +710,7 @@ onMounted(() => void loadWarehouses())
|
||||
<el-button :disabled="!warehouseReady" @click="openImport"><FileUp :size="16" />导入仓库</el-button>
|
||||
<el-dropdown trigger="click" :disabled="!warehouseReady" @command="openCreate($event as WarehouseType)">
|
||||
<el-button type="primary" :disabled="!warehouseReady"><Plus :size="16" />新增仓库<ChevronDown :size="15" /></el-button>
|
||||
<template #dropdown><el-dropdown-menu><el-dropdown-item command="FBA"><FileSpreadsheet :size="15" />新增 FBA 地址</el-dropdown-item><el-dropdown-item command="DOMESTIC"><WarehouseIcon :size="15" />新增国内仓</el-dropdown-item><el-dropdown-item command="OVERSEAS"><MapPin :size="15" />新增海外仓</el-dropdown-item></el-dropdown-menu></template>
|
||||
<template #dropdown><el-dropdown-menu><el-dropdown-item command="FBA"><FileSpreadsheet :size="15" />新增 FBA 地址</el-dropdown-item><el-dropdown-item command="DOMESTIC"><WarehouseIcon :size="15" />新增国内仓</el-dropdown-item><el-dropdown-item command="OVERSEAS"><MapPin :size="15" />新增海外仓</el-dropdown-item><el-dropdown-item command="OVERSEAS_PARTNER"><MapPin :size="15" />新增海外仓合作仓</el-dropdown-item></el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Admin Shared API Instructions
|
||||
|
||||
## 适用范围与入口
|
||||
|
||||
- 本文件适用于 `src/shared/api/` 及其测试。业务模块只通过领域 `api/` 适配层调用这里的客户端,不在页面和组件直接创建 Axios 实例。
|
||||
- 国内 API 客户端为 `http.ts`;海外 API 客户端为 `overseasHttp.ts`。认证会话编排在 `../../core/auth/auth.ts`,不得在本目录复制登录状态。
|
||||
|
||||
## 国内与海外请求
|
||||
|
||||
- `http` 使用 `VITE_API_BASE_URL`,默认走国内代理;国内登录、刷新和退出仍由统一 Auth 处理。
|
||||
- `overseasHttp` 使用 `VITE_OVERSEAS_API_BASE_URL`,默认 `/overseas-api`;Vite 将该前缀改写为海外 Gateway 的 `/api`。代理上游由开发环境配置,不在源码或指引中记录真实地址。
|
||||
- 登录和刷新响应一次性返回国内 Token、`overseasAccessToken`、`overseasExpiresIn`;`core/auth/auth.ts` 必须原子替换两套访问凭据。不得恢复 Token 兑换端点或让业务模块单独刷新海外 Token。
|
||||
- 海外 Token 只由 `replaceOverseasAccessToken`、`clearOverseasAccessToken` 管理;业务模块不得读写缓存、LocalStorage 或手工拼装 `Authorization`。
|
||||
- 国内、海外 401 共用统一会话刷新编排。海外请求最多统一刷新一次并重放一次;刷新失败按会话失效处理,不循环重试或回退假数据。
|
||||
|
||||
## 领域调用边界
|
||||
|
||||
- 国内业务领域 API 使用 `http`;四期海外尾程、海外仓资源和海外本土业务使用 `overseasHttp`,不得经国内 Gateway 中转。
|
||||
- 页面只调用所属模块的 API 封装;模块 API 负责路径、DTO 和错误适配,不把 Axios 响应或区域选择泄漏到页面。
|
||||
- 跨区域 Token、Audience、JWKS 和服务通信公共契约读取 `../../../../../docs/跨区域认证与服务通信契约.md`。
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 修改客户端、拦截器、刷新、Token 生命周期或 Base URL 时,联查 `http.test.ts`、`overseasHttp.test.ts`、`core/auth/auth.test.ts`、Vite 代理、`.env.example` 和全部实际消费者。
|
||||
- 至少运行相关 API/Auth 测试和 `pnpm typecheck`;交付前按项目指引运行完整测试与构建。
|
||||
+18
-13
@@ -61,6 +61,23 @@ export function registerAuthSessionExpiredHandler(handler: AuthSessionExpiredHan
|
||||
authSessionExpiredHandler = handler
|
||||
}
|
||||
|
||||
export async function refreshAuthenticatedSession(): Promise<string> {
|
||||
if (!authRefreshHandler) throw new Error('认证刷新处理器尚未初始化')
|
||||
if (!activeRefresh) {
|
||||
activeRefresh = authRefreshHandler()
|
||||
.catch(async (refreshError) => {
|
||||
try {
|
||||
await authSessionExpiredHandler?.()
|
||||
} catch {
|
||||
// 会话已清理,跳转失败不应覆盖原始认证错误。
|
||||
}
|
||||
throw refreshError
|
||||
})
|
||||
.finally(() => { activeRefresh = null })
|
||||
}
|
||||
return activeRefresh
|
||||
}
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('cc-admin-token')
|
||||
const isAnonymousAuthEndpoint = typeof config.url === 'string'
|
||||
@@ -82,19 +99,7 @@ http.interceptors.response.use(
|
||||
|
||||
config._authRetry = true
|
||||
try {
|
||||
if (!activeRefresh) {
|
||||
activeRefresh = authRefreshHandler()
|
||||
.catch(async (refreshError) => {
|
||||
try {
|
||||
await authSessionExpiredHandler?.()
|
||||
} catch {
|
||||
// 会话已清理,跳转失败不应覆盖原始认证错误。
|
||||
}
|
||||
throw refreshError
|
||||
})
|
||||
.finally(() => { activeRefresh = null })
|
||||
}
|
||||
const nextToken = await activeRefresh
|
||||
const nextToken = await refreshAuthenticatedSession()
|
||||
config.headers.Authorization = `Bearer ${nextToken}`
|
||||
return http.request(config)
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import axios, { type InternalAxiosRequestConfig } from 'axios'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { registerAuthRefreshHandler } from './http'
|
||||
import { clearOverseasAccessToken, overseasHttp, replaceOverseasAccessToken } from './overseasHttp'
|
||||
|
||||
const originalOverseasAdapter = overseasHttp.defaults.adapter
|
||||
|
||||
function success(config: InternalAxiosRequestConfig, data: unknown) {
|
||||
return Promise.resolve({ status: 200, statusText: 'OK', headers: {}, config, data })
|
||||
}
|
||||
|
||||
describe('overseas HTTP client', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('cc-admin-token', 'domestic-token')
|
||||
clearOverseasAccessToken()
|
||||
registerAuthRefreshHandler(async () => 'domestic-token')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
overseasHttp.defaults.adapter = originalOverseasAdapter
|
||||
localStorage.clear()
|
||||
clearOverseasAccessToken()
|
||||
})
|
||||
|
||||
it('uses the overseas token supplied by the login session for concurrent requests', async () => {
|
||||
replaceOverseasAccessToken('overseas-token', 900)
|
||||
const authorizations: unknown[] = []
|
||||
overseasHttp.defaults.adapter = async (config) => {
|
||||
authorizations.push(config.headers.Authorization)
|
||||
return success(config, { code: '200', message: 'success', data: [] })
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
overseasHttp.get('/platform/v1/last-leg/orders'),
|
||||
overseasHttp.get('/platform/v1/warehouse-admin'),
|
||||
])
|
||||
|
||||
expect(authorizations).toEqual(['Bearer overseas-token', 'Bearer overseas-token'])
|
||||
})
|
||||
|
||||
it('refreshes the unified session once and replays once after an overseas 401', async () => {
|
||||
replaceOverseasAccessToken('overseas-1', 900)
|
||||
const refresh = vi.fn(async () => {
|
||||
replaceOverseasAccessToken('overseas-2', 900)
|
||||
return 'domestic-2'
|
||||
})
|
||||
registerAuthRefreshHandler(refresh)
|
||||
let requests = 0
|
||||
const authorizations: unknown[] = []
|
||||
overseasHttp.defaults.adapter = async (config) => {
|
||||
authorizations.push(config.headers.Authorization)
|
||||
if (requests++ === 0) {
|
||||
throw new axios.AxiosError('Unauthorized', 'ERR_BAD_REQUEST', config, undefined, {
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
headers: {},
|
||||
config,
|
||||
data: { code: 'A0401', message: '海外令牌失效' },
|
||||
})
|
||||
}
|
||||
return success(config, { code: '200', message: 'success', data: [] })
|
||||
}
|
||||
|
||||
await overseasHttp.get('/platform/v1/last-leg/orders')
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(requests).toBe(2)
|
||||
expect(authorizations).toEqual(['Bearer overseas-1', 'Bearer overseas-2'])
|
||||
})
|
||||
|
||||
it('refreshes the unified session when no usable overseas token is present', async () => {
|
||||
const refresh = vi.fn(async () => {
|
||||
replaceOverseasAccessToken('overseas-refreshed', 900)
|
||||
return 'domestic-refreshed'
|
||||
})
|
||||
registerAuthRefreshHandler(refresh)
|
||||
let authorization: unknown
|
||||
overseasHttp.defaults.adapter = async (config) => {
|
||||
authorization = config.headers.Authorization
|
||||
return success(config, { code: '200', message: 'success', data: [] })
|
||||
}
|
||||
|
||||
await overseasHttp.get('/platform/v1/last-leg/orders')
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(authorization).toBe('Bearer overseas-refreshed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import axios, { type InternalAxiosRequestConfig } from 'axios'
|
||||
import { parseApiJsonResponse } from '@chongchong/shared'
|
||||
import { refreshAuthenticatedSession } from './http'
|
||||
|
||||
interface CachedOverseasToken {
|
||||
accessToken: string
|
||||
refreshAfter: number
|
||||
}
|
||||
|
||||
type RetryableOverseasRequestConfig = InternalAxiosRequestConfig & { _overseasAuthRetry?: boolean }
|
||||
|
||||
const TOKEN_REFRESH_SAFETY_WINDOW_MS = 30_000
|
||||
|
||||
let cachedToken: CachedOverseasToken | null = null
|
||||
|
||||
export const overseasHttp = axios.create({
|
||||
baseURL: import.meta.env.VITE_OVERSEAS_API_BASE_URL || '/overseas-api',
|
||||
timeout: 15_000,
|
||||
transformResponse: [(data: unknown) => parseApiJsonResponse(data)],
|
||||
})
|
||||
|
||||
function currentOverseasAccessToken() {
|
||||
return cachedToken && cachedToken.refreshAfter > Date.now() ? cachedToken.accessToken : ''
|
||||
}
|
||||
|
||||
async function requireOverseasAccessToken() {
|
||||
const current = currentOverseasAccessToken()
|
||||
if (current) return current
|
||||
await refreshAuthenticatedSession()
|
||||
const refreshed = currentOverseasAccessToken()
|
||||
if (!refreshed) throw new Error('当前环境未签发海外访问令牌')
|
||||
return refreshed
|
||||
}
|
||||
|
||||
export function replaceOverseasAccessToken(accessToken: string | null, expiresIn: number) {
|
||||
if (!accessToken && expiresIn === 0) {
|
||||
cachedToken = null
|
||||
return
|
||||
}
|
||||
if (!accessToken || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
||||
throw new Error('海外访问令牌响应无效')
|
||||
}
|
||||
cachedToken = {
|
||||
accessToken,
|
||||
refreshAfter: Date.now() + Math.max(0, expiresIn * 1000 - TOKEN_REFRESH_SAFETY_WINDOW_MS),
|
||||
}
|
||||
}
|
||||
|
||||
export function clearOverseasAccessToken() {
|
||||
cachedToken = null
|
||||
}
|
||||
|
||||
overseasHttp.interceptors.request.use(async (config) => {
|
||||
const accessToken = await requireOverseasAccessToken()
|
||||
config.headers.Authorization = `Bearer ${accessToken}`
|
||||
return config
|
||||
})
|
||||
|
||||
overseasHttp.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
async (error) => {
|
||||
const config = error.config as RetryableOverseasRequestConfig | undefined
|
||||
if (error.response?.status !== 401 || !config || config._overseasAuthRetry) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
config._overseasAuthRetry = true
|
||||
clearOverseasAccessToken()
|
||||
try {
|
||||
await refreshAuthenticatedSession()
|
||||
const accessToken = await requireOverseasAccessToken()
|
||||
config.headers.Authorization = `Bearer ${accessToken}`
|
||||
return overseasHttp.request(config)
|
||||
} catch {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
# Admin Shared Components Instructions
|
||||
|
||||
## 适用范围
|
||||
|
||||
- 本文件适用于 `src/shared/components/` 及其直接测试。业务页面使用这些组件时,同时遵守 `../../../AGENTS.md` 和 `../../../../../docs/Admin-Client前端原型还原验收规则.md`。
|
||||
- 这里只放与具体业务无关、已被多个模块真实复用的后台组件;纯业务字段、状态和流程留在所属模块。
|
||||
|
||||
## 标准列表组合
|
||||
|
||||
- 标准列表使用 `ListPageLayout` 组织查询区和表格区,保持单一 `16px` 间距;不叠加页面级空白操作区或重复面板间距。
|
||||
- 查询与筛选使用 `QueryToolbar.vue`,通过 `fields`、`v-model`、`search`、`reset` 接入;查询即刷新当前结果,不另设独立刷新按钮。
|
||||
- 数据列表使用 `DataTable.vue`,通过 `columns`、命名插槽、`v-model:page`、`v-model:page-size` 和 `total` 接入,不重复拼装 `el-table` 与分页器。
|
||||
- “操作”列放在第一列,需要固定时固定在左侧。单行操作不超过 3 个时直接显示“图标 + 文案”;第 4 个起使用 `MoreHorizontal` 菜单,并优先直接保留详情、编辑、下载等高频操作。
|
||||
- 新增、导入、导出和批量操作放入 `DataTable` 的 `toolbar-actions`;不放入查询栏,不用 `toolbar-start` 重复列表标题、数量或说明,数量统一放在 `pagination-left`。
|
||||
- 路由标题和说明默认由 `AppLayout` 的 `page-heading` 根据路由元数据展示;标准列表正文不重复标题。只有明确关闭路由页头的详情页或工作台才自带独立页头。
|
||||
|
||||
## 复用入口
|
||||
|
||||
- XLSX 批量导入使用 `BatchImportDialog.vue`;页面只提供模板下载、导入调用和成功后刷新,不复制文件校验、上传状态、错误报告或弹窗 CSS。
|
||||
- 模态框和抽屉优先使用 `AppDialog.vue`、`AppDrawer.vue`;但独立页面、弹框或抽屉的选择必须服从对应原型,不因已有组件方便而替换承载方式。
|
||||
- 加载失败组合 `LoadErrorState.vue` 与 `ApiErrorMeta.vue`,保留重试和 `requestId`/`traceId`;状态与复制优先使用 `StatusBadge.vue`、`CopyButton.vue`。
|
||||
- 参考页面:查询和表格见 `../../modules/master-data/express-companies/pages/ExpressCompaniesView.vue`,批量导入见该页面与 `../../modules/master-data/counterparties/pages/PartnersView.vue`,抽屉见 `../../modules/downloads/pages/DownloadCenterView.vue`。
|
||||
|
||||
## 图标、状态与可访问性
|
||||
|
||||
- 原型已定义图标时复用原型资源;原型未定义时使用 Lucide:查看 `Eye`、编辑 `Pencil`、更多 `MoreHorizontal`、新增 `Plus`、删除 `Trash2`、启停 `Power`、刷新 `RefreshCw`、导入 `FileUp`、导出/下载 `Download`、返回 `ArrowLeft`、保存 `Save`、复制 `Copy`。
|
||||
- 危险操作保留明确文案、危险色、确认过程和成功/失败反馈,不只显示图标。
|
||||
- 组件按适用语义覆盖加载、空数据、错误、禁用和无权限状态;保留键盘操作、可见焦点、表单标签、图标按钮名称和颜色对比度,不只用颜色表达状态。
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 扩展公共组件前定向检查全部消费者;通用能力扩展公共组件,纯业务特例留在模块,不用页面级 CSS 覆盖破坏公共契约。
|
||||
- 至少运行组件测试、直接受影响页面测试和 `pnpm typecheck`;交付前按项目指引运行完整测试与构建。
|
||||
Reference in New Issue
Block a user