feat(frontend): add inspection/replacement APIs and shared InspectionForm component

- Create inspection.ts with template CRUD and record APIs
- Create vehicle-replacement.ts with full CRUD + BPM approval APIs
- Update return-order.ts with new fields and 5 new endpoints
- Create shared InspectionForm.vue component with category grouping,
  multi-input-type rendering, auto-save, and image upload
- Update barrel exports in index.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-03-13 10:27:46 +08:00
parent 7314b8e0c6
commit 594912d2b8
6 changed files with 789 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
export * from './parking';
export * from './customer';
export * from './supplier';
export * from './vehicle-model';
export * from './vehicle-registration';
export * from './contract';
export * from './vehicle-prepare';
export * from './delivery-task';
export * from './delivery-order';
export * from './return-order';
export * from './vehicle';
export * from './inspection';
export * from './vehicle-replacement';

View File

@@ -0,0 +1,100 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace InspectionApi {
export interface Template {
id?: number;
code: string;
name: string;
bizType: number;
vehicleType?: string;
status: number;
remark?: string;
items?: TemplateItem[];
}
export interface TemplateItem {
id?: number;
category: string;
itemName: string;
itemCode: string;
inputType: string;
sort: number;
required: number;
}
export interface RecordDetail {
id: number;
recordCode: string;
templateId: number;
sourceType: number;
sourceId: number;
vehicleId: number;
inspectorName?: string;
inspectionTime?: string;
status: number;
overallResult?: number;
remark?: string;
items: RecordItem[];
}
export interface RecordItem {
id: number;
itemCode: string;
category: string;
itemName: string;
inputType: string;
result?: number;
value?: string;
remark?: string;
imageUrls?: string;
}
}
export function getInspectionTemplatePage(params: PageParam) {
return requestClient.get<PageResult<InspectionApi.Template>>(
'/asset/inspection-template/page',
{ params },
);
}
export function getInspectionTemplate(id: number) {
return requestClient.get<InspectionApi.Template>(
`/asset/inspection-template/get?id=${id}`,
);
}
export function createInspectionTemplate(data: InspectionApi.Template) {
return requestClient.post('/asset/inspection-template/create', data);
}
export function updateInspectionTemplate(data: InspectionApi.Template) {
return requestClient.put('/asset/inspection-template/update', data);
}
export function deleteInspectionTemplate(id: number) {
return requestClient.delete(`/asset/inspection-template/delete?id=${id}`);
}
export function getInspectionRecord(id: number) {
return requestClient.get<InspectionApi.RecordDetail>(
`/asset/inspection-record/get?id=${id}`,
);
}
export function updateInspectionRecordItem(data: {
id: number;
result?: number;
value?: string;
remark?: string;
imageUrls?: string;
}) {
return requestClient.put('/asset/inspection-record/update-item', data);
}
export function completeInspection(id: number, inspectorName: string) {
return requestClient.post(
`/asset/inspection-record/complete?id=${id}&inspectorName=${inspectorName}`,
);
}

View File

