feat(energy): 实现加氢记录管理页面(含三步导入)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-03-16 01:27:31 +08:00
parent 69afb41df5
commit c3999819c9
3 changed files with 619 additions and 0 deletions

View File

@@ -0,0 +1,134 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { EnergyHydrogenRecordApi } from '#/api/energy/hydrogen-record';
import { useActionColumn } from '#/utils/table';
import { getRangePickerDefaultProps } from '#/utils';
export const SOURCE_TYPE_OPTIONS = [
{ label: 'Excel导入', value: 1, color: 'blue' },
{ label: 'API同步', value: 2, color: 'green' },
{ label: '手动录入', value: 3, color: 'default' },
];
export const MATCH_STATUS_OPTIONS = [
{ label: '待匹配', value: 0, color: 'orange' },
{ label: '已匹配', value: 1, color: 'green' },
{ label: '匹配失败', value: 2, color: 'red' },
];
/** 搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'stationId',
label: '加氢站',
component: 'Input',
componentProps: {
placeholder: '请输入加氢站',
allowClear: true,
},
},
{
fieldName: 'plateNumber',
label: '车牌号',
component: 'Input',
componentProps: {
placeholder: '请输入车牌号',
allowClear: true,
},
},
{
fieldName: 'hydrogenDate',
label: '加氢日期',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
class: 'w-full',
},
},
{
fieldName: 'matchStatus',
label: '匹配状态',
component: 'Select',
componentProps: {
options: MATCH_STATUS_OPTIONS,
placeholder: '请选择匹配状态',
allowClear: true,
},
},
{
fieldName: 'sourceType',
label: '数据来源',
component: 'Select',
componentProps: {
options: SOURCE_TYPE_OPTIONS,
placeholder: '请选择数据来源',
allowClear: true,
},
},
];
}
/** 表格列配置 */
export function useGridColumns(): VxeTableGridOptions<EnergyHydrogenRecordApi.Record>['columns'] {
return [
{
field: 'stationName',
title: '站点名称',
minWidth: 150,
fixed: 'left',
},
{
field: 'plateNumber',
title: '车牌号',
minWidth: 120,
},
{
field: 'hydrogenDate',
title: '加氢日期',
minWidth: 120,
formatter: 'formatDate',
align: 'center',
},
{
field: 'hydrogenQuantity',
title: '加氢量(KG)',
minWidth: 100,
align: 'right',
},
{
field: 'unitPrice',
title: '单价',
minWidth: 80,
align: 'right',
},
{
field: 'amount',
title: '金额',
minWidth: 100,
align: 'right',
},
{
field: 'mileage',
title: '里程数',
minWidth: 80,
align: 'right',
},
{
field: 'sourceType',
title: '数据来源',
minWidth: 100,
align: 'center',
slots: { default: 'sourceType' },
},
{
field: 'matchStatus',
title: '匹配状态',
minWidth: 100,
align: 'center',
slots: { default: 'matchStatus' },
},
useActionColumn(2),
];
}

View File

