feat(energy): 前端优化完成

- 简化导入交互(3步→1步)
- 批量审核功能
- 快速生成账单(本月/上月)
- 批量价格配置(前端界面)

用户体验优化:
- 一键导入,自动匹配
- 批量审核,提高效率
- 快捷时间选择
- 清晰的操作反馈
This commit is contained in:
kkfluous
2026-03-16 13:22:46 +08:00
parent f62ff30c64
commit 17b6c99b4f
16 changed files with 841 additions and 409 deletions

View File

@@ -34,14 +34,21 @@ export namespace EnergyBillApi {
}
export interface GenerateReq {
customerId: number;
contractId: number;
stationId: number;
billPeriodStart: string;
billPeriodEnd: string;
startDate?: string;
endDate?: string;
customerId?: number;
contractId?: number;
stationId?: number;
billPeriodStart?: string;
billPeriodEnd?: string;
energyType?: number;
}
export interface GenerateResult {
total?: number;
billIds?: number[];
}
export interface Adjustment {
id?: number;
billId?: number;
@@ -72,7 +79,7 @@ export function getBill(id: number) {
}
export function generateBill(data: EnergyBillApi.GenerateReq) {
return requestClient.post<number>('/energy/bill/generate', data);
return requestClient.post<EnergyBillApi.GenerateResult>('/energy/bill/generate', data);
}
export function batchGenerateByPeriod(billPeriod: string) {

View File

@@ -51,10 +51,22 @@ export function auditHydrogenDetail(id: number, approved: boolean, remark?: stri
});
}
export function batchAuditHydrogenDetail(ids: number[], approved: boolean, remark?: string) {
return requestClient.post('/energy/hydrogen-detail/batch-audit', null, {
params: { ids: ids.join(','), approved, remark },
});
export interface BatchAuditReqVO {
ids: number[];
passed: boolean;
remark?: string;
}
export interface BatchAuditResultDTO {
total: number;
successCount: number;
failCount: number;
successIds: number[];
failIds: number[];
}
export function batchAuditHydrogenDetail(data: BatchAuditReqVO) {
return requestClient.post<BatchAuditResultDTO>('/energy/hydrogen-detail/batch-audit', data);
}
export function exportHydrogenDetail(params: any) {

View File

@@ -21,38 +21,7 @@ export namespace EnergyHydrogenRecordApi {
createTime?: string;
}
export interface ImportPreview {
batchNo: string;
totalCount: number;
validCount: number;
duplicateCount: number;
errorCount: number;
records: RecordPreviewItem[];
duplicates: RecordPreviewItem[];
errors: ImportErrorItem[];
}
export interface RecordPreviewItem {
rowNum: number;
plateNumber: string;
hydrogenDate: string;
hydrogenQuantity: number;
unitPrice: number;
amount: number;
mileage: number;
isDuplicate: boolean;
}
export interface ImportErrorItem {
rowNum: number;
reason: string;
}
export interface ImportProgress {
current: number;
total: number;
status: string;
}
}
export function getHydrogenRecordPage(params: PageParam) {
@@ -85,31 +54,17 @@ export function exportHydrogenRecord(params: any) {
return requestClient.download('/energy/hydrogen-record/export-excel', { params });
}
export function importPreview(stationId: number, file: File) {
const formData = new FormData();
formData.append('file', file);
formData.append('stationId', String(stationId));
return requestClient.post<EnergyHydrogenRecordApi.ImportPreview>(
'/energy/hydrogen-record/import-preview',
formData,
);
export interface ImportResultDTO {
total: number;
successCount: number;
failCount: number;
successIds: number[];
failIds: number[];
}
export function importConfirm(batchNo: string, duplicateStrategy: string) {
return requestClient.post<Record<string, number>>(
'/energy/hydrogen-record/import-confirm',
null,
{ params: { batchNo, duplicateStrategy } },
export function importHydrogenRecords(data: FormData) {
return requestClient.post<ImportResultDTO>(
'/energy/hydrogen-record/import',
data,
);
}
export function getImportProgress(batchNo: string) {
return requestClient.get<EnergyHydrogenRecordApi.ImportProgress>(
'/energy/hydrogen-record/import-progress',
{ params: { batchNo } },
);
}
export function batchMatch() {
return requestClient.post<Record<string, number>>('/energy/hydrogen-record/batch-match');
}

View File

@@ -36,3 +36,15 @@ export function updateStationPrice(data: EnergyStationPriceApi.Price) {
export function deleteStationPrice(id: number) {
return requestClient.delete('/energy/station-price/delete', { params: { id } });
}
export interface ImportResult {
total: number;
successCount: number;
failCount: number;
}
export function batchImportPrice(data: FormData) {
return requestClient.post<ImportResult>('/energy/station-price/batch-import', data, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}

View File

@@ -48,14 +48,14 @@ export function useGridFormSchema(): VbenFormSchema[] {
},
},
{
fieldName: 'billPeriod',
label: '账单周期',
component: 'DatePicker',
fieldName: 'dateRange',
label: '账单时间',
component: 'RangePicker',
componentProps: {
picker: 'month',
format: 'YYYY-MM',
valueFormat: 'YYYY-MM',
placeholder: '选择月份',
placeholder: ['开始月份', '结束月份'],
allowClear: true,
class: 'w-full',
},
@@ -137,7 +137,7 @@ export function useGridColumns(): VxeTableGridOptions<EnergyBillApi.Bill>['colum
align: 'center',
formatter: ({ row }: { row: EnergyBillApi.Bill }) => {
if (row.billPeriodStart) {
return row.billPeriodStart.substring(0, 7);
return String(row.billPeriodStart).substring(0, 7);
}
return '—';
},

View File

@@ -8,12 +8,14 @@ import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Tag, message } from 'ant-design-vue';
import { Modal, Tag, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteBill,
exportBill,
generateBill,
getBillPage,
} from '#/api/energy/bill';
import { $t } from '#/locales';
@@ -43,6 +45,41 @@ async function handleExport() {
downloadFileFromBlobPart({ fileName: '能源账单.xlsx', source: blob });
}
/** 快速生成账单 */
function handleQuickGenerate(type: 'current' | 'last') {
const now = dayjs();
let startDate: string;
let endDate: string;
let title: string;
if (type === 'current') {
startDate = now.startOf('month').format('YYYY-MM-DD');
endDate = now.format('YYYY-MM-DD');
title = '本月';
} else {
startDate = now.subtract(1, 'month').startOf('month').format('YYYY-MM-DD');
endDate = now.subtract(1, 'month').endOf('month').format('YYYY-MM-DD');
title = '上月';
}
Modal.confirm({
title: `生成${title}账单`,
content: `确定要生成 ${startDate}${endDate} 的账单吗?`,
onOk: async () => {
try {
const res = await generateBill({
startDate,
endDate,
});
message.success(`账单生成成功!共生成 ${res.total || 0} 张账单`);
handleRefresh();
} catch (error: any) {
message.error('生成账单失败:' + (error.message || '未知错误'));
}
},
});
}
/** 生成账单 */
function handleGenerate() {
generateModalRef.value?.open('single');
@@ -119,18 +156,24 @@ const [Grid, gridApi] = useVbenVxeGrid({
<TableAction
:actions="[
{
label: '生成账单',
label: '生成本月账单',
type: 'primary',
icon: 'ant-design:thunderbolt-outlined',
auth: ['energy:bill:create'],
onClick: () => handleQuickGenerate('current'),
},
{
label: '生成上月账单',
icon: 'ant-design:calendar-outlined',
auth: ['energy:bill:create'],
onClick: () => handleQuickGenerate('last'),
},
{
label: '自定义生成',
icon: ACTION_ICON.ADD,
auth: ['energy:bill:create'],
onClick: handleGenerate,
},
{
label: '批量生成',
icon: ACTION_ICON.ADD,
auth: ['energy:bill:create'],
onClick: handleBatchGenerate,
},
{
label: $t('ui.actionTitle.export'),
icon: ACTION_ICON.DOWNLOAD,

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { ref, watch } from 'vue';
import { Alert, DatePicker, InputNumber, message, Modal } from 'ant-design-vue';
import { Alert, Button, DatePicker, message, Modal, RangePicker, Select, Space } from 'ant-design-vue';
import dayjs from 'dayjs';
import {
@@ -9,6 +9,9 @@ import {
generateBill,
} from '#/api/energy/bill';
import type { EnergyBillApi } from '#/api/energy/bill';
import { getStationConfigSimpleList } from '#/api/energy/station-config';
import { getSimpleCustomerList } from '#/api/asset/customer';
import { getSimpleContractList } from '#/api/asset/contract';
const emit = defineEmits(['success']);
const visible = ref(false);
@@ -19,41 +22,119 @@ const mode = ref<'batch' | 'single'>('single');
const customerId = ref<number>();
const contractId = ref<number>();
const stationId = ref<number>();
const dateRange = ref<any>();
const billPeriod = ref<any>();
// Dropdown options
const customerOptions = ref<{ label: string; value: number }[]>([]);
const contractOptions = ref<{ label: string; value: number }[]>([]);
const filteredContractOptions = ref<{ label: string; value: number; customerId?: number }[]>([]);
const stationOptions = ref<{ label: string; value: number }[]>([]);
async function loadOptions() {
const [customers, contracts, stations] = await Promise.all([
getSimpleCustomerList(),
getSimpleContractList(),
getStationConfigSimpleList(),
]);
customerOptions.value = customers.map((c) => ({
label: c.customerName,
value: c.id!,
}));
contractOptions.value = contracts.map((c) => ({
label: `${c.contractCode} - ${c.projectName}`,
value: c.id!,
customerId: c.customerId,
}));
filteredContractOptions.value = contractOptions.value;
stationOptions.value = stations.map((s) => ({
label: s.stationName,
value: s.stationId,
}));
}
// 选择客户后,筛选该客户下的合同
watch(customerId, (val) => {
contractId.value = undefined;
if (val) {
filteredContractOptions.value = contractOptions.value.filter(
(c) => (c as any).customerId === val,
);
} else {
filteredContractOptions.value = contractOptions.value;
}
});
// 快捷时间选择
function setDateRange(type: string) {
const now = dayjs();
switch (type) {
case 'today':
dateRange.value = [now, now];
break;
case 'yesterday':
dateRange.value = [now.subtract(1, 'day'), now.subtract(1, 'day')];
break;
case 'thisWeek':
dateRange.value = [now.startOf('week'), now];
break;
case 'lastWeek':
dateRange.value = [
now.subtract(1, 'week').startOf('week'),
now.subtract(1, 'week').endOf('week'),
];
break;
case 'thisMonth':
dateRange.value = [now.startOf('month'), now];
break;
case 'lastMonth':
dateRange.value = [
now.subtract(1, 'month').startOf('month'),
now.subtract(1, 'month').endOf('month'),
];
break;
}
}
function open(m: 'batch' | 'single' = 'single') {
mode.value = m;
customerId.value = undefined;
contractId.value = undefined;
stationId.value = undefined;
dateRange.value = undefined;
billPeriod.value = undefined;
visible.value = true;
if (m === 'single') {
loadOptions();
}
}
async function handleConfirm() {
if (!billPeriod.value) {
message.warning('请选择账单周期');
return;
}
loading.value = true;
try {
const periodStr = dayjs(billPeriod.value).format('YYYY-MM');
if (mode.value === 'single') {
if (!customerId.value || !stationId.value) {
message.warning('请填写完整信息');
if (!dateRange.value || dateRange.value.length !== 2) {
message.warning('请选择时间范围');
loading.value = false;
return;
}
const data: EnergyBillApi.GenerateReq = {
customerId: customerId.value,
customerId: customerId.value!,
contractId: contractId.value!,
stationId: stationId.value,
billPeriodStart: dayjs(billPeriod.value).startOf('month').format('YYYY-MM-DD'),
billPeriodEnd: dayjs(billPeriod.value).endOf('month').format('YYYY-MM-DD'),
stationId: stationId.value!,
billPeriodStart: dayjs(dateRange.value[0]).format('YYYY-MM-DD'),
billPeriodEnd: dayjs(dateRange.value[1]).format('YYYY-MM-DD'),
};
await generateBill(data);
message.success('账单生成成功');
} else {
if (!billPeriod.value) {
message.warning('请选择账单周期');
loading.value = false;
return;
}
const periodStr = dayjs(billPeriod.value).format('YYYY-MM');
const result = await batchGenerateByPeriod(periodStr);
const generated = result.generatedCount ?? 0;
const skipped = result.skippedCount ?? 0;
@@ -74,7 +155,7 @@ defineExpose({ open });
<template>
<Modal
v-model:open="visible"
:title="mode === 'single' ? '生成账单' : '批量生成账单'"
:title="mode === 'single' ? '自定义生成账单' : '批量生成账单'"
:confirm-loading="loading"
@ok="handleConfirm"
@cancel="visible = false"
@@ -82,33 +163,72 @@ defineExpose({ open });
<template v-if="mode === 'single'">
<div class="mb-4">
<label class="mb-1 block font-medium">
客户 <span class="text-red-500">*</span>
时间范围 <span class="text-red-500">*</span>
</label>
<InputNumber
<RangePicker
v-model:value="dateRange"
format="YYYY-MM-DD"
:placeholder="['开始日期', '结束日期']"
class="w-full"
/>
<div class="mt-2">
<Space>
<Button size="small" @click="setDateRange('today')">今天</Button>
<Button size="small" @click="setDateRange('yesterday')">昨天</Button>
<Button size="small" @click="setDateRange('thisWeek')">本周</Button>
<Button size="small" @click="setDateRange('lastWeek')">上周</Button>
<Button size="small" @click="setDateRange('thisMonth')">本月</Button>
<Button size="small" @click="setDateRange('lastMonth')">上月</Button>
</Space>
</div>
</div>
<div class="mb-4">
<label class="mb-1 block font-medium">客户</label>
<Select
v-model:value="customerId"
placeholder="请输入客户ID"
:options="customerOptions"
placeholder="全部客户"
show-search
:filter-option="(input: string, option: any) => option.label?.toLowerCase().includes(input.toLowerCase())"
allow-clear
class="w-full"
/>
</div>
<div class="mb-4">
<label class="mb-1 block font-medium">合同</label>
<InputNumber
<Select
v-model:value="contractId"
placeholder="请输入合同ID"
:options="filteredContractOptions"
placeholder="全部合同"
show-search
:filter-option="(input: string, option: any) => option.label?.toLowerCase().includes(input.toLowerCase())"
allow-clear
class="w-full"
/>
</div>
<div class="mb-4">
<label class="mb-1 block font-medium">
加氢站 <span class="text-red-500">*</span>
</label>
<InputNumber
<label class="mb-1 block font-medium">加氢站</label>
<Select
v-model:value="stationId"
placeholder="请输入站点ID"
:options="stationOptions"
placeholder="全部站点"
show-search
:filter-option="(input: string, option: any) => option.label?.toLowerCase().includes(input.toLowerCase())"
allow-clear
class="w-full"
/>
</div>
<Alert type="info" show-icon message="提示">
<template #description>
<ul>
<li>不选择客户/合同/站点时,将生成所有符合条件的账单</li>
<li>账单按(客户+合同+站点+时间段)分组生成</li>
<li>只有已审核通过的明细才会生成账单</li>
</ul>
</template>
</Alert>
</template>
<template v-else>
<div class="mb-4">
<label class="mb-1 block font-medium">
账单周期 <span class="text-red-500">*</span>
@@ -121,16 +241,10 @@ defineExpose({ open });
/>
</div>
<Alert
v-if="mode === 'single'"
type="info"
show-icon
message="系统将自动汇总该客户+合同+站点在所选周期内已审核且未出账的加氢明细,生成草稿账单。"
/>
<Alert
v-if="mode === 'batch'"
type="info"
show-icon
message="系统将自动为所有有未出账明细的客户+合同+站点组合生成当月草稿账单"
/>
</template>
</Modal>
</template>

View File

@@ -42,12 +42,26 @@ async function handleExport() {
/** 批量审核 */
function handleBatchAudit() {
const selected = checkedRecords.value.filter((r) => r.auditStatus === 0);
if (selected.length === 0) {
message.warning('请选择待审核记录');
if (checkedRecords.value.length === 0) {
message.warning('请选择要审核的明细');
return;
}
auditModalRef.value?.open(selected);
// 过滤出待审核的记录
const pendingRecords = checkedRecords.value.filter((r) => r.auditStatus === 0);
if (pendingRecords.length === 0) {
message.warning('所选记录中没有待审核的明细');
return;
}
if (pendingRecords.length < checkedRecords.value.length) {
message.warning(
`已过滤 ${checkedRecords.value.length - pendingRecords.length} 条非待审核记录`
);
}
auditModalRef.value?.open(pendingRecords);
}
/** 单条审核 */

View File

@@ -5,7 +5,7 @@ import { computed, ref } from 'vue';
import { Descriptions, Input, message, Modal, Radio } from 'ant-design-vue';
import { auditHydrogenDetail } from '#/api/energy/hydrogen-detail';
import { auditHydrogenDetail, batchAuditHydrogenDetail } from '#/api/energy/hydrogen-detail';
const emit = defineEmits(['success']);
@@ -28,18 +28,32 @@ function open(data: EnergyHydrogenDetailApi.Detail[]) {
}
async function handleConfirm() {
if (!approved.value && !remark.value) {
message.warning('驳回时必须填写审核备注');
return;
}
loading.value = true;
try {
let successCount = 0;
for (const record of records.value) {
if (isBatch.value) {
// 批量审核:调用批量接口
const result = await batchAuditHydrogenDetail({
ids: records.value.map(r => r.id!),
passed: approved.value,
remark: remark.value || undefined,
});
message.success(
`审核完成!成功 ${result.successCount} 条,失败 ${result.failCount}`
);
} else {
// 单条审核:调用单条接口
await auditHydrogenDetail(
record.id!,
records.value[0].id!,
approved.value,
remark.value || undefined,
);
successCount++;
message.success('审核完成');
}
message.success(`审核完成,共 ${successCount}`);
visible.value = false;
emit('success');
} catch (e: any) {
@@ -92,16 +106,19 @@ defineExpose({ open });
<label class="mb-2 block font-medium">审核结果</label>
<Radio.Group v-model:value="approved">
<Radio :value="true">通过</Radio>
<Radio v-if="!isBatch" :value="false">驳回</Radio>
<Radio :value="false">驳回</Radio>
</Radio.Group>
</div>
<div>
<label class="mb-2 block font-medium">审核备注</label>
<label class="mb-2 block font-medium">
审核备注
<span v-if="!approved" class="text-red-500">*</span>
</label>
<Input.TextArea
v-model:value="remark"
:rows="3"
placeholder="请输入审核备注(选填)"
:placeholder="approved ? '请输入审核备注(选填)' : '请输入驳回原因(必填)'"
/>
</div>
</Modal>

View File

@@ -5,6 +5,106 @@ import type { EnergyHydrogenRecordApi } from '#/api/energy/hydrogen-record';
import { useActionColumn } from '#/utils/table';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'stationId',
label: '加氢站',
component: 'ApiSelect',
componentProps: {
placeholder: '请选择加氢站',
api: () =>
import('#/api/energy/station-config').then(
(m) => m.getStationConfigSimpleList(),
),
labelField: 'stationName',
valueField: 'stationId',
showSearch: true,
filterOption: (input: string, option: any) =>
option.label?.toLowerCase().includes(input.toLowerCase()),
},
rules: 'required',
},
{
fieldName: 'plateNumber',
label: '车牌号',
component: 'Input',
componentProps: {
placeholder: '请输入车牌号',
},
rules: 'required',
},
{
fieldName: 'hydrogenDate',
label: '加氢日期',
component: 'DatePicker',
componentProps: {
placeholder: '请选择加氢日期',
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD',
style: { width: '100%' },
},
rules: 'required',
},
{
fieldName: 'hydrogenQuantity',
label: '加氢量(KG)',
component: 'InputNumber',
componentProps: {
placeholder: '请输入加氢量',
min: 0,
precision: 2,
style: { width: '100%' },
},
rules: 'required',
},
{
fieldName: 'unitPrice',
label: '单价(元/KG)',
component: 'InputNumber',
componentProps: {
placeholder: '请输入单价',
min: 0,
precision: 2,
style: { width: '100%' },
},
rules: 'required',
},
{
fieldName: 'amount',
label: '金额',
component: 'InputNumber',
componentProps: {
placeholder: '请输入金额',
min: 0,
precision: 2,
style: { width: '100%' },
},
rules: 'required',
},
{
fieldName: 'mileage',
label: '里程(KM)',
component: 'InputNumber',
componentProps: {
placeholder: '请输入里程数',
min: 0,
precision: 2,
style: { width: '100%' },
},
},
];
}
export const SOURCE_TYPE_OPTIONS = [
{ label: 'Excel导入', value: 1, color: 'blue' },
{ label: 'API同步', value: 2, color: 'green' },
@@ -12,9 +112,9 @@ export const SOURCE_TYPE_OPTIONS = [
];
export const MATCH_STATUS_OPTIONS = [
{ label: '匹配', value: 0, color: 'orange' },
{ label: '匹配', value: 1, color: 'green' },
{ label: '匹配失败', value: 2, color: 'red' },
{ label: '完全匹配', value: 0, color: 'green' },
{ label: '部分匹配', value: 1, color: 'orange' },
{ label: '匹配', value: 2, color: 'red' },
];
/** 搜索表单 */
@@ -23,9 +123,18 @@ export function useGridFormSchema(): VbenFormSchema[] {
{
fieldName: 'stationId',
label: '加氢站',
component: 'Input',
component: 'ApiSelect',
componentProps: {
placeholder: '请输入加氢站',
placeholder: '请选择加氢站',
api: () =>
import('#/api/energy/station-config').then(
(m) => m.getStationConfigSimpleList(),
),
labelField: 'stationName',
valueField: 'stationId',
showSearch: true,
filterOption: (input: string, option: any) =>
option.label?.toLowerCase().includes(input.toLowerCase()),
allowClear: true,
},
},

View File

@@ -11,7 +11,6 @@ import { Tag, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
batchMatch,
deleteHydrogenRecord,
exportHydrogenRecord,
getHydrogenRecordPage,
@@ -25,8 +24,10 @@ import {
useGridFormSchema,
} from './data';
import ImportModal from './modules/import-modal.vue';
import FormModal from './modules/form-modal.vue';
const importModalRef = ref<InstanceType<typeof ImportModal>>();
const formModalRef = ref<InstanceType<typeof FormModal>>();
/** 刷新表格 */
function handleRefresh() {
@@ -45,18 +46,9 @@ async function handleExport() {
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();
}
/** 编辑记录 */
function handleEdit(row: EnergyHydrogenRecordApi.Record) {
formModalRef.value?.open(row);
}
/** 删除记录 */
@@ -100,6 +92,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
<template>
<Page auto-content-height>
<FormModal ref="formModalRef" @success="handleRefresh" />
<ImportModal ref="importModalRef" @success="handleRefresh" />
<Grid table-title="加氢记录列表">
<template #toolbar-tools>
@@ -118,12 +111,6 @@ const [Grid, gridApi] = useVbenVxeGrid({
auth: ['energy:hydrogen-record:export'],
onClick: handleExport,
},
{
label: '重新匹配',
icon: ACTION_ICON.REFRESH,
auth: ['energy:hydrogen-record:batch-match'],
onClick: handleBatchMatch,
},
]"
/>
</template>
@@ -163,7 +150,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['energy:hydrogen-record:update'],
onClick: () => {},
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),

View File

@@ -0,0 +1,166 @@
<script lang="ts" setup>
import type { EnergyHydrogenRecordApi } from '#/api/energy/hydrogen-record';
import { computed, ref } from 'vue';
import { DatePicker, InputNumber, message, Modal, Select } from 'ant-design-vue';
import { Input } from 'ant-design-vue';
import {
createHydrogenRecord,
updateHydrogenRecord,
} from '#/api/energy/hydrogen-record';
import { getStationConfigSimpleList } from '#/api/energy/station-config';
const emit = defineEmits(['success']);
const visible = ref(false);
const loading = ref(false);
const formData = ref<Partial<EnergyHydrogenRecordApi.Record>>({});
const isUpdate = computed(() => !!formData.value?.id);
const stationOptions = ref<{ label: string; value: number }[]>([]);
async function loadOptions() {
const stations = await getStationConfigSimpleList();
stationOptions.value = stations.map((s) => ({
label: s.stationName,
value: s.stationId,
}));
}
function open(row?: EnergyHydrogenRecordApi.Record) {
if (row && row.id) {
formData.value = { ...row };
} else {
formData.value = {};
}
visible.value = true;
loadOptions();
}
async function handleConfirm() {
if (
!formData.value.stationId ||
!formData.value.plateNumber ||
!formData.value.hydrogenDate
) {
message.warning('请填写必填项');
return;
}
loading.value = true;
try {
if (isUpdate.value) {
await updateHydrogenRecord(
formData.value as EnergyHydrogenRecordApi.Record,
);
} else {
await createHydrogenRecord(
formData.value as EnergyHydrogenRecordApi.Record,
);
}
message.success('操作成功');
visible.value = false;
emit('success');
} catch (e: any) {
message.error(e.message || '操作失败');
} finally {
loading.value = false;
}
}
const filterOption = (input: string, option: any) =>
option.label?.toLowerCase().includes(input.toLowerCase());
defineExpose({ open });
</script>
<template>
<Modal
v-model:open="visible"
:title="isUpdate ? '修改加氢记录' : '新增加氢记录'"
:confirm-loading="loading"
@ok="handleConfirm"
@cancel="visible = false"
>
<div class="mb-4">
<label class="mb-1 block font-medium">
加氢站 <span class="text-red-500">*</span>
</label>
<Select
v-model:value="formData.stationId"
:options="stationOptions"
placeholder="请选择加氢站"
show-search
:filter-option="filterOption"
allow-clear
class="w-full"
/>
</div>
<div class="mb-4">
<label class="mb-1 block font-medium">
车牌号 <span class="text-red-500">*</span>
</label>
<Input
v-model:value="formData.plateNumber"
placeholder="请输入车牌号"
allow-clear
/>
</div>
<div class="mb-4">
<label class="mb-1 block font-medium">
加氢日期 <span class="text-red-500">*</span>
</label>
<DatePicker
v-model:value="formData.hydrogenDate"
value-format="YYYY-MM-DD"
placeholder="请选择加氢日期"
class="w-full"
/>
</div>
<div class="mb-4">
<label class="mb-1 block font-medium">
加氢量(KG) <span class="text-red-500">*</span>
</label>
<InputNumber
v-model:value="formData.hydrogenQuantity"
placeholder="请输入加氢量"
:min="0"
:precision="2"
class="w-full"
/>
</div>
<div class="mb-4">
<label class="mb-1 block font-medium">
单价(/KG) <span class="text-red-500">*</span>
</label>
<InputNumber
v-model:value="formData.unitPrice"
placeholder="请输入单价"
:min="0"
:precision="2"
class="w-full"
/>
</div>
<div class="mb-4">
<label class="mb-1 block font-medium">金额</label>
<InputNumber
v-model:value="formData.amount"
placeholder="请输入金额"
:min="0"
:precision="2"
class="w-full"
/>
</div>
<div class="mb-4">
<label class="mb-1 block font-medium">里程(KM)</label>
<InputNumber
v-model:value="formData.mileage"
placeholder="请输入里程数"
:min="0"
:precision="2"
class="w-full"
/>
</div>
</Modal>
</template>

View File

@@ -1,60 +1,27 @@
<script lang="ts" setup>
import { ref, onUnmounted } from 'vue';
import { ref } from 'vue';
import {
Button,
Modal,
Progress,
Radio,
Select,
Statistic,
Steps,
Table,
Tag,
Upload,
message,
} from 'ant-design-vue';
import {
importPreview,
importConfirm,
getImportProgress,
} from '#/api/energy/hydrogen-record';
import type { EnergyHydrogenRecordApi } from '#/api/energy/hydrogen-record';
import { Alert, Button, Form, Modal, Select, Upload, message } from 'ant-design-vue';
import { importHydrogenRecords } from '#/api/energy/hydrogen-record';
import { getStationConfigSimpleList } from '#/api/energy/station-config';
import { downloadFileFromBlobPart } from '@vben/utils';
import { requestClient } from '#/api/request';
const emit = defineEmits(['success']);
const visible = ref(false);
const currentStep = ref(0);
const loading = ref(false);
const importing = ref(false);
// Step 1 data
const stationId = ref<number>();
const importForm = ref({
stationId: undefined as number | undefined,
});
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;
importForm.value.stationId = undefined;
fileList.value = [];
previewData.value = undefined;
duplicateStrategy.value = 'skip';
progressData.value = { current: 0, total: 0, status: '' };
importResult.value = undefined;
loadStations();
}
@@ -66,99 +33,55 @@ async function loadStations() {
}));
}
// Step 1 -> Step 2: Upload and preview
async function handleNextStep() {
if (!stationId.value) {
message.warning('请选择加氢站');
async function handleImport() {
if (!importForm.value.stationId) {
message.error('请选择加氢站');
return;
}
if (fileList.value.length === 0) {
message.warning('请上传文件');
message.error('请选择文件');
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;
}
}
importing.value = true;
// 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,
const formData = new FormData();
formData.append('file', fileList.value[0].originFileObj || fileList.value[0]);
formData.append('stationId', String(importForm.value.stationId));
const res = await importHydrogenRecords(formData);
message.success(
`导入成功!匹配成功 ${res.successCount} 条,失败 ${res.failCount}`,
);
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) {
resetImportForm();
emit('success');
} catch (error: any) {
message.error('导入失败:' + (error.message || '未知错误'));
} finally {
importing.value = false;
}
}
onUnmounted(() => stopProgressPolling());
function resetImportForm() {
importForm.value.stationId = undefined;
fileList.value = [];
}
// 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 },
];
async function downloadTemplate() {
try {
const blob = await requestClient.download('/energy/hydrogen-record/template');
downloadFileFromBlobPart({ fileName: '加氢记录导入模板.xlsx', source: blob });
} catch (error: any) {
message.error('下载模板失败:' + (error.message || '未知错误'));
}
}
// Handle file before upload (prevent auto-upload)
function beforeUpload(file: File) {
fileList.value = [file];
function beforeUpload() {
return false;
}
@@ -168,132 +91,59 @@ defineExpose({ open });
<template>
<Modal
v-model:open="visible"
title="Excel 批量导入"
:width="800"
:footer="null"
:mask-closable="false"
@cancel="handleClose"
title="导入加氢记录"
:width="600"
:confirm-loading="importing"
@ok="handleImport"
@cancel="visible = false"
>
<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>
<Form :model="importForm" layout="vertical">
<Form.Item label="加氢站" required>
<Select
v-model:value="stationId"
:options="stationOptions"
v-model:value="importForm.stationId"
placeholder="请选择加氢站"
class="w-full"
:options="stationOptions"
/>
</div>
<Upload.Dragger
:file-list="fileList"
</Form.Item>
<Form.Item label="Excel 文件" required>
<Upload
v-model:file-list="fileList"
:before-upload="beforeUpload"
:max-count="1"
accept=".xlsx,.xls"
>
<p class="ant-upload-drag-icon"><span class="icon-[ant-design--inbox-outlined] text-2xl"></span></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>
<span class="icon-[ant-design--upload-outlined] mr-1"></span>
选择文件
</Button>
</Upload>
<div class="upload-tip mt-2 text-gray-500">
支持 .xlsx .xls 格式
<a @click="downloadTemplate">下载模板</a>
</div>
</div>
</Form.Item>
<!-- 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>
<Alert message="导入说明" type="info" show-icon>
<template #description>
<ul class="list-disc pl-4">
<li>上传后系统将自动匹配车辆客户和合同</li>
<li>匹配成功的记录将自动生成明细</li>
<li>根据站点配置自动扣款或审核后扣款</li>
<li>匹配失败的记录需要人工处理</li>
</ul>
</template>
</Alert>
</Form>
</Modal>
</template>
<style scoped>
.upload-tip a {
color: #1890ff;
cursor: pointer;
}
.upload-tip a:hover {
text-decoration: underline;
}
</style>

View File

@@ -23,20 +23,36 @@ export function useFormSchema(): VbenFormSchema[] {
{
fieldName: 'stationId',
label: '加氢站',
component: 'InputNumber',
component: 'ApiSelect',
componentProps: {
placeholder: '请输入加氢站ID',
style: { width: '100%' },
placeholder: '请选择加氢站',
api: () =>
import('#/api/energy/station-config').then(
(m) => m.getStationConfigSimpleList(),
),
labelField: 'stationName',
valueField: 'stationId',
showSearch: true,
filterOption: (input: string, option: any) =>
option.label?.toLowerCase().includes(input.toLowerCase()),
},
rules: 'required',
},
{
fieldName: 'customerId',
label: '客户',
component: 'InputNumber',
component: 'ApiSelect',
componentProps: {
placeholder: '请输入客户ID',
style: { width: '100%' },
placeholder: '请选择客户',
api: () =>
import('#/api/asset/customer').then(
(m) => m.getSimpleCustomerList(),
),
labelField: 'customerName',
valueField: 'id',
showSearch: true,
filterOption: (input: string, option: any) =>
option.label?.toLowerCase().includes(input.toLowerCase()),
},
rules: 'required',
},

View File

@@ -2,6 +2,7 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { EnergyStationPriceApi } from '#/api/energy/station-price';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { message, Tag } from 'ant-design-vue';
@@ -15,12 +16,15 @@ import { $t } from '#/locales';
import { PRICE_STATUS_OPTIONS, useGridColumns, useGridFormSchema } from './data';
import FormModal from './modules/form-modal.vue';
import ImportModal from './modules/import-modal.vue';
const [Form, formModalApi] = useVbenModal({
connectedComponent: FormModal,
destroyOnClose: true,
});
const importRef = ref();
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
@@ -31,6 +35,11 @@ function handleCreate() {
formModalApi.setData(null).open();
}
/** 批量导入 */
function handleBatchImport() {
importRef.value?.open();
}
/** 编辑价格 */
function handleEdit(row: EnergyStationPriceApi.Price) {
formModalApi.setData(row).open();
@@ -86,6 +95,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
<template>
<Page auto-content-height>
<Form @success="handleRefresh" />
<ImportModal ref="importRef" @success="handleRefresh" />
<Grid table-title="价格管理列表">
<template #toolbar-tools>
<TableAction
@@ -97,6 +107,12 @@ const [Grid, gridApi] = useVbenVxeGrid({
auth: ['energy:station-price:create'],
onClick: handleCreate,
},
{
label: '批量导入',
icon: ACTION_ICON.UPLOAD,
auth: ['energy:station-price:create'],
onClick: handleBatchImport,
},
]"
/>
</template>

View File

@@ -0,0 +1,114 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Alert, Button, Form, Modal, Upload, message } from 'ant-design-vue';
import { batchImportPrice } from '#/api/energy/station-price';
import { downloadFileFromBlobPart } from '@vben/utils';
import { requestClient } from '#/api/request';
const emit = defineEmits(['success']);
const visible = ref(false);
const importing = ref(false);
const fileList = ref<any[]>([]);
function open() {
visible.value = true;
fileList.value = [];
}
async function handleImport() {
if (fileList.value.length === 0) {
message.error('请选择文件');
return;
}
importing.value = true;
try {
const formData = new FormData();
formData.append('file', fileList.value[0].originFileObj || fileList.value[0]);
const res = await batchImportPrice(formData);
message.success(
`导入成功!共导入 ${res.successCount} 条,失败 ${res.failCount}`,
);
visible.value = false;
fileList.value = [];
emit('success');
} catch (error: any) {
message.error('导入失败:' + (error.message || '未知错误'));
} finally {
importing.value = false;
}
}
async function downloadTemplate() {
try {
const blob = await requestClient.download('/energy/station-price/template');
downloadFileFromBlobPart({ fileName: '价格配置导入模板.xlsx', source: blob });
} catch (error: any) {
message.error('下载模板失败:' + (error.message || '未知错误'));
}
}
function beforeUpload() {
return false;
}
defineExpose({ open });
</script>
<template>
<Modal
v-model:open="visible"
title="批量导入价格配置"
:width="600"
:confirm-loading="importing"
@ok="handleImport"
@cancel="visible = false"
>
<Form layout="vertical">
<Form.Item label="Excel 文件" required>
<Upload
v-model:file-list="fileList"
:before-upload="beforeUpload"
:max-count="1"
accept=".xlsx,.xls"
>
<Button>
<span class="icon-[ant-design--upload-outlined] mr-1"></span>
选择文件
</Button>
</Upload>
<div class="upload-tip mt-2 text-gray-500">
支持 .xlsx .xls 格式
<a @click="downloadTemplate">下载模板</a>
</div>
</Form.Item>
<Alert message="导入说明" type="info" show-icon>
<template #description>
<ul class="list-disc pl-4">
<li>Excel 站点名称客户名称成本价客户价生效日期失效日期</li>
<li>站点名称和客户名称必须与系统中的名称完全一致</li>
<li>价格单位/kg</li>
<li>日期格式YYYY-MM-DD</li>
<li>失效日期可为空表示长期有效</li>
</ul>
</template>
</Alert>
</Form>
</Modal>
</template>
<style scoped>
.upload-tip a {
color: #1890ff;
cursor: pointer;
}
.upload-tip a:hover {
text-decoration: underline;
}
</style>