feat(energy): 实现加氢记录管理页面(含三步导入)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
134
apps/web-antd/src/views/energy/hydrogen-record/data.ts
Normal file
134
apps/web-antd/src/views/energy/hydrogen-record/data.ts
Normal 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),
|
||||
];
|
||||
}
|
||||
184
apps/web-antd/src/views/energy/hydrogen-record/index.vue
Normal file
184
apps/web-antd/src/views/energy/hydrogen-record/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user