feat: 报表导出支持

This commit is contained in:
写意 2025-07-01 15:46:53 +08:00
parent 64b36285fb
commit 6e447ec96e
7 changed files with 295 additions and 13 deletions

View File

@ -1,9 +1,11 @@
// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
//
// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
//
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
using OfficeOpenXml;
namespace Admin.NET.Core.Service;
/// <summary>
@ -297,4 +299,125 @@ public class SysReportConfigService : IDynamicApiController, ITransient
return isSelectQuery;
}
/// <summary>
/// 导出报表到Excel
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
/// <exception cref="AppFriendlyException"></exception>
[ApiDescriptionSettings(Name = "ExportToExcel"), HttpPost]
[DisplayName("导出报表到Excel")]
public async Task<FileStreamResult> ExportToExcel(ReportConfigExecuteSqlScriptInput input)
{
var entity = await _reportConfigRep.GetFirstAsync(u => u.Id == input.Id) ?? throw Oops.Bah(ErrorCodeEnum.D1002);
// 执行Sql脚本
var data = await ExecuteSqlScript(input);
// 重新创建忽略大小写的字典
data = data.Select(u => new Dictionary<string, object>(u, StringComparer.OrdinalIgnoreCase)).ToList();
var layoutConfig = GetLayoutConfig(input);
var fields = layoutConfig.Fields.Where(f => f.Visible).ToList();
// 按字段原始顺序处理分组
var orderedGroups = OrderedGroupFields(fields);
using var package = new ExcelPackage();
var worksheet = package.Workbook.Worksheets.Add("ReportData");
int currentRow = 1;
int startCol = 1;
foreach (var group in orderedGroups)
{
int colCount = group.Count();
worksheet.Cells[currentRow, startCol, currentRow, startCol + colCount - 1].Merge = true;
worksheet.Cells[currentRow, startCol].Value = group.First().GroupTitle;
startCol += colCount;
}
currentRow++;
// 处理列标题使用Title或FieldName
var curColIndex = 0;
foreach (var field in orderedGroups.SelectMany(group => group))
{
worksheet.Cells[currentRow, curColIndex + 1].Value = string.IsNullOrEmpty(field.Title) ? field.FieldName : field.Title;
curColIndex++;
}
currentRow++;
// 填充数据
foreach (var item in data)
{
curColIndex = 0;
foreach (var field in orderedGroups.SelectMany(group => group))
{
worksheet.Cells[currentRow, curColIndex + 1].Value = item[field.FieldName];
curColIndex++;
}
currentRow++;
}
// 处理汇总行
var summaryFields = fields.Where(f => f.IsSummary).ToList();
if (summaryFields.Count > 0)
{
worksheet.Cells[currentRow, 1].Value = "汇总";
foreach (var field in summaryFields)
{
int colIndex = fields.FindIndex(f => f.FieldName == field.FieldName) + 1;
decimal sum = data.Sum(r =>
{
decimal.TryParse(r[field.FieldName]?.ToString(), out decimal val);
return val;
});
worksheet.Cells[currentRow, colIndex].Value = sum;
}
currentRow++;
}
// 自动调整列宽
worksheet.Cells[1, 1, currentRow - 1, fields.Count].AutoFitColumns();
var stream = new MemoryStream();
package.SaveAs(stream);
stream.Position = 0;
var fileName = entity.Name + $"_{DateTime.Now:yyyyMMddHHmmss}.xlsx";
return new FileStreamResult(stream, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") { FileDownloadName = fileName };
}
/// <summary>
/// 按照字段原始顺序处理分组
/// </summary>
private List<IGrouping<string, SysReportField>> OrderedGroupFields(List<SysReportField> fields)
{
// GroupTitle 没有值,填充特定值作为独自分组
foreach (var field in fields.Where(field => string.IsNullOrWhiteSpace(field.GroupTitle)))
field.GroupTitle = $"-{field.FieldName}-";
// 按分组标题分组
var groupFields = fields.GroupBy(field => field.GroupTitle).ToList();
// 按字段原始顺序处理分组
var orderedGroups = new List<IGrouping<string, SysReportField>>();
foreach (var field in fields)
{
var group = groupFields.First(g => g.Key == field.GroupTitle);
if (orderedGroups.Any(u => u.Key == group.Key)) continue;
orderedGroups.Add(group);
}
// 还原 GroupTitle 为空
foreach (var field in fields.Where(field => field.GroupTitle == $"-{field.FieldName}-"))
field.GroupTitle = "";
return orderedGroups;
}
}

View File