@@ -0,0 +1,121 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AssetReturnOrderApi {
export interface ReturnOrderVehicle {
id?: number;
vehicleId?: number;
plateNo?: string;
vin?: string;
brand?: string;
model?: string;
returnMileage?: number;
returnHydrogenLevel?: number;
deliveryHydrogenLevel?: number;
hydrogenDiff?: number;
hydrogenUnitPrice?: number;
hydrogenRefundAmount?: number;
checkList?: string;
defectPhotos?: string;
vehicleDamageFee?: number;
toolDamageFee?: number;
unpaidMaintenanceFee?: number;
unpaidRepairFee?: number;
otherFee?: number;
inspectionRecordId?: number;
}
export interface ReturnOrder {
id?: number;
orderCode?: string;
contractId: number;
contractCode?: string;
projectName?: string;
customerId?: number;
customerName?: string;
returnDate: string;
returnPerson: string;
returnLocation?: string;
returnReason?: string;
returnReasonDesc?: string;
totalRefundAmount?: number;
depositRefund?: number;
hydrogenRefund?: number;
otherCharges?: number;
returnPhotos?: string;
sourceType?: number;
sourceId?: number;
deliveryOrderId?: number;
status?: number;
approvalStatus?: number;
vehicles?: ReturnOrderVehicle[];
createTime?: Date;
}
}
export function getReturnOrderPage(params: PageParam) {
return requestClient.get<PageResult<AssetReturnOrderApi.ReturnOrder>>(
'/asset/return-order/page',
{ params },
);
}
export function getReturnOrder(id: number) {
return requestClient.get<AssetReturnOrderApi.ReturnOrder>(
`/asset/return-order/get?id=${id}`,
);
}
export function createReturnOrder(data: AssetReturnOrderApi.ReturnOrder) {
return requestClient.post('/asset/return-order/create', data);
}
export function updateReturnOrder(data: AssetReturnOrderApi.ReturnOrder) {
return requestClient.put('/asset/return-order/update', data);
}
export function deleteReturnOrder(id: number) {
return requestClient.delete(`/asset/return-order/delete?id=${id}`);
}
export function completeReturnOrderInspection(id: number) {
return requestClient.put(`/asset/return-order/complete-inspection?id=${id}`);
}
export function settleReturnOrder(id: number) {
return requestClient.put(`/asset/return-order/settle?id=${id}`);
}
export function createReturnOrderFromDelivery(
deliveryOrderId: number,
vehicleIds: number[],
) {
return requestClient.post('/asset/return-order/create-from-delivery', {
deliveryOrderId,
vehicleIds,
});
}
export function startVehicleInspection(returnOrderVehicleId: number) {
return requestClient.post(
`/asset/return-order/start-vehicle-inspection?returnOrderVehicleId=${returnOrderVehicleId}`,
);
}
export function completeVehicleInspection(returnOrderVehicleId: number) {
return requestClient.post(
`/asset/return-order/complete-vehicle-inspection?returnOrderVehicleId=${returnOrderVehicleId}`,
);
}
export function submitReturnOrderApproval(id: number) {
return requestClient.post(`/asset/return-order/submit-approval?id=${id}`, {});
}
export function withdrawReturnOrderApproval(id: number) {
return requestClient.post(
`/asset/return-order/withdraw-approval?id=${id}`,
{},
);
}

View File

@@ -0,0 +1,82 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AssetVehicleReplacementApi {
export interface VehicleReplacement {
id?: number;
replacementCode?: string;
replacementType: number;
contractId?: number;
contractCode?: string;
projectName?: string;
customerId?: number;
customerName?: string;
originalVehicleId?: number;
originalPlateNo?: string;
originalVin?: string;
newVehicleId?: number;
newPlateNo?: string;
newVin?: string;
deliveryOrderId?: number;
replacementReason?: string;
expectedDate?: string;
actualDate?: string;
returnDate?: string;
status?: number;
approvalStatus?: number;
bpmInstanceId?: string;
remark?: string;
creator?: string;
createTime?: Date;
}
}
export function getVehicleReplacementPage(params: PageParam) {
return requestClient.get<
PageResult<AssetVehicleReplacementApi.VehicleReplacement>
>('/asset/vehicle-replacement/page', { params });
}
export function getVehicleReplacement(id: number) {
return requestClient.get<AssetVehicleReplacementApi.VehicleReplacement>(
`/asset/vehicle-replacement/get?id=${id}`,
);
}
export function createVehicleReplacement(
data: AssetVehicleReplacementApi.VehicleReplacement,
) {
return requestClient.post('/asset/vehicle-replacement/create', data);
}
export function updateVehicleReplacement(
data: AssetVehicleReplacementApi.VehicleReplacement,
) {
return requestClient.put('/asset/vehicle-replacement/update', data);
}
export function deleteVehicleReplacement(id: number) {
return requestClient.delete(`/asset/vehicle-replacement/delete?id=${id}`);
}
export function submitVehicleReplacementApproval(id: number) {
return requestClient.post(
`/asset/vehicle-replacement/submit-approval?id=${id}`,
{},
);
}
export function withdrawVehicleReplacementApproval(id: number) {
return requestClient.post(
`/asset/vehicle-replacement/withdraw-approval?id=${id}`,
{},
);
}
export function confirmVehicleReplacementReturn(id: number) {
return requestClient.post(
`/asset/vehicle-replacement/confirm-return?id=${id}`,
{},
);
}

View File

