优化
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
- 报价、汇率、车辆现有 API 在 `api/overseasMasterData.ts`;司机 API 在 `api/drivers.ts`,字段与标签在 `driverTypes.ts`,后端合同为 Lastleg `fleet-drivers-v1.yaml`。
|
||||
- 共享车次接口在 `api/trips.ts`,类型在 `tripTypes.ts`,后端合同为 `fleet-trips-v1.yaml`;覆盖计划/分配、客户排车关联、实际装载、货物结案和分别结束车辆/司机任务。接口保留稳定幂等键、版本与货物hash,长ID按字符串透传;数量和容量展示服务端结果,不在浏览器推算整车可发或可结案。新页面承载按 `PROTOTYPE_MATRIX.md` 确定,不因API存在推导页面能力。
|
||||
- 用户已于2026-10-02确认首版车次交互:`pages/TripsView.vue`、`pages/TripDetailView.vue` 为独立路由;`TripPlanDialog.vue` 1080px居中计划弹框,`TripAssignmentPanel.vue`、`TripLoadingPanel.vue` 在详情分区。列表一次分页请求,历史/装载/结案检查进入对应页签才读取;候选聚焦或搜索时调用车辆/司机目录,不逐行读取详情。先分配资源、关联排车及匹配卸货站,每张排车单调度审核通过后由PDA拣货、平台确认实测装载、整车理货审核通过后PDA整车发车;平台不提供替代PDA的发车按钮。审核使用独立trip:dispatch-review/trip:loading-review权限,详情展示通过/打回,通过意见选填、打回原因必填,不加审核清单;普通说明选填并提供标准审计说明,仍保留版本、幂等、容量和证照期限约束。
|
||||
- 车辆实际托位/载重kg/容积m³分别由服务端返回,三项同填正数或全空待确认;不按车型名称推算。占用期间主档关键变更由服务端拒绝,CSV使用车牌业务标识并包含容量字段。
|
||||
- 车辆实际托位/载重kg/容积m³分别由服务端返回,三项同填正数或全空待确认;不按车型名称推算。占用期间主档关键变更由服务端拒绝,下载中心 XLSX 导出使用车牌业务标识并包含容量字段。
|
||||
- 车辆运维在 `VehicleDetailView.vue` 的页签内,表格与编辑框为 `components/VehicleCareTable.vue`、`VehicleCareEditor.vue`;`api/vehicleCare.ts` 对接 `fleet-vehicle-records-v1.yaml`。保养/加油680px,维修/故障/救援/异常620px居中弹框,行操作按 `PROTOTYPE_MATRIX.md` 的逐类型入口执行。进入页签才分页查询,保养加油图片点击才鉴权读取并在新标签页预览;卸载释放Blob。车辆list包含记录只读,record-create/edit/upload独立;未知保存结果保留原请求键,照片重试不重复建记录。处理状态与付款状态独立,费用未填和零值区分;无财务关联时只显示服务端UNLINKED,不自动修改车辆公里码、状态或财务。
|
||||
- 维修/故障/救援的“更新处理”已获用户确认,复用原620px弹框及record-edit权限;更正说明选填,空值由页面填标准动作说明,不移除服务端不可变历史。
|
||||
- 司机列表 `pages/DriversView.vue`、详情 `pages/DriverDetailView.vue`,编辑和证照预览在 `components/DriverEditorDialog.vue`、`DriverLicensePreview.vue`。详情独立路由;编辑居中1080px三列表单;预览居中620px。
|
||||
@@ -33,3 +33,5 @@
|
||||
- `TripBookingPanel.vue`在车次详情“外接预订”页签按需读取列表;1080px居中弹框登记/编辑或只读详情,客户聚焦搜索才读取`/fleet/trips/customer-options`。询价只要求客户、既有卸货站和箱数,载荷/价格/联系信息可后补;确认承运由服务端校验余位及完整报价。create/edit/confirm/cancel分别使用独立`trip:booking-*`权限,读操作沿trip:list。未知创建及确认结果保留原请求重试,成功用命令结果更新列表和车次版本;计划容量表展示availableSegments(已扣除外接占位),不使用旧自有货余位覆盖。
|
||||
|
||||
- 外接履约弹框`TripBookingFulfillmentDialog.vue`为1080px居中:登记来货、按服务器实托/receiptHash核对实收与约定价格、输入客户排车单号关联;来货详情只读。只有显式打开来货/实收弹框才查询来源,不逐行请求。关联后刷新外接列表及车次详情,更新全部排车成员和理货审核状态;预订取消不删除来货或库存。
|
||||
|
||||
- 报价、汇率、车辆、车队报价与出车记录导出统一使用 `../downloads/` 的 `useExportTask`,按当前筛选创建 XLSX 任务;按钮要求原业务导出权限和 `platform:download:list`。出车成本可见性由 File 根据账号权限冻结,不接受页面声明。CSV 仅用于车队报价导入模板。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http, ApiResponseError } from '@/shared/api/http'
|
||||
import { listFleetQuotes, createFleetQuote, updateFleetQuote, importFleetQuotes, fleetCsv, fleetMoney, fleetVersion, isFleetCommandRejected, type FleetQuoteDraft } from './fleetQuotes'
|
||||
import { listFleetQuotes, createFleetQuote, updateFleetQuote, importFleetQuotes, fleetMoney, fleetVersion, isFleetCommandRejected, type FleetQuoteDraft } from './fleetQuotes'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
|
||||
const details: FleetQuoteDraft = { kind: 'SALE', partyId: '90071992547409931', originCity: 'LA', destinationCode: 'ONT8', currency: 'USD', distanceMiles: 10, selfPickupPrice: 0, fullDeliveryPrice: null, looseDeliveryPrice: 2, validFrom: '2026-01-01', validTo: '2026-12-31', remark: null }
|
||||
const quote = { quoteNo: 'FQ-ONE', details: { ...details, selfPickupPrice: '0', looseDeliveryPrice: '2', distanceMiles: '10' }, partyName: '委托企业', status: 'ACTIVE', recordVersion: '90071992547409931', updatedAt: '2026-10-01T00:00:00Z' }
|
||||
@@ -20,14 +20,6 @@ it('sends stable creation and batch keys without converting business references'
|
||||
vi.mocked(http.post).mockResolvedValue(ok({ importedRows: 1 })); await importFleetQuotes('batch-key', [details])
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/fleet/quotes/import', { idempotencyKey: 'batch-key', rows: [details] })
|
||||
})
|
||||
it('does not download a business error JSON as a CSV file', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue(new TextEncoder().encode('{"code":"CONFLICT","message":"缩小范围","requestId":"r1"}').buffer)
|
||||
await expect(fleetCsv('/export', {})).rejects.toMatchObject({ message: '缩小范围', requestId: 'r1' })
|
||||
})
|
||||
it('preserves diagnostics from a non-200 export response', async () => {
|
||||
vi.mocked(http.get).mockRejectedValue({ isAxiosError: true, response: { status: 403, data: new TextEncoder().encode('{"code":"403","message":"无导出权限","traceId":"t1"}').buffer } })
|
||||
await expect(fleetCsv('/export', {})).rejects.toMatchObject({ message: '无导出权限', status: 403, traceId: 't1' })
|
||||
})
|
||||
it('rejects lossy money and numeric versions instead of rounding them', () => {
|
||||
expect(() => fleetMoney('99999999999999999.01')).toThrow('精确展示')
|
||||
expect(() => fleetVersion(9007199254740992)).toThrow('版本无效')
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
|
||||
import axios from 'axios'
|
||||
import type { PageResult } from './overseasMasterData'
|
||||
|
||||
export type FleetQuoteKind = 'PURCHASE' | 'SALE'
|
||||
@@ -54,18 +53,4 @@ export async function fleetQuoteSuppliers(keyword: string) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<PageResult<{ id: string; name: string }>>, ApiEnvelope<PageResult<{ id: string; name: string }>>>('/platform/v1/suppliers', { params: { page: 1, size: 30, keyword, status: 'ACTIVE', category: 'TRUCKING', truckingScope: 'OVERSEAS' } }))
|
||||
return result.records.map(row => ({ ...row, id: String(row.id) }))
|
||||
}
|
||||
export async function fleetCsv(path: string, params: object): Promise<Blob> {
|
||||
function check(bytes: ArrayBuffer, status: number) {
|
||||
const text = new TextDecoder().decode(bytes)
|
||||
if (!/^\s*[{[]/.test(text)) return
|
||||
let error: ApiEnvelope<unknown>
|
||||
try { error = JSON.parse(text) as ApiEnvelope<unknown> } catch { throw new ApiResponseError('导出接口返回了无效响应', 'INVALID_EXPORT_RESPONSE', status) }
|
||||
throw new ApiResponseError(error.message || '导出失败', error.code || 'EXPORT_FAILED', status, error.data, error.requestId, error.traceId)
|
||||
}
|
||||
try {
|
||||
const bytes = await http.get<ArrayBuffer, ArrayBuffer>(path, { params, responseType: 'arraybuffer' }); check(bytes, 200)
|
||||
return new Blob([bytes], { type: 'text/csv;charset=utf-8' })
|
||||
} catch (e) { if (axios.isAxiosError(e) && Object.prototype.toString.call(e.response?.data) === '[object ArrayBuffer]') check(e.response!.data as ArrayBuffer, e.response!.status); throw e }
|
||||
}
|
||||
export const exportFleetQuotes = (query: FleetQuoteQuery) => fleetCsv(`${base}/export`, query)
|
||||
export function saveFleetCsv(blob: Blob, name: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = name; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ApiResponseError, http } from '@/shared/api/http'
|
||||
|
||||
import {
|
||||
changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle,
|
||||
exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getExchangeRateSummary, getQuote, getVehicle,
|
||||
getExchangeRate, getExchangeRateSummary, getQuote, getVehicle,
|
||||
listExchangeRates, listQuotes, listQuoteWarehouseOptions, listVehicles, updateExchangeRate, updateQuote, updateVehicle,
|
||||
type QuoteCreate, type QuoteUpdate,
|
||||
} from './overseasMasterData'
|
||||
@@ -124,22 +124,4 @@ describe('overseas master data API', () => {
|
||||
expect(error).toMatchObject({ code: 'A0409', message: '记录版本已变化', requestId: 'request-conflict', traceId: 'trace-conflict' })
|
||||
})
|
||||
|
||||
it('downloads real CSV Blobs and rejects JSON error Blobs with trace metadata', async () => {
|
||||
const csv = new Blob(['id,status\n1,ACTIVE\n'], { type: 'text/csv;charset=UTF-8' })
|
||||
vi.mocked(http.get)
|
||||
.mockResolvedValueOnce(csv)
|
||||
.mockResolvedValueOnce(csv)
|
||||
.mockResolvedValueOnce(csv)
|
||||
.mockResolvedValueOnce(new Blob([JSON.stringify({ code: 'A0409', message: '导出条件已变化', data: null, requestId: 'request-export' })], { type: 'application/json' }))
|
||||
|
||||
await expect(exportQuotes({ status: 'ACTIVE' })).resolves.toBe(csv)
|
||||
await expect(exportExchangeRates({})).resolves.toBe(csv)
|
||||
await expect(exportVehicles({ ownership: 'OWNED' })).resolves.toBe(csv)
|
||||
const error = await exportVehicles({}).catch((reason: unknown) => reason)
|
||||
expect(error).toBeInstanceOf(ApiResponseError)
|
||||
expect(error).toMatchObject({ code: 'A0409', message: '导出条件已变化', requestId: 'request-export' })
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/master-data/overseas-quotes/export', { params: { status: 'ACTIVE' }, responseType: 'arraybuffer' })
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/master-data/exchange-rates/export', { params: {}, responseType: 'arraybuffer' })
|
||||
expect(http.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/vehicles/export', { params: { ownership: 'OWNED' }, responseType: 'arraybuffer' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import axios from 'axios'
|
||||
import { http, ApiResponseError, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
import type {
|
||||
MasterDataStatus, MonthlyExchangeRate, OverseasTransitPrice, OverseasWarehouseFee, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption,
|
||||
@@ -34,58 +33,6 @@ const normalizeQuoteDetail = (value: QuoteDetailWire): OverseasWarehouseQuoteDet
|
||||
const normalizeExchangeRate = (value: ExchangeRateWire): MonthlyExchangeRate => ({ ...value, usdToCnyAverage: numeric(value.usdToCnyAverage), recordVersion: numeric(value.recordVersion) })
|
||||
const normalizeVehicle = (value: VehicleWire): VehicleDetail => ({ ...value, fuelConsumptionPer100Km: numeric(value.fuelConsumptionPer100Km), mileageKm: numeric(value.mileageKm), recordVersion: numeric(value.recordVersion), maxPalletSpaces: nullableNumeric(value.maxPalletSpaces), maxLoadKg: nullableNumeric(value.maxLoadKg), maxVolumeCbm: nullableNumeric(value.maxVolumeCbm) })
|
||||
|
||||
type DownloadPayload = Blob | ArrayBuffer | ArrayBufferView | string
|
||||
|
||||
async function downloadText(payload: DownloadPayload) {
|
||||
if (typeof payload === 'string') return payload
|
||||
if (payload instanceof Blob) return payload.text()
|
||||
const bytes = payload instanceof ArrayBuffer
|
||||
? new Uint8Array(payload)
|
||||
: new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength)
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
async function csvError(payload: DownloadPayload, status: number) {
|
||||
const contentType = payload instanceof Blob ? payload.type.toLowerCase() : ''
|
||||
const text = await downloadText(payload)
|
||||
if (!contentType.includes('json') && !/^\s*[\[{]/.test(text)) return null
|
||||
try {
|
||||
const envelope = JSON.parse(text) as Partial<ApiEnvelope<unknown>>
|
||||
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) throw new Error('invalid envelope')
|
||||
return new ApiResponseError(envelope.message || 'CSV 导出失败', envelope.code || 'DOWNLOAD_FAILED', status,
|
||||
envelope.data, envelope.requestId, envelope.traceId)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiResponseError) return error
|
||||
return new ApiResponseError('CSV 导出接口返回了无法识别的 JSON 响应', 'INVALID_DOWNLOAD_RESPONSE', status)
|
||||
}
|
||||
}
|
||||
function csvBlob(payload: DownloadPayload) {
|
||||
if (payload instanceof Blob) return payload
|
||||
if (typeof payload === 'string') return new Blob([payload], { type: 'text/csv;charset=utf-8' })
|
||||
const bytes = payload instanceof ArrayBuffer
|
||||
? new Uint8Array(payload)
|
||||
: new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength)
|
||||
const copy = new Uint8Array(bytes.byteLength)
|
||||
copy.set(bytes)
|
||||
return new Blob([copy.buffer], { type: 'text/csv;charset=utf-8' })
|
||||
}
|
||||
async function download(path: string, params: object) {
|
||||
try {
|
||||
const payload = await http.get<DownloadPayload, DownloadPayload>(path, { params, responseType: 'arraybuffer' })
|
||||
const error = await csvError(payload, 200)
|
||||
if (error) throw error
|
||||
return csvBlob(payload)
|
||||
} catch (error) {
|
||||
const axiosError = axios.isAxiosError(error) ? error : null
|
||||
const responsePayload = axiosError?.response?.data as DownloadPayload | undefined
|
||||
if (responsePayload !== undefined) {
|
||||
const responseError = await csvError(responsePayload, axiosError?.response?.status || 0)
|
||||
if (responseError) throw responseError
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export interface QuoteQuery { page: number; size: number; keyword?: string; warehouseId?: string; providerName?: string; currency?: string; status?: MasterDataStatus }
|
||||
type QuoteDetailFields = { warehouseFees: OverseasWarehouseFee[]; transitPrices: OverseasTransitPrice[] }
|
||||
type QuoteEditableFields = Pick<OverseasWarehouseQuote, 'name' | 'providerName' | 'currency' | 'effectiveStart' | 'remark'>
|
||||
@@ -98,7 +45,6 @@ export async function listQuoteWarehouseOptions() { return unwrapApiResponse(awa
|
||||
export async function createQuote(payload: QuoteCreate) { return normalizeQuoteDetail(unwrapApiResponse(await http.post<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes`, payload))) }
|
||||
export async function updateQuote(id: string, payload: QuoteUpdate) { return normalizeQuoteDetail(unwrapApiResponse(await http.put<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes/${encodeURIComponent(id)}`, payload))) }
|
||||
export async function changeQuoteStatus(id: string, status: MasterDataStatus, recordVersion: number) { return normalizeQuoteDetail(unwrapApiResponse(await http.patch<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes/${encodeURIComponent(id)}/status`, { status, recordVersion }))) }
|
||||
export const exportQuotes = (query: Omit<QuoteQuery, 'page' | 'size'>) => download(`${base}/overseas-quotes/export`, query)
|
||||
|
||||
export interface ExchangeRateQuery { page: number; size: number; monthFrom?: string; monthTo?: string; status?: MasterDataStatus }
|
||||
export const listExchangeRates = (query: ExchangeRateQuery) => getPage<MonthlyExchangeRate, ExchangeRateWire>(`${base}/exchange-rates`, query, normalizeExchangeRate)
|
||||
@@ -110,7 +56,6 @@ export async function getExchangeRateSummary(): Promise<ExchangeRateSummary> {
|
||||
export async function createExchangeRate(payload: { month: string; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await http.post<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates`, payload))) }
|
||||
export async function updateExchangeRate(month: string, payload: { recordVersion: number; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await http.put<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}`, payload))) }
|
||||
export async function changeExchangeRateStatus(month: string, status: MasterDataStatus, recordVersion: number, remark: string | null) { return normalizeExchangeRate(unwrapApiResponse(await http.patch<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark }))) }
|
||||
export const exportExchangeRates = (query: Omit<ExchangeRateQuery, 'page' | 'size'>) => download(`${base}/exchange-rates/export`, query)
|
||||
|
||||
export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus }
|
||||
type VehicleEditableFields = Omit<VehicleDetail, 'id' | 'recordVersion' | 'updatedBy' | 'updatedAt' | 'status'>
|
||||
@@ -121,4 +66,3 @@ export async function getVehicle(id: string) { return normalizeVehicle(unwrapApi
|
||||
export async function createVehicle(payload: VehicleCreate) { return normalizeVehicle(unwrapApiResponse(await http.post<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles`, payload))) }
|
||||
export async function updateVehicle(id: string, payload: VehicleUpdate) { return normalizeVehicle(unwrapApiResponse(await http.put<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles/${encodeURIComponent(id)}`, payload))) }
|
||||
export async function changeVehicleStatus(id: string, status: VehicleStatus, recordVersion: number) { return normalizeVehicle(unwrapApiResponse(await http.patch<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles/${encodeURIComponent(id)}/status`, { status, recordVersion }))) }
|
||||
export const exportVehicles = (query: Omit<VehicleQuery, 'page' | 'size'>) => download(`${base}/vehicles/export`, query)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios'
|
||||
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { PageResult } from './overseasMasterData'
|
||||
import { fleetCsv, fleetNumber, fleetMoney, fleetVersion, fleetPage } from './fleetQuotes'
|
||||
import { fleetNumber, fleetMoney, fleetVersion, fleetPage } from './fleetQuotes'
|
||||
import type { CostSummary, CostTotal } from './tripCosts'
|
||||
export interface TripOperationsQuery { page?: number; size?: number; from: string; to: string; keyword?: string; warehouseId?: string; sourceType?: 'OWN' | 'EXTERNAL'; status?: string }
|
||||
export interface TripOperationsMoney { currency: string; amount: number }
|
||||
@@ -42,7 +42,6 @@ export async function listTripOperations(query: TripOperationsQuery): Promise<Tr
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ page: PageResult<WireRow>; summary: WireSummary; charts: WireCharts }>, ApiEnvelope<{ page: PageResult<WireRow>; summary: WireSummary; charts: WireCharts }>>('/platform/v1/last-leg/fleet/operations', { params: query }))
|
||||
return { page: fleetPage({ ...result.page, records: result.page.records.map(r => ({ ...r, ...counts(r), measured: r.measured == null ? null : measured(r.measured), externalPalletRates: r.externalPalletRates.map(p => ({ ...p, pricedPalletSpaces: fleetNumber(p.pricedPalletSpaces), agreedAmount: fleetMoney(p.agreedAmount) })), costs: r.costs == null ? null : { allocationBlockReason: r.costs.allocationBlockReason, totals: costTotals(r.costs.totals) } })) }), summary: { ...result.summary, ...counts(result.summary), trips: fleetNumber(result.summary.trips), inTransit: fleetNumber(result.summary.inTransit), completed: fleetNumber(result.summary.completed), costs: { ...result.summary.costs, unrecordedTrips: result.summary.costs.unrecordedTrips == null ? null : fleetNumber(result.summary.costs.unrecordedTrips), unallocatedTrips: result.summary.costs.unallocatedTrips == null ? null : fleetNumber(result.summary.costs.unallocatedTrips), totals: costTotals(result.summary.costs.totals) } }, charts: { physicalPallets: series(result.charts.physicalPallets), destinationTrips: series(result.charts.destinationTrips), externalAgreedAmounts: series(result.charts.externalAgreedAmounts), externalPalletRates: series(result.charts.externalPalletRates) } }
|
||||
}
|
||||
export const exportTripOperations = (query: TripOperationsQuery) => fleetCsv('/platform/v1/last-leg/fleet/operations/export', query)
|
||||
export interface TripOperationsRecord { tripNo: string; recordVersion: string; details: TripOperationsMeasured; updatedAt: string | null }
|
||||
export interface TripOperationsRecordSave { recordVersion: string; idempotencyKey: string; details: TripOperationsMeasuredInput; reason: string | null }
|
||||
export interface TripOperationsRecordHistory { recordVersion: string; reason: string; occurredAt: string; details: TripOperationsMeasured }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useExportTask } from '@/modules/downloads'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Download, Plus } from '@lucide/vue'
|
||||
@@ -11,12 +12,12 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { changeExchangeRateStatus, createExchangeRate, exportExchangeRates, getExchangeRate, getExchangeRateSummary, listExchangeRates, updateExchangeRate } from '../api/overseasMasterData'
|
||||
import { changeExchangeRateStatus, createExchangeRate, getExchangeRate, getExchangeRateSummary, listExchangeRates, updateExchangeRate } from '../api/overseasMasterData'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { saveCsv } from '@/shared/downloadCsv'
|
||||
import { masterDataErrorMessage } from '../apiError'
|
||||
import type { MasterDataStatus, MonthlyExchangeRate } from '../types'
|
||||
|
||||
const submitExport = useExportTask()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<MonthlyExchangeRate[]>([]), total = ref(0), page = ref(1), pageSize = ref(20), loading = ref(false), saving = ref(false), exporting = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), editorOpen = ref(false), editingMonth = ref('')
|
||||
@@ -40,7 +41,7 @@ function openCreate() { editingMonth.value = ''; Object.assign(form, { month: ''
|
||||
async function openEdit(row: MonthlyExchangeRate) { try { const value = await getExchangeRate(row.month); editingMonth.value = value.month; Object.assign(form, { month: value.month, usdToCnyAverage: Number(value.usdToCnyAverage), remark: value.remark || '', recordVersion: value.recordVersion }); editorOpen.value = true } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率详情加载失败')) } }
|
||||
async function save() { if (!form.month || form.usdToCnyAverage <= 0) { ElMessage.warning('请选择月份并填写大于 0 的平均汇率'); return } saving.value = true; try { if (editingMonth.value) await updateExchangeRate(editingMonth.value, { recordVersion: form.recordVersion, usdToCnyAverage: form.usdToCnyAverage, remark: form.remark || null }); else await createExchangeRate({ month: form.month, usdToCnyAverage: form.usdToCnyAverage, remark: form.remark || null }); ElMessage.success(editingMonth.value ? '月度汇率已更新' : '月度汇率已创建'); editorOpen.value = false; await load() } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '月度汇率保存失败')) } finally { saving.value = false } }
|
||||
async function toggle(row: MonthlyExchangeRate) { const next: MasterDataStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'; try { await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'} ${row.month} 汇率吗?`, '汇率状态', { customClass: 'admin-density-confirm', type: 'warning' }); await changeExchangeRateStatus(row.month, next, row.recordVersion, null); ElMessage.success('汇率状态已更新'); await load() } catch (cause) { if (cause !== 'cancel' && cause !== 'close') ElMessage.error(masterDataErrorMessage(cause, '汇率状态更新失败')) } }
|
||||
async function exportCsv() { exporting.value = true; try { const { page: _page, size: _size, ...query } = params(); saveCsv(await exportExchangeRates(query), 'monthly-exchange-rates.csv') } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率导出失败')) } finally { exporting.value = false } }
|
||||
async function exportCsv() { exporting.value = true; try { const { page: _page, size: _size, ...query } = params(); await submitExport('MONTHLY_EXCHANGE_RATE_LIST', '月度汇率', query) } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率导出失败')) } finally { exporting.value = false } }
|
||||
watch([page, pageSize], load); onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -55,7 +56,7 @@ watch([page, pageSize], load); onMounted(load)
|
||||
<template #before-results><dl class="rate-summary density-detail-overview"><div v-for="item in [{label:'最新维护汇率',value:summary.latestUsdToCnyAverage??'—'},{label:'汇率月份',value:summary.latestMonth||'—'},{label:'最后更新人',value:summary.updatedBy||'—'},{label:'最后更新时间',value:summary.updatedAt||'—'}]" :key="item.label"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div></dl></template>
|
||||
|
||||
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.exchangeRateCreate)" type="primary" @click="openCreate"><Plus :size="16" />更新月度汇率</el-button><el-button v-if="access.can(permission.exchangeRateExport)" :loading="exporting" @click="exportCsv"><Download :size="16" />导出</el-button></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.exchangeRateCreate)" type="primary" @click="openCreate"><Plus :size="16" />更新月度汇率</el-button><el-button v-if="access.can(permission.exchangeRateExport) && access.can('platform:download:list')" :loading="exporting" @click="exportCsv"><Download :size="16" />导出</el-button></template>
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.exchangeRateEdit)" @click="openEdit(row as MonthlyExchangeRate)">编辑</el-button><el-button class="density-action" v-if="access.can(permission.exchangeRateStatus)" :data-tone="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggle(row as MonthlyExchangeRate)">{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '停用' : '启用' }}</el-button></div></template>
|
||||
<template #conversion="{ row }">1 USD = {{ (row as MonthlyExchangeRate).usdToCnyAverage }} CNY</template><template #status="{ row }"><el-tag :type="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'success' : 'info'">{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
|
||||
</DataTable></section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useExportTask } from '@/modules/downloads'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -13,7 +14,8 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import FleetQuoteEditor from '../components/FleetQuoteEditor.vue'
|
||||
import FleetQuoteImport from '../components/FleetQuoteImport.vue'
|
||||
import { listFleetQuotes, setFleetQuoteStatus, deleteFleetQuote, exportFleetQuotes, saveFleetCsv, type FleetQuote, type FleetQuoteKind, type FleetQuoteQuery } from '../api/fleetQuotes'
|
||||
import { listFleetQuotes, setFleetQuoteStatus, deleteFleetQuote, type FleetQuote, type FleetQuoteKind, type FleetQuoteQuery } from '../api/fleetQuotes'
|
||||
const submitExport = useExportTask()
|
||||
const access = useAccessPreviewStore(), rows = ref<FleetQuote[]>([]), total = ref(0), page = ref(1), size = ref(20), loading = ref(false), busy = ref(false)
|
||||
const kind = ref<FleetQuoteKind>('PURCHASE'), editor = ref(false), importer = ref(false), selected = ref<FleetQuote | null>(null), readonly = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ keyword: '', status: '', effectiveOn: '' }); let applied: FleetQuoteQuery = {}, sequence = 0
|
||||
@@ -34,7 +36,7 @@ async function action(key: string, quoteNo: unknown) {
|
||||
busy.value = true
|
||||
try { if (key === 'delete') await deleteFleetQuote(row); else await setFleetQuoteStatus(row, row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'); ElMessage.success('报价已更新'); await load() } catch (e) { error.value = getApiErrorDetails(e, '报价操作失败') } finally { busy.value = false }
|
||||
}
|
||||
async function download() { busy.value = true; try { saveFleetCsv(await exportFleetQuotes({ ...applied, kind: kind.value }), '车队报价.csv') } catch (e) { error.value = getApiErrorDetails(e, '导出失败') } finally { busy.value = false } }
|
||||
async function download() { busy.value = true; try { await submitExport('FLEET_QUOTE_LIST', '车队报价', { ...applied, kind: kind.value }) } catch (e) { error.value = getApiErrorDetails(e, '导出失败') } finally { busy.value = false } }
|
||||
watch(kind, () => { if (page.value !== 1) page.value = 1; else void load() }); watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><ListPageLayout>
|
||||
@@ -42,7 +44,7 @@ watch(kind, () => { if (page.value !== 1) page.value = 1; else void load() }); w
|
||||
<LoadErrorState v-if="error" title="报价操作失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="车队报价查询" @search="search" @reset="search" />
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="quoteNo" empty-text="暂无报价">
|
||||
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:quote:create')" type="primary" @click="open(null)">新增报价</el-button><el-button v-if="access.can('platform:fleet:quote:import')" @click="importer = true">批量导入</el-button><el-button v-if="access.can('platform:fleet:quote:export')" :loading="busy" @click="download">导出CSV</el-button></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:quote:create')" type="primary" @click="open(null)">新增报价</el-button><el-button v-if="access.can('platform:fleet:quote:import')" @click="importer = true">批量导入</el-button><el-button v-if="access.can('platform:fleet:quote:export') && access.can('platform:download:list')" :loading="busy" @click="download">导出</el-button></template>
|
||||
<template #actions="{ row }"><RowActions :actions="actions(row.quoteNo)" @action="action($event,row.quoteNo)" /></template>
|
||||
<template #origin="{ row }">{{ row.details.originCity }}</template><template #destination="{ row }">{{ row.details.destinationCode }}</template><template #miles="{ row }">{{ row.details.distanceMiles }}</template><template #currency="{ row }">{{ row.details.currency }}</template>
|
||||
<template #pickup="{ row }">{{ row.details.selfPickupPrice ?? '—' }}</template><template #full="{ row }">{{ row.details.fullDeliveryPrice ?? '—' }}</template><template #loose="{ row }">{{ row.details.looseDeliveryPrice ?? '—' }}</template>
|
||||
|
||||
@@ -55,9 +55,9 @@ describe('overseas master data real contract pages', () => {
|
||||
expect(view).toContain('access.can(permission.')
|
||||
expect(view).toContain('ElMessage.success')
|
||||
}
|
||||
expect(source('OverseasQuotesView')).toContain('导出CSV')
|
||||
expect(source('VehiclesView')).toContain('导出CSV')
|
||||
expect(source('ExchangeRatesView')).toContain("'monthly-exchange-rates.csv'")
|
||||
expect(source('OverseasQuotesView')).toContain('导出')
|
||||
expect(source('VehiclesView')).toContain('导出')
|
||||
expect(source('ExchangeRatesView')).toContain("submitExport('MONTHLY_EXCHANGE_RATE_LIST'")
|
||||
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
|
||||
expect(source(name)).not.toMatch(/deleteQuote|deleteExchangeRate|deleteVehicle/)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ describe('overseas master data real contract pages', () => {
|
||||
expect(source('VehiclesView')).toContain('changeVehicleStatus(row.id,statusTarget.value,row.recordVersion)')
|
||||
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
|
||||
expect(source(name)).toContain("masterDataErrorMessage(")
|
||||
expect(source(name)).toContain('saveCsv(await export')
|
||||
expect(source(name)).toContain('await submitExport(')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useExportTask } from '@/modules/downloads'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Download, Plus } from '@lucide/vue'
|
||||
@@ -11,12 +12,12 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { changeQuoteStatus, createQuote, exportQuotes, getQuote, listQuotes, listQuoteWarehouseOptions, updateQuote } from '../api/overseasMasterData'
|
||||
import { changeQuoteStatus, createQuote, getQuote, listQuotes, listQuoteWarehouseOptions, updateQuote } from '../api/overseasMasterData'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { saveCsv } from '@/shared/downloadCsv'
|
||||
import { masterDataErrorMessage } from '../apiError'
|
||||
import type { MasterDataStatus, OverseasTransitPrice, OverseasWarehouseFee, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption } from '../types'
|
||||
|
||||
const submitExport = useExportTask()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<OverseasWarehouseQuote[]>([]), total = ref(0), page = ref(1), pageSize = ref(20), loading = ref(false), saving = ref(false), exporting = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), options = ref<PartnerWarehouseOption[]>([]), detail = ref<OverseasWarehouseQuoteDetail | null>(null)
|
||||
@@ -50,7 +51,7 @@ async function openCreate(){editingId.value='';resetForm();if(!options.value.len
|
||||
async function openEdit(row:OverseasWarehouseQuote){try{const d=await getQuote(row.id);editingId.value=row.id;fill(d);editorOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价详情加载失败'))}}
|
||||
async function save(){if(!form.name||!form.warehouseId||!form.providerName||!form.effectiveStart||!form.effectiveEnd||!form.warehouseFees.length){ElMessage.warning('请完整填写报价必填信息');return} if(form.effectiveEnd<form.effectiveStart){ElMessage.warning('失效日期不能早于生效日期');return} saving.value=true;try{const common={name:form.name,providerName:form.providerName,currency:form.currency as 'USD'|'CNY',effectiveStart:form.effectiveStart,effectiveEnd:form.effectiveEnd,warehouseFees:form.warehouseFees,transitPrices:form.transitPrices,remark:form.remark||null};if(editingId.value)await updateQuote(editingId.value,{...common,recordVersion:form.recordVersion});else await createQuote({...common,warehouseId:form.warehouseId,versionCode:form.versionCode});ElMessage.success(editingId.value?'报价已更新':'报价已创建');editorOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价保存失败'))}finally{saving.value=false}}
|
||||
async function toggle(row:OverseasWarehouseQuote){const next:MasterDataStatus=row.status==='ACTIVE'?'INACTIVE':'ACTIVE';try{await ElMessageBox.confirm(`确认${next==='ACTIVE'?'启用':'停用'}“${row.name}”吗?`,'报价状态',{customClass:'admin-density-confirm',type:'warning'});await changeQuoteStatus(row.id,next,row.recordVersion);ElMessage.success('报价状态已更新');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(masterDataErrorMessage(e,'报价状态更新失败'))}}
|
||||
async function exportCsv(){exporting.value=true;try{const {page:_,size:__,...q}=params();saveCsv(await exportQuotes(q),'overseas-quotes.csv')}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价导出失败'))}finally{exporting.value=false}}
|
||||
async function exportCsv(){exporting.value=true;try{const {page:_,size:__,...q}=params();await submitExport('OVERSEAS_QUOTE_LIST', '海外报价', q)}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价导出失败'))}finally{exporting.value=false}}
|
||||
watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions()])})
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="海外仓报价列表">
|
||||
@@ -62,7 +63,7 @@ watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions(
|
||||
<template #notices><LoadErrorState v-if="error" title="海外仓报价加载失败" :message="error.message" @retry="load" /></template>
|
||||
|
||||
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.quoteCreate)" type="primary" @click="openCreate"><Plus :size="16"/>新增报价</el-button><el-button v-if="access.can(permission.quoteExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.quoteCreate)" type="primary" @click="openCreate"><Plus :size="16"/>新增报价</el-button><el-button v-if="access.can(permission.quoteExport) && access.can('platform:download:list')" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出</el-button></template>
|
||||
<template #actions="{row}"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="neutral" class="density-action" @click="openDetail(row as OverseasWarehouseQuote)">详情</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.quoteEdit)" @click="openEdit(row as OverseasWarehouseQuote)">编辑</el-button><el-button class="density-action" v-if="access.can(permission.quoteStatus)" :data-tone="(row as OverseasWarehouseQuote).status==='ACTIVE'?'danger':'success'" @click="toggle(row as OverseasWarehouseQuote)">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'停用':'启用'}}</el-button></div></template>
|
||||
<template #status="{row}"><el-tag :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'success':'info'">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'启用':'停用'}}</el-tag></template>
|
||||
</DataTable></section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useExportTask } from '@/modules/downloads'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -10,11 +11,11 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listTripOperations, exportTripOperations, type TripOperationsQuery, type TripOperationsResult, type TripOperationsMoney, type TripOperationsRow } from '../api/tripOperations'
|
||||
import { saveFleetCsv } from '../api/fleetQuotes'
|
||||
import { listTripOperations, type TripOperationsQuery, type TripOperationsResult, type TripOperationsMoney, type TripOperationsRow } from '../api/tripOperations'
|
||||
import TripOperationsFinanceCell from '../components/TripOperationsFinanceCell.vue'
|
||||
import TripOperationsCharts from '../components/TripOperationsCharts.vue'
|
||||
import TripOperationsRecordDialog from '../components/TripOperationsRecordDialog.vue'
|
||||
const submitExport = useExportTask()
|
||||
const router = useRouter(), access = useAccessPreviewStore()
|
||||
const result = ref<TripOperationsResult | null>(null), page = ref(1), size = ref(20), loading = ref(false), exporting = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const recordOpen = ref(false), selected = ref<TripOperationsRow | null>(null)
|
||||
@@ -41,7 +42,7 @@ function range(): TripOperationsQuery {
|
||||
async function load() { const current = ++sequence; loading.value = true; error.value = null; try { const data = await listTripOperations({ ...applied, page: page.value, size: size.value }); if (current === sequence) result.value = data } catch (e) { if (current === sequence) { result.value = null; error.value = getApiErrorDetails(e, '运营记录加载失败') } } finally { if (current === sequence) loading.value = false } }
|
||||
function search() { try { applied = range(); if (page.value !== 1) page.value = 1; else void load() } catch (e) { error.value = getApiErrorDetails(e, '日期范围无效') } }
|
||||
function openRecord(tripNo: unknown) { const row = result.value?.page.records.find(item => item.tripNo === tripNo); if (row) { selected.value = row; recordOpen.value = true } }
|
||||
async function download() { exporting.value = true; try { saveFleetCsv(await exportTripOperations(applied), '车次运营.csv') } catch (e) { error.value = getApiErrorDetails(e, '导出失败') } finally { exporting.value = false } }
|
||||
async function download() { exporting.value = true; try { await submitExport('TRIP_OPERATIONS_LIST', '出车记录', applied) } catch (e) { error.value = getApiErrorDetails(e, '导出失败') } finally { exporting.value = false } }
|
||||
const money = (rows: TripOperationsMoney[]) => rows.length ? rows.map(r => `${r.currency} ${r.amount.toFixed(2)}`).join(' / ') : '—'
|
||||
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
@@ -61,7 +62,7 @@ watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }
|
||||
<p class="operations-note">按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。约定金额不代表应收或实收,结案不代表车辆与司机已释放。</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:operations:export')" :loading="exporting" @click="download">导出CSV</el-button></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:operations:export') && access.can('platform:download:list')" :loading="exporting" @click="download">导出</el-button></template>
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button v-if="access.can('platform:fleet:trip:list')" class="density-action" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } })">详情</el-button><el-button class="density-action" @click="openRecord(row.tripNo)">实测记录</el-button></div></template>
|
||||
<template #status="{ row }">{{ row.status === 'COMPLETED' ? '货物已结案' : '在途' }}</template><template #destinations="{ row }">{{ row.destinations.map((d: TripOperationsRow['destinations'][number]) => d.label).join(' / ') }}</template>
|
||||
<template #distance="{ row }">{{ row.measured?.actualDistanceMiles ?? '—' }}</template><template #fuel="{ row }">{{ row.measured?.actualFuelLitres ?? '—' }}</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useExportTask } from '@/modules/downloads'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { vehicleStatusLabels } from '../presentation'
|
||||
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
@@ -7,8 +8,9 @@ import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'; import AppDialog from '@/shared/components/AppDialog.vue'; import DataTable,{type DataTableColumn} from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import QueryToolbar,{type QueryField,type QueryModel} from '@/shared/components/QueryToolbar.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails,type ApiErrorDetails } from '@/shared/api/http'; import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { changeVehicleStatus,createVehicle,exportVehicles,getVehicle,listVehicles,updateVehicle } from '../api/overseasMasterData'; import { overseasMasterDataPermissions as permission } from '../permissions'; import { saveCsv } from '@/shared/downloadCsv'; import type { VehicleDetail,VehicleOwnership,VehicleRegion,VehicleStatus } from '../types'
|
||||
import { changeVehicleStatus,createVehicle,getVehicle,listVehicles,updateVehicle } from '../api/overseasMasterData'; import { overseasMasterDataPermissions as permission } from '../permissions'; import type { VehicleDetail,VehicleOwnership,VehicleRegion,VehicleStatus } from '../types'
|
||||
import { masterDataErrorMessage } from '../apiError'
|
||||
const submitExport = useExportTask()
|
||||
const router=useRouter(),access=useAccessPreviewStore(),rows=ref<VehicleDetail[]>([]),total=ref(0),page=ref(1),pageSize=ref(20),loading=ref(false),saving=ref(false),exporting=ref(false),error=ref<ApiErrorDetails|null>(null),editorOpen=ref(false),editingId=ref(''),statusOpen=ref(false),statusRow=ref<VehicleDetail|null>(null),statusTarget=ref<VehicleStatus>('IN_SERVICE')
|
||||
const ownership=ref<VehicleOwnership>('OWNED'),tabs:[string,VehicleOwnership][]=[['自有车辆','OWNED'],['外派车辆','DISPATCHED']]
|
||||
const filters=ref<QueryModel>({keyword:'',region:'',status:''});const fields:QueryField[]=[{key:'keyword',type:'input',label:'车辆关键词',placeholder:'车牌 / 司机 / 车型',width:220},{key:'region',type:'select',label:'区域',placeholder:'全部区域',width:140,options:[{label:'国内',value:'DOMESTIC'},{label:'海外',value:'OVERSEAS'}]},{key:'status',type:'select',label:'状态',placeholder:'全部状态',width:140,options:Object.entries(vehicleStatusLabels).map(([value,label])=>({label,value}))}]
|
||||
@@ -32,7 +34,7 @@ function reset(){Object.assign(form,{plateNumber:'',model:'',fuelConsumptionPer1
|
||||
function openCreate(){editingId.value='';reset();editorOpen.value=true} async function openEdit(row:VehicleDetail){try{const d=await getVehicle(row.id);editingId.value=d.id;Object.assign(form,{...d,driverOrDispatchCompany:d.driverOrDispatchCompany||'',phone:d.phone||'',remark:d.remark||''});editorOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆详情加载失败'))}}
|
||||
async function save(){if(!form.plateNumber||!form.model||form.fuelConsumptionPer100Km==null||form.fuelConsumptionPer100Km<=0){ElMessage.warning('请完整填写车辆必填信息');return}const capacity=[form.maxPalletSpaces,form.maxLoadKg,form.maxVolumeCbm];if(capacity.some(v=>v!=null)&&capacity.some(v=>v==null||v<=0)){ElMessage.warning('托位、载重和容积须同时填写正数,或全部留空');return}saving.value=true;try{const base={plateNumber:form.plateNumber,model:form.model,fuelConsumptionPer100Km:form.fuelConsumptionPer100Km,mileageKm:form.mileageKm,driverOrDispatchCompany:form.driverOrDispatchCompany||null,phone:form.phone||null,region:form.region,ownership:form.ownership,remark:form.remark||null,maxPalletSpaces:form.maxPalletSpaces??null,maxLoadKg:form.maxLoadKg??null,maxVolumeCbm:form.maxVolumeCbm??null};if(editingId.value)await updateVehicle(editingId.value,{...base,recordVersion:form.recordVersion});else await createVehicle({...base,status:form.status});ElMessage.success(editingId.value?'车辆已更新':'车辆已创建');editorOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆保存失败'))}finally{saving.value=false}}
|
||||
function openStatus(row:VehicleDetail){statusRow.value=row;statusTarget.value=row.status;statusOpen.value=true} async function submitStatus(){const row=statusRow.value;if(!row)return;saving.value=true;try{await changeVehicleStatus(row.id,statusTarget.value,row.recordVersion);ElMessage.success('车辆状态已更新');statusOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆状态更新失败'))}finally{saving.value=false}}
|
||||
async function exportCsv(){exporting.value=true;try{const{page:_,size:__,...q}=params();saveCsv(await exportVehicles(q),'vehicles.csv')}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆导出失败'))}finally{exporting.value=false}}
|
||||
async function exportCsv(){exporting.value=true;try{const{page:_,size:__,...q}=params();await submitExport('VEHICLE_LIST', '车辆', q)}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆导出失败'))}finally{exporting.value=false}}
|
||||
watch([page,pageSize],load);onMounted(load)
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="车辆列表"><section class="vehicle-panel"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆">
|
||||
@@ -40,6 +42,6 @@ watch([page,pageSize],load);onMounted(load)
|
||||
<template #filters><div class="query-panel integrated-query-group"><div class="status-tabs density-status-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{'is-active':ownership===tab[1]}]" @click="selectOwnership(tab[1])"><span class="density-tab-icon" aria-hidden="true">{{ tab[1] === 'OWNED' ? '🚚' : '🚛' }}</span>{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template>
|
||||
<template #notices><LoadErrorState v-if="error" title="车辆加载失败" :message="error.message" @retry="load"/></template>
|
||||
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template><template #actions="{row}"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="neutral" class="density-action" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})">查看</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleEdit)" @click="openEdit(row as VehicleDetail)">编辑</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleStatus)" @click="openStatus(row as VehicleDetail)">状态</el-button></div></template><template #status="{row}"><el-tag>{{vehicleStatusLabels[(row as VehicleDetail).status]}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section>
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport) && access.can('platform:download:list')" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出</el-button></template><template #actions="{row}"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="neutral" class="density-action" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})">查看</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleEdit)" @click="openEdit(row as VehicleDetail)">编辑</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleStatus)" @click="openStatus(row as VehicleDetail)">状态</el-button></div></template><template #status="{row}"><el-tag>{{vehicleStatusLabels[(row as VehicleDetail).status]}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section>
|
||||
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editingId?'编辑车辆':'添加车辆'" width="min(1080px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form class="density-form" label-position="top"><div class="form-grid"><el-form-item label="车辆归属"><el-select v-model="form.ownership"><el-option label="自有车辆" value="OWNED"/><el-option label="外派车辆" value="DISPATCHED"/></el-select></el-form-item><el-form-item label="车牌号"><el-input v-model="form.plateNumber" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车型"><el-input v-model="form.model" placeholder="请输入车辆型号" /></el-form-item><el-form-item label="油耗(L/100km)"><el-input-number v-model="form.fuelConsumptionPer100Km" :min="0.01" :precision="2" placeholder="请输入百公里油耗" /></el-form-item><el-form-item label="当前公里码(km)"><el-input-number v-model="form.mileageKm" :min="0" placeholder="请输入当前里程" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="司机 / 外派单位"><el-input v-model="form.driverOrDispatchCompany" placeholder="请输入司机或外派单位" /></el-form-item><el-form-item label="区域"><el-select v-model="form.region"><el-option label="国内" value="DOMESTIC"/><el-option label="海外" value="OVERSEAS"/></el-select></el-form-item><el-form-item v-if="!editingId" label="状态"><el-select v-model="form.status"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></el-form-item><el-form-item label="最大标准托位"><el-input-number v-model="form.maxPalletSpaces" :min="1" :max="10000" :precision="0" placeholder="请输入实际托位" /></el-form-item><el-form-item label="最大载重(kg)"><el-input-number v-model="form.maxLoadKg" :min="0.001" :precision="3" placeholder="请输入核定载重" /></el-form-item><el-form-item label="可用容积(m³)"><el-input-number v-model="form.maxVolumeCbm" :min="0.001" :precision="3" placeholder="请输入实际容积" /></el-form-item><p class="capacity-hint">三项容量须同时确认;未知时可全部留空,确认后才能安排出车。</p><el-form-item label="备注"><el-input v-model="form.remark" placeholder="请输入备注(选填)" /></el-form-item></div></el-form></AppDialog><AppDialog class="admin-density-dialog" v-model="statusOpen" title="变更车辆状态" width="min(480px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="submitStatus"><el-select v-model="statusTarget" style="width:100%"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></AppDialog></ListPageLayout></AdminPage></template>
|
||||
<style scoped>.vehicle-panel{min-width:0;overflow:hidden}.query-panel{min-width:0}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-5) var(--admin-space-6)}.capacity-hint{grid-column:1/-1;margin:0;color:var(--cc-muted)}</style>
|
||||
|
||||
Reference in New Issue
Block a user