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 { export interface GenerateReq {
customerId: number; startDate?: string;
contractId: number; endDate?: string;
stationId: number; customerId?: number;
billPeriodStart: string; contractId?: number;
billPeriodEnd: string; stationId?: number;
billPeriodStart?: string;
billPeriodEnd?: string;
energyType?: number; energyType?: number;
} }
export interface GenerateResult {
total?: number;
billIds?: number[];
}
export interface Adjustment { export interface Adjustment {
id?: number; id?: number;
billId?: number; billId?: number;
@@ -72,7 +79,7 @@ export function getBill(id: number) {
} }
export function generateBill(data: EnergyBillApi.GenerateReq) { 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) { 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) { export interface BatchAuditReqVO {
return requestClient.post('/energy/hydrogen-detail/batch-audit', null, { ids: number[];
params: { ids: ids.join(','), approved, remark }, 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) { export function exportHydrogenDetail(params: any) {

View File

@@ -21,38 +21,7 @@ export namespace EnergyHydrogenRecordApi {
createTime?: string; 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) { export function getHydrogenRecordPage(params: PageParam) {
@@ -85,31 +54,17 @@ export function exportHydrogenRecord(params: any) {
return requestClient.download('/energy/hydrogen-record/export-excel', { params }); return requestClient.download('/energy/hydrogen-record/export-excel', { params });
} }
export function importPreview(stationId: number, file: File) { export interface ImportResultDTO {
const formData = new FormData(); total: number;
formData.append('file', file); successCount: number;
formData.append('stationId', String(stationId)); failCount: number;
return requestClient.post<EnergyHydrogenRecordApi.ImportPreview>( successIds: number[];
'/energy/hydrogen-record/import-preview', failIds: number[];
formData,
);
} }
export function importConfirm(batchNo: string, duplicateStrategy: string) { export function importHydrogenRecords(data: FormData) {
return requestClient.post<Record<string, number>>( return requestClient.post<ImportResultDTO>(
'/energy/hydrogen-record/import-confirm', '/energy/hydrogen-record/import',
null, data,
{ params: { batchNo, duplicateStrategy } },
); );
} }
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) { export function deleteStationPrice(id: number) {
return requestClient.delete('/energy/station-price/delete', { params: { id } }); 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', fieldName: 'dateRange',
label: '账单周期', label: '账单时间',
component: 'DatePicker', component: 'RangePicker',
componentProps: { componentProps: {
picker: 'month', picker: 'month',
format: 'YYYY-MM', format: 'YYYY-MM',
valueFormat: 'YYYY-MM', valueFormat: 'YYYY-MM',
placeholder: '选择月份', placeholder: ['开始月份', '结束月份'],
allowClear: true, allowClear: true,
class: 'w-full', class: 'w-full',
}, },
@@ -137,7 +137,7 @@ export function useGridColumns(): VxeTableGridOptions<EnergyBillApi.Bill>['colum
align: 'center', align: 'center',
formatter: ({ row }: { row: EnergyBillApi.Bill }) => { formatter: ({ row }: { row: EnergyBillApi.Bill }) => {
if (row.billPeriodStart) { if (row.billPeriodStart) {
return row.billPeriodStart.substring(0, 7); return String(row.billPeriodStart).substring(0, 7);
} }
return '—'; return '—';
}, },

View File

@@ -8,12 +8,14 @@ import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils'; 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 { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { import {
deleteBill, deleteBill,
exportBill, exportBill,
generateBill,
getBillPage, getBillPage,
} from '#/api/energy/bill'; } from '#/api/energy/bill';
import { $t } from '#/locales'; import { $t } from '#/locales';
@@ -43,6 +45,41 @@ async function handleExport() {
downloadFileFromBlobPart({ fileName: '能源账单.xlsx', source: blob }); 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() { function handleGenerate() {
generateModalRef.value?.open('single'); generateModalRef.value?.open('single');
@@ -119,18 +156,24 @@ const [Grid, gridApi] = useVbenVxeGrid({
<TableAction <TableAction
:actions="[ :actions="[
{ {
label: '生成账单', label: '生成本月账单',
type: 'primary', 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, icon: ACTION_ICON.ADD,
auth: ['energy:bill:create'], auth: ['energy:bill:create'],
onClick: handleGenerate, onClick: handleGenerate,
}, },
{
label: '批量生成',
icon: ACTION_ICON.ADD,
auth: ['energy:bill:create'],
onClick: handleBatchGenerate,
},
{ {
label: $t('ui.actionTitle.export'), label: $t('ui.actionTitle.export'),
icon: ACTION_ICON.DOWNLOAD, icon: ACTION_ICON.DOWNLOAD,

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup> <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 dayjs from 'dayjs';
import { import {
@@ -9,6 +9,9 @@ import {
generateBill, generateBill,
} from '#/api/energy/bill'; } from '#/api/energy/bill';
import type { EnergyBillApi } 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 emit = defineEmits(['success']);
const visible = ref(false); const visible = ref(false);
@@ -19,41 +22,119 @@ const mode = ref<'batch' | 'single'>('single');
const customerId = ref<number>(); const customerId = ref<number>();
const contractId = ref<number>(); const contractId = ref<number>();
const stationId = ref<number>(); const stationId = ref<number>();
const dateRange = ref<any>();
const billPeriod = 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') { function open(m: 'batch' | 'single' = 'single') {
mode.value = m; mode.value = m;
customerId.value = undefined; customerId.value = undefined;
contractId.value = undefined; contractId.value = undefined;
stationId.value = undefined; stationId.value = undefined;
dateRange.value = undefined;
billPeriod.value = undefined; billPeriod.value = undefined;
visible.value = true; visible.value = true;
if (m === 'single') {
loadOptions();
}
} }
async function handleConfirm() { async function handleConfirm() {
if (!billPeriod.value) {
message.warning('请选择账单周期');
return;
}
loading.value = true; loading.value = true;
try { try {
const periodStr = dayjs(billPeriod.value).format('YYYY-MM');
if (mode.value === 'single') { if (mode.value === 'single') {
if (!customerId.value || !stationId.value) { if (!dateRange.value || dateRange.value.length !== 2) {
message.warning('请填写完整信息'); message.warning('请选择时间范围');
loading.value = false; loading.value = false;
return; return;
} }
const data: EnergyBillApi.GenerateReq = { const data: EnergyBillApi.GenerateReq = {
customerId: customerId.value, customerId: customerId.value!,
contractId: contractId.value!, contractId: contractId.value!,
stationId: stationId.value, stationId: stationId.value!,
billPeriodStart: dayjs(billPeriod.value).startOf('month').format('YYYY-MM-DD'), billPeriodStart: dayjs(dateRange.value[0]).format('YYYY-MM-DD'),
billPeriodEnd: dayjs(billPeriod.value).endOf('month').format('YYYY-MM-DD'), billPeriodEnd: dayjs(dateRange.value[1]).format('YYYY-MM-DD'),
}; };
await generateBill(data); await generateBill(data);
message.success('账单生成成功'); message.success('账单生成成功');
} else { } else {
if (!billPeriod.value) {
message.warning('请选择账单周期');
loading.value = false;
return;
}
const periodStr = dayjs(billPeriod.value).format('YYYY-MM');
const result = await batchGenerateByPeriod(periodStr); const result = await batchGenerateByPeriod(periodStr);
const generated = result.generatedCount ?? 0; const generated = result.generatedCount ?? 0;
const skipped = result.skippedCount ?? 0; const skipped = result.skippedCount ?? 0;
@@ -74,7 +155,7 @@ defineExpose({ open });
<template> <template>
<Modal <Modal
v-model:open="visible" v-model:open="visible"
:title="mode === 'single' ? '生成账单' : '批量生成账单'" :title="mode === 'single' ? '自定义生成账单' : '批量生成账单'"
:confirm-loading="loading" :confirm-loading="loading"
@ok="handleConfirm" @ok="handleConfirm"
@cancel="visible = false" @cancel="visible = false"
@@ -82,55 +163,88 @@ defineExpose({ open });
<template v-if="mode === 'single'"> <template v-if="mode === 'single'">
<div class="mb-4"> <div class="mb-4">
<label class="mb-1 block font-medium"> <label class="mb-1 block font-medium">
客户 <span class="text-red-500">*</span> 时间范围 <span class="text-red-500">*</span>
</label> </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" 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" class="w-full"
/> />
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="mb-1 block font-medium">合同</label> <label class="mb-1 block font-medium">合同</label>
<InputNumber <Select
v-model:value="contractId" 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" class="w-full"
/> />
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="mb-1 block font-medium"> <label class="mb-1 block font-medium">加氢站</label>
加氢站 <span class="text-red-500">*</span> <Select
</label>
<InputNumber
v-model:value="stationId" 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" class="w-full"
/> />
</div> </div>
<Alert type="info" show-icon message="提示">
<template #description>
<ul>
<li>不选择客户/合同/站点时,将生成所有符合条件的账单</li>
<li>账单按(客户+合同+站点+时间段)分组生成</li>
<li>只有已审核通过的明细才会生成账单</li>
</ul>
</template>
</Alert>
</template> </template>
<div class="mb-4"> <template v-else>
<label class="mb-1 block font-medium"> <div class="mb-4">
账单周期 <span class="text-red-500">*</span> <label class="mb-1 block font-medium">
</label> 账单周期 <span class="text-red-500">*</span>
<DatePicker </label>
v-model:value="billPeriod" <DatePicker
picker="month" v-model:value="billPeriod"
class="w-full" picker="month"
placeholder="选择月份" class="w-full"
placeholder="选择月份"
/>
</div>
<Alert
type="info"
show-icon
message="系统将自动为所有有未出账明细的客户+合同+站点组合生成当月草稿账单"
/> />
</div> </template>
<Alert
v-if="mode === 'single'"
type="info"
show-icon
message="系统将自动汇总该客户+合同+站点在所选周期内已审核且未出账的加氢明细,生成草稿账单。"
/>
<Alert
v-if="mode === 'batch'"
type="info"
show-icon
message="系统将自动为所有有未出账明细的客户+合同+站点组合生成当月草稿账单。"
/>
</Modal> </Modal>
</template> </template>

View File

@@ -42,12 +42,26 @@ async function handleExport() {
/** 批量审核 */ /** 批量审核 */
function handleBatchAudit() { function handleBatchAudit() {
const selected = checkedRecords.value.filter((r) => r.auditStatus === 0); if (checkedRecords.value.length === 0) {
if (selected.length === 0) { message.warning('请选择要审核的明细');
message.warning('请选择待审核记录');
return; 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 { 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']); const emit = defineEmits(['success']);
@@ -28,18 +28,32 @@ function open(data: EnergyHydrogenDetailApi.Detail[]) {
} }
async function handleConfirm() { async function handleConfirm() {
if (!approved.value && !remark.value) {
message.warning('驳回时必须填写审核备注');
return;
}
loading.value = true; loading.value = true;
try { try {
let successCount = 0; if (isBatch.value) {
for (const record of records.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( await auditHydrogenDetail(
record.id!, records.value[0].id!,
approved.value, approved.value,
remark.value || undefined, remark.value || undefined,
); );
successCount++; message.success('审核完成');
} }
message.success(`审核完成,共 ${successCount}`);
visible.value = false; visible.value = false;
emit('success'); emit('success');
} catch (e: any) { } catch (e: any) {
@@ -92,16 +106,19 @@ defineExpose({ open });
<label class="mb-2 block font-medium">审核结果</label> <label class="mb-2 block font-medium">审核结果</label>
<Radio.Group v-model:value="approved"> <Radio.Group v-model:value="approved">
<Radio :value="true">通过</Radio> <Radio :value="true">通过</Radio>
<Radio v-if="!isBatch" :value="false">驳回</Radio> <Radio :value="false">驳回</Radio>
</Radio.Group> </Radio.Group>
</div> </div>
<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 <Input.TextArea
v-model:value="remark" v-model:value="remark"
:rows="3" :rows="3"
placeholder="请输入审核备注(选填)" :placeholder="approved ? '请输入审核备注(选填)' : '请输入驳回原因(必填)'"
/> />
</div> </div>
</Modal> </Modal>

View File

@@ -5,6 +5,106 @@ import type { EnergyHydrogenRecordApi } from '#/api/energy/hydrogen-record';
import { useActionColumn } from '#/utils/table'; import { useActionColumn } from '#/utils/table';
import { getRangePickerDefaultProps } from '#/utils'; 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 = [ export const SOURCE_TYPE_OPTIONS = [
{ label: 'Excel导入', value: 1, color: 'blue' }, { label: 'Excel导入', value: 1, color: 'blue' },
{ label: 'API同步', value: 2, color: 'green' }, { label: 'API同步', value: 2, color: 'green' },
@@ -12,9 +112,9 @@ export const SOURCE_TYPE_OPTIONS = [
]; ];
export const MATCH_STATUS_OPTIONS = [ export const MATCH_STATUS_OPTIONS = [
{ label: '匹配', value: 0, color: 'orange' }, { label: '完全匹配', value: 0, color: 'green' },
{ label: '匹配', value: 1, color: 'green' }, { label: '部分匹配', value: 1, color: 'orange' },
{ label: '匹配失败', value: 2, color: 'red' }, { label: '匹配', value: 2, color: 'red' },
]; ];
/** 搜索表单 */ /** 搜索表单 */
@@ -23,9 +123,18 @@ export function useGridFormSchema(): VbenFormSchema[] {
{ {
fieldName: 'stationId', fieldName: 'stationId',
label: '加氢站', label: '加氢站',
component: 'Input', component: 'ApiSelect',
componentProps: { 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, 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 { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { import {
batchMatch,
deleteHydrogenRecord, deleteHydrogenRecord,
exportHydrogenRecord, exportHydrogenRecord,
getHydrogenRecordPage, getHydrogenRecordPage,
@@ -25,8 +24,10 @@ import {
useGridFormSchema, useGridFormSchema,
} from './data'; } from './data';
import ImportModal from './modules/import-modal.vue'; import ImportModal from './modules/import-modal.vue';
import FormModal from './modules/form-modal.vue';
const importModalRef = ref<InstanceType<typeof ImportModal>>(); const importModalRef = ref<InstanceType<typeof ImportModal>>();
const formModalRef = ref<InstanceType<typeof FormModal>>();
/** 刷新表格 */ /** 刷新表格 */
function handleRefresh() { function handleRefresh() {
@@ -45,18 +46,9 @@ async function handleExport() {
downloadFileFromBlobPart({ fileName: '加氢记录.xlsx', source: blob }); downloadFileFromBlobPart({ fileName: '加氢记录.xlsx', source: blob });
} }
/** 批量匹配 */ /** 编辑记录 */
async function handleBatchMatch() { function handleEdit(row: EnergyHydrogenRecordApi.Record) {
const hideLoading = message.loading({ content: '正在匹配...', duration: 0 }); formModalRef.value?.open(row);
try {
const result = await batchMatch();
message.success(
`匹配完成:成功 ${result.successCount} 条,失败 ${result.failCount}`,
);
handleRefresh();
} finally {
hideLoading();
}
} }
/** 删除记录 */ /** 删除记录 */
@@ -100,6 +92,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
<template> <template>
<Page auto-content-height> <Page auto-content-height>
<FormModal ref="formModalRef" @success="handleRefresh" />
<ImportModal ref="importModalRef" @success="handleRefresh" /> <ImportModal ref="importModalRef" @success="handleRefresh" />
<Grid table-title="加氢记录列表"> <Grid table-title="加氢记录列表">
<template #toolbar-tools> <template #toolbar-tools>
@@ -118,12 +111,6 @@ const [Grid, gridApi] = useVbenVxeGrid({
auth: ['energy:hydrogen-record:export'], auth: ['energy:hydrogen-record:export'],
onClick: handleExport, onClick: handleExport,
}, },
{
label: '重新匹配',
icon: ACTION_ICON.REFRESH,
auth: ['energy:hydrogen-record:batch-match'],
onClick: handleBatchMatch,
},
]" ]"
/> />
</template> </template>
@@ -163,7 +150,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
type: 'link', type: 'link',
icon: ACTION_ICON.EDIT, icon: ACTION_ICON.EDIT,
auth: ['energy:hydrogen-record:update'], auth: ['energy:hydrogen-record:update'],
onClick: () => {}, onClick: handleEdit.bind(null, row),
}, },
{ {
label: $t('common.delete'), 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> <script lang="ts" setup>
import { ref, onUnmounted } from 'vue'; import { ref } from 'vue';
import { import { Alert, Button, Form, Modal, Select, Upload, message } from 'ant-design-vue';
Button, import { importHydrogenRecords } from '#/api/energy/hydrogen-record';
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 { getStationConfigSimpleList } from '#/api/energy/station-config'; import { getStationConfigSimpleList } from '#/api/energy/station-config';
import { downloadFileFromBlobPart } from '@vben/utils';
import { requestClient } from '#/api/request';
const emit = defineEmits(['success']); const emit = defineEmits(['success']);
const visible = ref(false); const visible = ref(false);
const currentStep = ref(0); const importing = ref(false);
const loading = ref(false);
// Step 1 data const importForm = ref({
const stationId = ref<number>(); stationId: undefined as number | undefined,
});
const stationOptions = ref<{ label: string; value: number }[]>([]); const stationOptions = ref<{ label: string; value: number }[]>([]);
const fileList = ref<any[]>([]); 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() { function open() {
visible.value = true; visible.value = true;
currentStep.value = 0; importForm.value.stationId = undefined;
stationId.value = undefined;
fileList.value = []; fileList.value = [];
previewData.value = undefined;
duplicateStrategy.value = 'skip';
progressData.value = { current: 0, total: 0, status: '' };
importResult.value = undefined;
loadStations(); loadStations();
} }
@@ -66,99 +33,55 @@ async function loadStations() {
})); }));
} }
// Step 1 -> Step 2: Upload and preview async function handleImport() {
async function handleNextStep() { if (!importForm.value.stationId) {
if (!stationId.value) { message.error('请选择加氢站');
message.warning('请选择加氢站');
return; return;
} }
if (fileList.value.length === 0) { if (fileList.value.length === 0) {
message.warning('请上传文件'); message.error('请选择文件');
return; return;
} }
loading.value = true; importing.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 { try {
importResult.value = await importConfirm( const formData = new FormData();
previewData.value.batchNo, formData.append('file', fileList.value[0].originFileObj || fileList.value[0]);
duplicateStrategy.value, 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() { visible.value = false;
if (!previewData.value) return; resetImportForm();
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'); 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 async function downloadTemplate() {
const previewColumns = [ try {
{ title: '行号', dataIndex: 'rowNum', width: 60 }, const blob = await requestClient.download('/energy/hydrogen-record/template');
{ title: '车牌号', dataIndex: 'plateNumber', width: 100 }, downloadFileFromBlobPart({ fileName: '加氢记录导入模板.xlsx', source: blob });
{ title: '加氢日期', dataIndex: 'hydrogenDate', width: 100 }, } catch (error: any) {
{ title: '加氢量(KG)', dataIndex: 'hydrogenQuantity', width: 100 }, message.error('下载模板失败:' + (error.message || '未知错误'));
{ title: '单价', dataIndex: 'unitPrice', width: 80 }, }
{ title: '金额', dataIndex: 'amount', width: 80 }, }
{ title: '里程数', dataIndex: 'mileage', width: 80 },
];
// Handle file before upload (prevent auto-upload) function beforeUpload() {
function beforeUpload(file: File) {
fileList.value = [file];
return false; return false;
} }
@@ -168,132 +91,59 @@ defineExpose({ open });
<template> <template>
<Modal <Modal
v-model:open="visible" v-model:open="visible"
title="Excel 批量导入" title="导入加氢记录"
:width="800" :width="600"
:footer="null" :confirm-loading="importing"
:mask-closable="false" @ok="handleImport"
@cancel="handleClose" @cancel="visible = false"
> >
<Steps :current="currentStep" class="mb-6"> <Form :model="importForm" layout="vertical">
<Steps.Step title="上传文件" /> <Form.Item label="加氢站" required>
<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 <Select
v-model:value="stationId" v-model:value="importForm.stationId"
:options="stationOptions"
placeholder="请选择加氢站" placeholder="请选择加氢站"
class="w-full" :options="stationOptions"
/> />
</div> </Form.Item>
<Upload.Dragger
: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>
</div>
</div>
<!-- Step 2: Preview --> <Form.Item label="Excel 文件" required>
<div v-if="currentStep === 1 && previewData"> <Upload
<div class="mb-4 flex gap-4"> v-model:file-list="fileList"
<Statistic title="总行数" :value="previewData.totalCount" /> :before-upload="beforeUpload"
<Statistic :max-count="1"
title="有效" accept=".xlsx,.xls"
:value="previewData.validCount" >
class="text-green-600" <Button>
/> <span class="icon-[ant-design--upload-outlined] mr-1"></span>
<Statistic 选择文件
title="重复" </Button>
:value="previewData.duplicateCount" </Upload>
class="text-orange-500" <div class="upload-tip mt-2 text-gray-500">
/> 支持 .xlsx .xls 格式
<Statistic <a @click="downloadTemplate">下载模板</a>
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> </Form.Item>
<div class="flex justify-end">
<Button type="primary" @click="handleClose">关闭</Button> <Alert message="导入说明" type="info" show-icon>
</div> <template #description>
</div> <ul class="list-disc pl-4">
<li>上传后系统将自动匹配车辆客户和合同</li>
<li>匹配成功的记录将自动生成明细</li>
<li>根据站点配置自动扣款或审核后扣款</li>
<li>匹配失败的记录需要人工处理</li>
</ul>
</template>
</Alert>
</Form>
</Modal> </Modal>
</template> </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', fieldName: 'stationId',
label: '加氢站', label: '加氢站',
component: 'InputNumber', component: 'ApiSelect',
componentProps: { componentProps: {
placeholder: '请输入加氢站ID', placeholder: '请选择加氢站',
style: { width: '100%' }, 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', rules: 'required',
}, },
{ {
fieldName: 'customerId', fieldName: 'customerId',
label: '客户', label: '客户',
component: 'InputNumber', component: 'ApiSelect',
componentProps: { componentProps: {
placeholder: '请输入客户ID', placeholder: '请选择客户',
style: { width: '100%' }, 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', rules: 'required',
}, },

View File

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