@@ -0,0 +1,290 @@
<script lang="ts" setup>
import type { InspectionApi } from '#/api/asset/inspection';
import { computed, ref, watch } from 'vue';
import {
Button,
Collapse,
CollapsePanel,
Image,
Input,
InputNumber,
message,
RadioButton,
RadioGroup,
Space,
Spin,
Upload,
} from 'ant-design-vue';
import {
completeInspection,
getInspectionRecord,
updateInspectionRecordItem,
} from '#/api/asset/inspection';
const props = defineProps<{
recordId: number;
readonly?: boolean;
onComplete?: () => void;
}>();
const loading = ref(false);
const submitting = ref(false);
const record = ref<InspectionApi.RecordDetail>();
const inspectorName = ref('');
// Group items by category
const groupedItems = computed(() => {
if (!record.value?.items) return [];
const map = new Map<string, InspectionApi.RecordItem[]>();
for (const item of record.value.items) {
const list = map.get(item.category) || [];
list.push(item);
map.set(item.category, list);
}
return [...map.entries()].map(([category, items]) => ({
category,
items,
}));
});
// Active collapse keys (all open by default)
const activeKeys = computed(() =>
groupedItems.value.map((g) => g.category),
);
// Whether inspection is already completed
const isCompleted = computed(() => record.value?.status === 2);
// Load inspection record
async function loadRecord() {
if (!props.recordId) return;
loading.value = true;
try {
record.value = await getInspectionRecord(props.recordId);
inspectorName.value = record.value.inspectorName || '';
} finally {
loading.value = false;
}
}
watch(
() => props.recordId,
() => loadRecord(),
{ immediate: true },
);
// Save single item on change
async function handleItemChange(item: InspectionApi.RecordItem) {
if (props.readonly || isCompleted.value) return;
try {
await updateInspectionRecordItem({
id: item.id,
result: item.result,
value: item.value,
remark: item.remark,
imageUrls: item.imageUrls,
});
} catch {
message.error('保存检查项失败');
}
}
// Complete inspection
async function handleComplete() {
if (!inspectorName.value) {
message.warning('请输入验车人姓名');
return;
}
submitting.value = true;
try {
await completeInspection(props.recordId, inspectorName.value);
message.success('验车完成');
await loadRecord();
props.onComplete?.();
} catch {
message.error('完成验车失败');
} finally {
submitting.value = false;
}
}
// Result options for checkbox type
const resultOptions = [
{ label: '合格', value: 1 },
{ label: '不合格', value: 2 },
{ label: '不适用', value: 3 },
];
// Parse image URLs
function parseImageUrls(urls?: string): string[] {
if (!urls) return [];
return urls.split(',').filter(Boolean);
}
// Handle image upload (simplified - assumes backend returns URL)
function handleImageUpload(item: InspectionApi.RecordItem, info: any) {
if (info.file.status === 'done' && info.file.response) {
const url = info.file.response.data;
const urls = parseImageUrls(item.imageUrls);
urls.push(url);
item.imageUrls = urls.join(',');
handleItemChange(item);
}
}
// Determine if inputs should be disabled
const isDisabled = computed(() => props.readonly || isCompleted.value);
</script>
<template>
<Spin :spinning="loading">
<div class="inspection-form">
<!-- Inspector name -->
<div class="mb-4 flex items-center gap-4">
<label class="font-medium">验车人</label>
<Input
v-model:value="inspectorName"
:disabled="isDisabled"
placeholder="请输入验车人姓名"
style="width: 200px"
/>
<span v-if="record?.inspectionTime" class="text-gray-500">
验车时间{{ record.inspectionTime }}
</span>
<span
v-if="isCompleted"
class="rounded bg-green-100 px-2 py-1 text-sm text-green-700"
>
已完成
</span>
</div>
<!-- Inspection items grouped by category -->
<Collapse :active-key="activeKeys" :bordered="true">
<CollapsePanel
v-for="group in groupedItems"
:key="group.category"
:header="group.category"
>
<div class="space-y-4">
<div
v-for="item in group.items"
:key="item.id"
class="rounded border border-gray-200 p-3"
>
<div class="mb-2 flex items-start justify-between">
<span class="font-medium">{{ item.itemName }}</span>
<span class="text-xs text-gray-400">{{ item.itemCode }}</span>
</div>
<div class="flex flex-wrap items-start gap-4">
<!-- Checkbox type: radio group -->
<div v-if="item.inputType === 'checkbox'" class="flex-1">
<RadioGroup
v-model:value="item.result"
:disabled="isDisabled"
button-style="solid"
size="small"
@change="handleItemChange(item)"
>
<RadioButton
v-for="opt in resultOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</RadioButton>
</RadioGroup>
</div>
<!-- Number type: input number -->
<div v-else-if="item.inputType === 'number'" class="flex-1">
<InputNumber
v-model:value="item.value"
:disabled="isDisabled"
placeholder="请输入数值"
size="small"
style="width: 200px"
@change="handleItemChange(item)"
/>
</div>
<!-- Text type: input -->
<div v-else class="flex-1">
<Input
v-model:value="item.value"
:disabled="isDisabled"
placeholder="请输入"
size="small"
style="width: 300px"
@change="handleItemChange(item)"
/>
</div>
<!-- Remark -->
<div class="flex-1">
<Input
v-model:value="item.remark"
:disabled="isDisabled"
placeholder="备注"
size="small"
@change="handleItemChange(item)"
/>
</div>
</div>
<!-- Images -->
<div class="mt-2">
<Space :size="8" wrap>
<Image
v-for="(url, idx) in parseImageUrls(item.imageUrls)"
:key="idx"
:src="url"
:width="60"
:height="60"
style="object-fit: cover; border-radius: 4px"
/>
<Upload
v-if="!isDisabled"
:show-upload-list="false"
action="/admin-api/infra/file/upload"
name="file"
accept="image/*"
@change="(info: any) => handleImageUpload(item, info)"
>
<Button size="small" type="dashed">上传图片</Button>
</Upload>
</Space>
</div>
</div>
</div>
</CollapsePanel>
</Collapse>
<!-- Overall remark -->
<div v-if="record" class="mt-4">
<label class="mb-1 block font-medium">整体备注</label>
<Input.TextArea
v-model:value="record.remark"
:disabled="isDisabled"
:rows="3"
placeholder="请输入整体备注"
/>
</div>
<!-- Complete button -->
<div v-if="!isDisabled" class="mt-4 text-right">
<Button
type="primary"
:loading="submitting"
@click="handleComplete"
>
完成验车
</Button>
</div>
</div>
</Spin>
</template>

