feat(energy): 前端优化完成
- 简化导入交互(3步→1步) - 批量审核功能 - 快速生成账单(本月/上月) - 批量价格配置(前端界面) 用户体验优化: - 一键导入,自动匹配 - 批量审核,提高效率 - 快捷时间选择 - 清晰的操作反馈
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 '—';
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,55 +163,88 @@ 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>
|
||||
<div class="mb-4">
|
||||
<label class="mb-1 block font-medium">
|
||||
账单周期 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<DatePicker
|
||||
v-model:value="billPeriod"
|
||||
picker="month"
|
||||
class="w-full"
|
||||
placeholder="选择月份"
|
||||
<template v-else>
|
||||
<div class="mb-4">
|
||||
<label class="mb-1 block font-medium">
|
||||
账单周期 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<DatePicker
|
||||
v-model:value="billPeriod"
|
||||
picker="month"
|
||||
class="w-full"
|
||||
placeholder="选择月份"
|
||||
/>
|
||||
</div>
|
||||
<Alert
|
||||
type="info"
|
||||
show-icon
|
||||
message="系统将自动为所有有未出账明细的客户+合同+站点组合生成当月草稿账单。"
|
||||
/>
|
||||
</div>
|
||||
<Alert
|
||||
v-if="mode === 'single'"
|
||||
type="info"
|
||||
show-icon
|
||||
message="系统将自动汇总该客户+合同+站点在所选周期内已审核且未出账的加氢明细,生成草稿账单。"
|
||||
/>
|
||||
<Alert
|
||||
v-if="mode === 'batch'"
|
||||
type="info"
|
||||
show-icon
|
||||
message="系统将自动为所有有未出账明细的客户+合同+站点组合生成当月草稿账单。"
|
||||
/>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/** 单条审核 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
visible.value = false;
|
||||
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"
|
||||
: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>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Button type="primary" @click="handleClose">关闭</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user