@ -18,6 +18,7 @@ import { Configuration } from '../configuration';
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError } from '../base';
import { AddReportConfigInput } from '../models';
import { AdminNETResultFileStreamResult } from '../models';
import { AdminNETResultListDictionaryStringObject } from '../models';
import { AdminNETResultReportConfigParseSqlOutput } from '../models';
import { AdminNETResultSqlSugarPagedListReportConfigOutput } from '../models';
@ -225,6 +226,54 @@ export const SysReportConfigApiAxiosParamCreator = function (configuration?: Con
options: localVarRequestOptions,
};
},
/**
*
* @summary Excel
* @param {ReportConfigExecuteSqlScriptInput} [body]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
apiSysReportConfigExportToExcelPost: async (body?: ReportConfigExecuteSqlScriptInput, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/api/sysReportConfig/exportToExcel`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, 'https://example.com');
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions :AxiosRequestConfig = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication Bearer required
// http bearer authentication required
if (configuration && configuration.accessToken) {
const accessToken = typeof configuration.accessToken === 'function'
? await configuration.accessToken()
: await configuration.accessToken;
localVarHeaderParameter["Authorization"] = "Bearer " + accessToken;
}
localVarHeaderParameter['Content-Type'] = 'application/json-patch+json';
const query = new URLSearchParams(localVarUrlObj.search);
for (const key in localVarQueryParameter) {
query.set(key, localVarQueryParameter[key]);
}
for (const key in options.params) {
query.set(key, options.params[key]);
}
localVarUrlObj.search = (new URLSearchParams(query)).toString();
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
const needsSerialization = (typeof body !== "string") || localVarRequestOptions.headers['Content-Type'] === 'application/json';
localVarRequestOptions.data = needsSerialization ? JSON.stringify(body !== undefined ? body : {}) : (body || "");
return {
url: localVarUrlObj.pathname + localVarUrlObj.search + localVarUrlObj.hash,
options: localVarRequestOptions,
};
},
/**
*
* @summary
@ -486,6 +535,20 @@ export const SysReportConfigApiFp = function(configuration?: Configuration) {
return axios.request(axiosRequestArgs);
};
},
/**
*
* @summary Excel
* @param {ReportConfigExecuteSqlScriptInput} [body]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async apiSysReportConfigExportToExcelPost(body?: ReportConfigExecuteSqlScriptInput, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => Promise<AxiosResponse<AdminNETResultFileStreamResult>>> {
const localVarAxiosArgs = await SysReportConfigApiAxiosParamCreator(configuration).apiSysReportConfigExportToExcelPost(body, options);
return (axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
const axiosRequestArgs :AxiosRequestConfig = {...localVarAxiosArgs.options, url: basePath + localVarAxiosArgs.url};
return axios.request(axiosRequestArgs);
};
},
/**
*
* @summary
@ -591,6 +654,16 @@ export const SysReportConfigApiFactory = function (configuration?: Configuration
async apiSysReportConfigExecuteSqlScriptPost(body?: ReportConfigExecuteSqlScriptInput, options?: AxiosRequestConfig): Promise<AxiosResponse<AdminNETResultListDictionaryStringObject>> {
return SysReportConfigApiFp(configuration).apiSysReportConfigExecuteSqlScriptPost(body, options).then((request) => request(axios, basePath));
},
/**
*
* @summary Excel
* @param {ReportConfigExecuteSqlScriptInput} [body]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async apiSysReportConfigExportToExcelPost(body?: ReportConfigExecuteSqlScriptInput, options?: AxiosRequestConfig): Promise<AxiosResponse<AdminNETResultFileStreamResult>> {
return SysReportConfigApiFp(configuration).apiSysReportConfigExportToExcelPost(body, options).then((request) => request(axios, basePath));
},
/**
*
* @summary
@ -685,6 +758,17 @@ export class SysReportConfigApi extends BaseAPI {
public async apiSysReportConfigExecuteSqlScriptPost(body?: ReportConfigExecuteSqlScriptInput, options?: AxiosRequestConfig) : Promise<AxiosResponse<AdminNETResultListDictionaryStringObject>> {
return SysReportConfigApiFp(this.configuration).apiSysReportConfigExecuteSqlScriptPost(body, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Excel
* @param {ReportConfigExecuteSqlScriptInput} [body]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof SysReportConfigApi
*/
public async apiSysReportConfigExportToExcelPost(body?: ReportConfigExecuteSqlScriptInput, options?: AxiosRequestConfig) : Promise<AxiosResponse<AdminNETResultFileStreamResult>> {
return SysReportConfigApiFp(this.configuration).apiSysReportConfigExportToExcelPost(body, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary

View File

@ -0,0 +1,70 @@
/* tslint:disable */
/* eslint-disable */
/**
* Admin.NET
* .NET <br/><u><b><font color='FF0000'> 👮</font></b></u>
*
* OpenAPI spec version: 1.0.0
*
*
* NOTE: This class is auto generated by the swagger code generator program.
* https://github.com/swagger-api/swagger-codegen.git
* Do not edit the class manually.
*/
/**
*
*
* @export
* @interface AdminNETResultFileStreamResult
*/
export interface AdminNETResultFileStreamResult {
/**
*
*
* @type {number}
* @memberof AdminNETResultFileStreamResult
*/
code?: number;
/**
* successwarningerror
*
* @type {string}
* @memberof AdminNETResultFileStreamResult
*/
type?: string | null;
/**
*
*
* @type {string}
* @memberof AdminNETResultFileStreamResult
*/
message?: string | null;
/**
*
*
* @type {Blob}
* @memberof AdminNETResultFileStreamResult
*/
result?: Blob | null;
/**
*
*
* @type {any}
* @memberof AdminNETResultFileStreamResult
*/
extras?: any | null;
/**
*
*
* @type {Date}
* @memberof AdminNETResultFileStreamResult
*/
time?: Date;
}

View File

@ -32,6 +32,7 @@ export * from './admin-netresult-create-pay-transaction-output';
export * from './admin-netresult-data-set';
export * from './admin-netresult-data-table';
export * from './admin-netresult-dictionary-string-string';
export * from './admin-netresult-file-stream-result';
export * from './admin-netresult-generate-qrimage-output';
export * from './admin-netresult-generate-signature-output';
export * from './admin-netresult-get-refund-domestic-refund-by-out-refund-number-response';

View File

@ -414,9 +414,6 @@ onMounted(() => {
const openDialog = (row: any) => {
state.ruleForm = JSON.parse(JSON.stringify(row));
state.fieldListData = state.ruleForm.fields ? JSON.parse(state.ruleForm.fields) : [];
state.paramListData = state.ruleForm.params ? JSON.parse(state.ruleForm.params) : [];
state.isShowDialog = true;
ruleFormRef.value?.resetFields();
@ -431,6 +428,11 @@ const openDialog = (row: any) => {
if (apiParamsMonacoEditor == null || apiParamsMonacoEditor == undefined) initApiParamsMonacoEditor();
apiParamsMonacoEditor!.setValue(state.ruleForm.apiParams ?? '');
}, 0);
setTimeout(() => {
state.fieldListData = state.ruleForm.fields ? JSON.parse(state.ruleForm.fields) : [];
state.paramListData = state.ruleForm.params ? JSON.parse(state.ruleForm.params) : [];
}, 0);
};
//
@ -566,6 +568,7 @@ defineExpose({ openDialog });
<style lang="scss" scoped>
:deep(.el-dialog__body) {
height: calc(100vh - 18px) !important;
max-height: calc(100vh - 116px) !important;
display: flex;
flex-direction: column;
}

View File

@ -6,8 +6,9 @@
<TableSearch ref="tableSearch" :search="state.search" @search="onSearch" v-model="state.queryParams" />
</template>
<template #toolbar_buttons>
<!-- <el-button @click="handleExport"> {{ $t('导出') }} </el-button> -->
<el-button @click="handleExport"> {{ $t('导出') }} </el-button>
</template>
<template #toolbar_tools> </template>
</vxe-grid>
</el-card>
</div>
@ -26,6 +27,7 @@ import TableSearch from '/@/components/table/search.vue';
import { getAPI } from '/@/utils/axios-utils';
import { SysReportConfigApi } from '/@/api-services/api';
import { SysReportField, SysReportLayoutConfig, SysReportParam } from '/@/api-services/models';
import { downloadByData, getFileName } from '/@/utils/download';
const { t } = useI18n();
@ -170,13 +172,12 @@ const handleExport = () => {
cancelButtonText: t('取消'),
type: 'warning',
}).then(() => {
// TODO:
// getAPI(SysReportConfigApi)
// .apiSysReportConfigExportPost(Object.assign(queryParamHandle(state.queryParam)), { responseType: 'blob' })
// .then((res) => {
// var fileName = getFileName(res.headers);
// downloadByData(res.data as any, fileName);
// });
getAPI(SysReportConfigApi)
.apiSysReportConfigExportToExcelPost({ id: Number(reportConfigId), execParams: state.queryParams }, { responseType: 'blob' })
.then((res) => {
var fileName = getFileName(res.headers);
downloadByData(res.data as any, fileName);
});
});
};

View File

@ -97,7 +97,7 @@ const state = reactive({
} as EmptyObjectType,
localPageParam: {
pageSize: 50 as number,
defaultSort: { field: 'id', order: 'asc', descStr: 'desc' },
defaultSort: { field: 'id', order: 'desc', descStr: 'desc' },
},
title: '',
/** 已选择的分组Id */