View File

@@ -0,0 +1,183 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AssetReturnOrderApi } from '#/api/asset/return-order';
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
completeReturnOrderInspection,
deleteReturnOrder,
getReturnOrderPage,
settleReturnOrder,
} from '#/api/asset/return-order';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
function handleRefresh() {
gridApi.query();
}
function handleCreate() {
formModalApi.setData(null).open();
}
function handleEdit(row: AssetReturnOrderApi.ReturnOrder) {
formModalApi.setData(row).open();
}
async function handleDelete(row: AssetReturnOrderApi.ReturnOrder) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.orderCode]),
duration: 0,
});
try {
await deleteReturnOrder(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.orderCode]));
handleRefresh();
} finally {
hideLoading();
}
}
async function handleCompleteInspection(row: AssetReturnOrderApi.ReturnOrder) {
await confirm('确认验车完成?完成后状态将变更为验车完成。');
await completeReturnOrderInspection(row.id!);
message.success('验车已完成');
handleRefresh();
}
async function handleSettle(row: AssetReturnOrderApi.ReturnOrder) {
await confirm('确认结算还车单?结算后不可修改。');
await settleReturnOrder(row.id!);
message.success('还车单已结算');
handleRefresh();
}
/** 根据状态动态生成操作菜单 */
function getRowActions(row: AssetReturnOrderApi.ReturnOrder) {
const actions: any[] = [];
// 查看 - 始终可见
actions.push({
auth: ['asset:return-order:query'],
icon: ACTION_ICON.VIEW,
label: '查看',
onClick: () => message.info('查看功能开发中'),
type: 'link',
});
// 待验车 → 可编辑
if (row.status === 0) {
actions.push({
auth: ['asset:return-order:update'],
icon: ACTION_ICON.EDIT,
label: $t('common.edit'),
onClick: () => handleEdit(row),
type: 'link',
});
}
// 待验车 → 完成验车
if (row.status === 0) {
actions.push({
auth: ['asset:return-order:update'],
label: '完成验车',
onClick: () => handleCompleteInspection(row),
type: 'link',
});
}
// 验车完成 → 结算
if (row.status === 1) {
actions.push({
auth: ['asset:return-order:update'],
label: '结算',
onClick: () => handleSettle(row),
type: 'link',
});
}
// 待验车 → 可删除
if (row.status === 0) {
actions.push({
auth: ['asset:return-order:delete'],
danger: true,
icon: ACTION_ICON.DELETE,
label: $t('common.delete'),
popConfirm: {
confirm: () => handleDelete(row),
title: $t('ui.actionMessage.deleteConfirm', [row.orderCode]),
},
type: 'link',
});
}
return actions;
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getReturnOrderPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<AssetReturnOrderApi.ReturnOrder>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<Grid table-title="还车管理">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['还车单']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['asset:return-order:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction :actions="getRowActions(row)" />
</template>
</Grid>
</Page>
</template>