@@ -0,0 +1,184 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { EnergyHydrogenRecordApi } from '#/api/energy/hydrogen-record';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Tag, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
batchMatch,
deleteHydrogenRecord,
exportHydrogenRecord,
getHydrogenRecordPage,
} from '#/api/energy/hydrogen-record';
import { $t } from '#/locales';
import {
MATCH_STATUS_OPTIONS,
SOURCE_TYPE_OPTIONS,
useGridColumns,
useGridFormSchema,
} from './data';
import ImportModal from './modules/import-modal.vue';
const importModalRef = ref<InstanceType<typeof ImportModal>>();
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 打开导入弹窗 */
function handleImport() {
importModalRef.value?.open();
}
/** 导出 */
async function handleExport() {
const formValues = await gridApi.formApi?.getValues();
const blob = await exportHydrogenRecord(formValues ?? {});
downloadFileFromBlobPart({ fileName: '加氢记录.xlsx', source: blob });
}
/** 批量匹配 */
async function handleBatchMatch() {
const hideLoading = message.loading({ content: '正在匹配...', duration: 0 });
try {
const result = await batchMatch();
message.success(
`匹配完成:成功 ${result.successCount} 条,失败 ${result.failCount}`,
);
handleRefresh();
} finally {
hideLoading();
}
}
/** 删除记录 */
async function handleDelete(row: EnergyHydrogenRecordApi.Record) {
await deleteHydrogenRecord(row.id!);
message.success($t('ui.actionMessage.operationSuccess'));
handleRefresh();
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
collapsed: true,
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getHydrogenRecordPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<EnergyHydrogenRecordApi.Record>,
});
</script>
<template>
<Page auto-content-height>
<ImportModal ref="importModalRef" @success="handleRefresh" />
<Grid table-title="加氢记录列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: 'Excel导入',
type: 'primary',
icon: ACTION_ICON.UPLOAD,
auth: ['energy:hydrogen-record:import'],
onClick: handleImport,
},
{
label: $t('ui.actionTitle.export'),
icon: ACTION_ICON.DOWNLOAD,
auth: ['energy:hydrogen-record:export'],
onClick: handleExport,
},
{
label: '重新匹配',
icon: ACTION_ICON.REFRESH,
auth: ['energy:hydrogen-record:batch-match'],
onClick: handleBatchMatch,
},
]"
/>
</template>
<template #sourceType="{ row }">
<Tag
v-if="row.sourceType != null"
:color="
SOURCE_TYPE_OPTIONS.find((o) => o.value === row.sourceType)?.color
"
>
{{
SOURCE_TYPE_OPTIONS.find((o) => o.value === row.sourceType)
?.label ?? row.sourceType
}}
</Tag>
<span v-else></span>
</template>
<template #matchStatus="{ row }">
<Tag
v-if="row.matchStatus != null"
:color="
MATCH_STATUS_OPTIONS.find((o) => o.value === row.matchStatus)?.color
"
>
{{
MATCH_STATUS_OPTIONS.find((o) => o.value === row.matchStatus)
?.label ?? row.matchStatus
}}
</Tag>
<span v-else></span>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['energy:hydrogen-record:update'],
onClick: () => {},
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['energy:hydrogen-record:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.plateNumber]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,301 @@
<script lang="ts" setup>
import { ref, onUnmounted } from 'vue';
import {
Button,
Modal,
Progress,
Radio,
Select,
Statistic,
Steps,
Table,
Tag,
Upload,
message,
} from 'ant-design-vue';
import { InboxOutlined } from '@ant-design/icons-vue';
import {
importPreview,
importConfirm,
getImportProgress,
} from '#/api/energy/hydrogen-record';
import type { EnergyHydrogenRecordApi } from '#/api/energy/hydrogen-record';
import { getStationConfigSimpleList } from '#/api/energy/station-config';
const emit = defineEmits(['success']);
const visible = ref(false);
const currentStep = ref(0);
const loading = ref(false);
// Step 1 data
const stationId = ref<number>();
const stationOptions = ref<{ label: string; value: number }[]>([]);
const fileList = ref<any[]>([]);
// Step 2 data
const previewData = ref<EnergyHydrogenRecordApi.ImportPreview>();
const duplicateStrategy = ref('skip');
// Step 3 data
const progressData = ref<EnergyHydrogenRecordApi.ImportProgress>({
current: 0,
total: 0,
status: '',
});
const importResult = ref<Record<string, number>>();
let progressTimer: ReturnType<typeof setInterval> | null = null;
function open() {
visible.value = true;
currentStep.value = 0;
stationId.value = undefined;
fileList.value = [];
previewData.value = undefined;
duplicateStrategy.value = 'skip';
progressData.value = { current: 0, total: 0, status: '' };
importResult.value = undefined;
loadStations();
}
async function loadStations() {
const list = await getStationConfigSimpleList();
stationOptions.value = list.map((s) => ({
label: s.stationName,
value: s.stationId,
}));
}
// Step 1 -> Step 2: Upload and preview
async function handleNextStep() {
if (!stationId.value) {
message.warning('请选择加氢站');
return;
}
if (fileList.value.length === 0) {
message.warning('请上传文件');
return;
}
loading.value = true;
try {
const file = fileList.value[0].originFileObj || fileList.value[0];
const result = await importPreview(stationId.value, file);
previewData.value = result;
currentStep.value = 1;
} catch (e: any) {
message.error(e.message || '预览失败');
} finally {
loading.value = false;
}
}
// Step 2 -> Step 3: Confirm import
async function handleConfirmImport() {
if (!previewData.value) return;
loading.value = true;
try {
importResult.value = await importConfirm(
previewData.value.batchNo,
duplicateStrategy.value,
);
currentStep.value = 2;
startProgressPolling();
} catch (e: any) {
message.error(e.message || '导入失败');
} finally {
loading.value = false;
}
}
function startProgressPolling() {
if (!previewData.value) return;
const batchNo = previewData.value.batchNo;
progressTimer = setInterval(async () => {
try {
const progress = await getImportProgress(batchNo);
progressData.value = progress;
if (
progress.status === 'completed' ||
progress.status === 'failed' ||
progress.status === 'not_found'
) {
stopProgressPolling();
}
} catch {
stopProgressPolling();
}
}, 2000);
}
function stopProgressPolling() {
if (progressTimer) {
clearInterval(progressTimer);
progressTimer = null;
}
}
function handleClose() {
stopProgressPolling();
visible.value = false;
if (currentStep.value === 2) {
emit('success');
}
}
onUnmounted(() => stopProgressPolling());
// Preview table columns
const previewColumns = [
{ title: '行号', dataIndex: 'rowNum', width: 60 },
{ title: '车牌号', dataIndex: 'plateNumber', width: 100 },
{ title: '加氢日期', dataIndex: 'hydrogenDate', width: 100 },
{ title: '加氢量(KG)', dataIndex: 'hydrogenQuantity', width: 100 },
{ title: '单价', dataIndex: 'unitPrice', width: 80 },
{ title: '金额', dataIndex: 'amount', width: 80 },
{ title: '里程数', dataIndex: 'mileage', width: 80 },
];
// Handle file before upload (prevent auto-upload)
function beforeUpload(file: File) {
fileList.value = [file];
return false;
}
defineExpose({ open });
</script>
<template>
<Modal
v-model:open="visible"
title="Excel 批量导入"
:width="800"
:footer="null"
:mask-closable="false"
@cancel="handleClose"
>
<Steps :current="currentStep" class="mb-6">
<Steps.Step title="上传文件" />
<Steps.Step title="预览确认" />
<Steps.Step title="导入结果" />
</Steps>
<!-- Step 1: Upload -->
<div v-if="currentStep === 0">
<div class="mb-4">
<label class="mb-2 block font-medium">选择加氢站</label>
<Select
v-model:value="stationId"
:options="stationOptions"
placeholder="请选择加氢站"
class="w-full"
/>
</div>
<Upload.Dragger
:file-list="fileList"
:before-upload="beforeUpload"
:max-count="1"
accept=".xlsx,.xls"
>
<p class="ant-upload-drag-icon"><InboxOutlined /></p>
<p class="ant-upload-text">点击或拖拽文件到此区域上传</p>
<p class="ant-upload-hint">支持 .xlsx, .xls 格式</p>
</Upload.Dragger>
<div class="mt-4 flex justify-between">
<a href="javascript:void(0)">下载导入模板</a>
<Button type="primary" :loading="loading" @click="handleNextStep">
下一步
</Button>
</div>
</div>
<!-- Step 2: Preview -->
<div v-if="currentStep === 1 && previewData">
<div class="mb-4 flex gap-4">
<Statistic title="总行数" :value="previewData.totalCount" />
<Statistic
title="有效"
:value="previewData.validCount"
class="text-green-600"
/>
<Statistic
title="重复"
:value="previewData.duplicateCount"
class="text-orange-500"
/>
<Statistic
title="错误"
:value="previewData.errorCount"
class="text-red-500"
/>
</div>
<Table
:data-source="[...previewData.records, ...previewData.duplicates]"
:columns="previewColumns"
:row-class-name="
(record: any) => (record.isDuplicate ? 'bg-yellow-50' : '')
"
:pagination="false"
:scroll="{ y: 300 }"
size="small"
row-key="rowNum"
/>
<div v-if="previewData.errors?.length" class="mt-2">
<Tag color="red">{{ previewData.errors.length }} 条错误记录</Tag>
</div>
<div class="mt-4">
<Radio.Group v-model:value="duplicateStrategy">
<Radio value="skip">跳过重复记录</Radio>
<Radio value="overwrite">覆盖重复记录</Radio>
</Radio.Group>
</div>
<div class="mt-4 flex justify-end gap-2">
<Button @click="currentStep = 0">上一步</Button>
<Button type="primary" :loading="loading" @click="handleConfirmImport">
确认导入
</Button>
</div>
</div>
<!-- Step 3: Result -->
<div v-if="currentStep === 2">
<div class="py-8 text-center">
<Progress
:percent="
progressData.total > 0
? Math.round((progressData.current / progressData.total) * 100)
: 0
"
:status="
progressData.status === 'failed'
? 'exception'
: progressData.status === 'completed'
? 'success'
: 'active'
"
/>
<div v-if="importResult" class="mt-4">
<p>
成功导入
<span class="font-bold text-green-600">{{
importResult.successCount
}}</span>
</p>
<p v-if="(importResult.failCount ?? 0) > 0">
失败
<span class="font-bold text-red-500">{{
importResult.failCount
}}</span>
</p>
</div>
</div>
<div class="flex justify-end">
<Button type="primary" @click="handleClose">关闭</Button>
</div>
</div>
</Modal>
</template>