数据查询新增功能

This commit is contained in:
2026-06-25 15:13:52 +08:00
parent 4b8ff9af65
commit e8833b458d
2 changed files with 680 additions and 0 deletions
+53
View File
@@ -145,6 +145,59 @@ export function deleteReport(id: number) {
});
}
/** 获取某业务下所有报表(不分页,供下拉选择) */
export function listAllReports(businessCode: string) {
return request({
url: '/data-engine/report/reports',
method: 'get',
params: { businessCode, pageNum: 1, pageSize: 9999 },
});
}
// --- 查询相关类型 ---
export interface ReportFilter {
fieldCode: string;
operator: string;
value: string;
}
export interface ReportIndicator {
fieldCode: string;
aggregate: string;
alias: string;
}
export interface ReportOrderBy {
fieldCode: string;
direction: 'ASC' | 'DESC';
}
export interface ReportQueryParams {
businessCode: string;
reportCode: string;
dimensions: string[];
indicators: ReportIndicator[];
filters: ReportFilter[];
timeRange: {
startDate: string;
endDate: string;
};
timeGroup: string;
orderBy: ReportOrderBy[];
page: number;
pageSize: number;
}
/** 数据查询 */
export function reportQuery(data: ReportQueryParams) {
return request({
url: '/data-engine/report/query',
method: 'post',
data,
});
}
/** 按天回刷(支持单天或日期范围) */
export function execDailyBackfill(data: { businessCode: string; reportCode: string; startDate: string; endDate: string }) {
return request({
+627
View File
@@ -0,0 +1,627 @@
<template>
<div class="report-query-container">
<!-- 选择业务和报表 -->
<el-card shadow="hover" class="mb15">
<el-form :inline="true">
<el-form-item label="业务">
<el-select v-model="queryForm.businessCode" placeholder="请选择业务" style="width: 200px" @change="onBusinessChange">
<el-option v-for="biz in businesses" :key="biz.businessCode" :label="biz.businessName" :value="biz.businessCode" />
</el-select>
</el-form-item>
<el-form-item label="报表">
<el-select v-model="queryForm.reportCode" placeholder="请选择报表" style="width: 260px" @change="onReportChange" :loading="reportLoading">
<el-option v-for="r in reports" :key="r.reportCode" :label="r.reportName" :value="r.reportCode" />
</el-select>
</el-form-item>
</el-form>
</el-card>
<!-- 查询配置区 -->
<template v-if="reportLoaded">
<el-card shadow="hover" class="mb15">
<template #header><span>查询配置</span></template>
<el-form :model="queryForm" label-width="100px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="时间范围" required>
<el-date-picker
v-model="timeRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="时间粒度">
<el-select v-model="queryForm.timeGroup" style="width: 100%">
<el-option label="按天" value="day" />
<el-option label="按周" value="week" />
<el-option label="按月" value="month" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 维度 -->
<el-form-item label="统计维度">
<el-checkbox-group v-model="queryForm.dimensions" class="field-checkbox-group">
<el-checkbox
v-for="f in dimensionFields"
:key="f.fieldCode"
:label="f.fieldCode"
>
{{ f.fieldName || f.fieldCode }}
</el-checkbox>
</el-checkbox-group>
<div v-if="dimensionFields.length === 0" class="field-empty-hint">该报表无可用的维度字段</div>
</el-form-item>
<!-- 指标 -->
<el-form-item label="统计指标">
<div class="indicator-list" v-if="indicatorFields.length > 0">
<div v-for="(f, idx) in indicatorFields" :key="f.fieldCode" class="indicator-row">
<el-checkbox
v-model="indicatorChecked[f.fieldCode]"
:label="f.fieldName || f.fieldCode"
@change="(val: boolean) => onIndicatorCheck(f.fieldCode, val)"
style="min-width: 120px"
/>
<template v-if="indicatorChecked[f.fieldCode]">
<el-select
v-model="indicatorAggs[f.fieldCode].aggregate"
style="width: 130px; margin-left: 8px"
placeholder="聚合方式"
>
<el-option v-for="agg in aggregateOptions" :key="agg.value" :label="agg.label" :value="agg.value" />
</el-select>
<el-input
v-model="indicatorAggs[f.fieldCode].alias"
style="width: 130px; margin-left: 8px"
placeholder="显示别名"
/>
</template>
</div>
</div>
<div v-else class="field-empty-hint">该报表无可用的指标字段</div>
</el-form-item>
<!-- 筛选条件 -->
<el-form-item label="筛选条件">
<div class="filter-list">
<div v-for="(f, idx) in filters" :key="idx" class="filter-row">
<el-select v-model="f.fieldCode" style="width: 150px" placeholder="字段" @change="onFilterFieldChange(idx)">
<el-option v-for="qf in queryableFields" :key="qf.fieldCode" :label="qf.fieldName || qf.fieldCode" :value="qf.fieldCode" />
</el-select>
<el-select v-model="f.operator" style="width: 100px; margin-left: 6px" placeholder="运算符">
<el-option v-for="op in filterOperators" :key="op.value" :label="op.label" :value="op.value" />
</el-select>
<el-input v-model="f.value" style="width: 160px; margin-left: 6px" placeholder="值" />
<el-button type="danger" :icon="Delete" circle size="small" style="margin-left: 6px" @click="removeFilter(idx)" />
</div>
<el-button size="small" @click="addFilter" :disabled="queryableFields.length === 0">+ 添加筛选</el-button>
</div>
</el-form-item>
<!-- 排序 -->
<el-form-item label="排序">
<div class="order-list">
<div v-for="(o, idx) in queryForm.orderBy" :key="idx" class="order-row">
<el-select v-model="o.fieldCode" style="width: 150px" placeholder="排序字段">
<el-option v-for="qf in sortableFields" :key="qf.fieldCode" :label="qf.fieldName || qf.fieldCode" :value="qf.fieldCode" />
</el-select>
<el-select v-model="o.direction" style="width: 100px; margin-left: 6px">
<el-option label="降序" value="DESC" />
<el-option label="升序" value="ASC" />
</el-select>
<el-button type="danger" :icon="Delete" circle size="small" style="margin-left: 6px" @click="removeOrder(idx)" />
</div>
<el-button size="small" @click="addOrder" :disabled="sortableFields.length === 0">+ 添加排序</el-button>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" size="default" @click="handleQuery" :loading="queryLoading">
<el-icon><ele-Search /></el-icon> 查询
</el-button>
<el-button size="default" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 查询结果 -->
<el-card shadow="hover" v-if="queryDone">
<template #header>
<div class="result-header">
<span>查询结果 {{ queryResult.total }} </span>
<el-radio-group v-model="viewMode" size="small">
<el-radio-button value="table">数据列表</el-radio-button>
<el-radio-button value="chart">图表展示</el-radio-button>
</el-radio-group>
</div>
</template>
<!-- 数据列表 -->
<template v-if="viewMode === 'table'">
<el-table :data="queryResult.list" border stripe v-loading="queryLoading" style="width: 100%" empty-text="暂无数据">
<el-table-column v-for="col in queryResult.columns" :key="col" :prop="col" :label="col" show-overflow-tooltip min-width="120" />
</el-table>
<pagination
v-show="queryResult.total > 0"
:total="queryResult.total"
v-model:page="queryForm.page"
v-model:limit="queryForm.pageSize"
@pagination="handleQuery"
/>
</template>
<!-- 图表展示 -->
<template v-if="viewMode === 'chart'">
<div class="chart-tip" v-if="!chartReady">
<el-alert title="图表展示需要至少选择一个维度和一个指标" type="info" show-icon :closable="false" />
</div>
<div ref="chartRef" class="chart-container" v-loading="queryLoading"></div>
</template>
</el-card>
</template>
</div>
</template>
<script lang="ts">
export default { name: 'reportEngineQuery' };
</script>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Delete } from '@element-plus/icons-vue';
import * as echarts from 'echarts';
import {
listBusinesses,
listAllReports,
getReport,
reportQuery,
} from '/@/api/report-engine/report';
import type {
FieldDefine,
ReportFilter,
ReportOrderBy,
} from '/@/api/report-engine/report';
// ========== 常量 ==========
const AGGREGATE_OPTIONS = [
{ value: 'SUM', label: 'SUM (求和)' },
{ value: 'COUNT', label: 'COUNT (计数)' },
{ value: 'AVG', label: 'AVG (平均)' },
{ value: 'MAX', label: 'MAX (最大)' },
{ value: 'MIN', label: 'MIN (最小)' },
];
const FILTER_OPERATORS = [
{ value: '=', label: '=' },
{ value: '!=', label: '!=' },
{ value: '>', label: '>' },
{ value: '>=', label: '>=' },
{ value: '<', label: '<' },
{ value: '<=', label: '<=' },
{ value: 'LIKE', label: 'LIKE' },
{ value: 'IN', label: 'IN' },
];
// ========== 状态 ==========
const businesses = ref<any[]>([]);
const reports = ref<any[]>([]);
const reportLoading = ref(false);
const reportLoaded = ref(false);
const queryLoading = ref(false);
const queryDone = ref(false);
const viewMode = ref<'table' | 'chart'>('table');
const chartRef = ref<HTMLDivElement>();
let chartInstance: echarts.ECharts | null = null;
/** 当前选中报表的所有字段 */
const reportFields = ref<FieldDefine[]>([]);
// 维度字段(role === DIMENSION
const dimensionFields = computed(() =>
reportFields.value.filter((f) => f.fieldRole === 'DIMENSION')
);
// 指标字段(role === INDICATOR
const indicatorFields = computed(() =>
reportFields.value.filter((f) => f.fieldRole === 'INDICATOR')
);
// 可筛选字段
const queryableFields = computed(() =>
reportFields.value.filter((f) => f.isFilterable !== false || f.isQueryable)
);
// 可排序字段
const sortableFields = computed(() =>
reportFields.value.filter((f) => f.isSortable !== false || f.fieldRole === 'DIMENSION')
);
const aggregateOptions = AGGREGATE_OPTIONS;
const filterOperators = FILTER_OPERATORS;
/** 指标选中状态 */
const indicatorChecked = reactive<Record<string, boolean>>({});
/** 指标聚合配置 */
const indicatorAggs = reactive<Record<string, { aggregate: string; alias: string }>>({});
/** 筛选条件列表 */
const filters = ref<ReportFilter[]>([]);
/** 时间范围绑定(内部用数组) */
const timeRange = ref<string[]>([]);
/** 查询表单 */
const queryForm = reactive({
businessCode: '',
reportCode: '',
dimensions: [] as string[],
timeGroup: 'day',
page: 1,
pageSize: 10,
orderBy: [] as ReportOrderBy[],
});
/** 查询结果 */
const queryResult = reactive({
list: [] as Record<string, any>[],
columns: [] as string[],
total: 0,
});
/** 图表数据是否可用 */
const chartReady = computed(() => {
return queryForm.dimensions.length > 0 && Object.values(indicatorChecked).some(Boolean);
});
// ========== 初始化 ==========
const initBusinesses = async () => {
try {
const res = await listBusinesses();
businesses.value = res.data?.list || res.data || [];
} catch (_e) {
ElMessage.error('获取业务列表失败');
}
};
initBusinesses();
// ========== 业务/报表切换 ==========
const onBusinessChange = async (code: string) => {
queryForm.reportCode = '';
reportLoaded.value = false;
queryDone.value = false;
reports.value = [];
if (!code) return;
reportLoading.value = true;
try {
const res = await listAllReports(code);
const raw = res.data;
reports.value = Array.isArray(raw) ? raw : raw?.list || raw?.records || [];
} catch (_e) {
reports.value = [];
ElMessage.error('获取报表列表失败');
} finally {
reportLoading.value = false;
}
};
const onReportChange = async (code: string) => {
reportLoaded.value = false;
queryDone.value = false;
if (!code) return;
// 从报表列表中找到该报表的 id
const row = reports.value.find((r) => r.reportCode === code);
if (!row?.id) {
ElMessage.warning('未找到报表信息');
return;
}
try {
const res = await getReport(row.id);
const raw = res.data || {};
const detail = raw.report || raw.data || raw;
reportFields.value = detail.fields || [];
// 重置查询配置
resetQueryState();
// 默认选中所有维度
queryForm.dimensions = dimensionFields.value.map((f) => f.fieldCode);
// 默认选中所有指标
indicatorFields.value.forEach((f) => {
indicatorChecked[f.fieldCode] = true;
indicatorAggs[f.fieldCode] = {
aggregate: f.defaultAggregate || 'SUM',
alias: f.fieldName || f.fieldCode,
};
});
reportLoaded.value = true;
} catch (_e) {
ElMessage.error('获取报表字段信息失败');
}
};
// ========== 指标勾选 ==========
const onIndicatorCheck = (fieldCode: string, checked: boolean) => {
if (checked) {
const f = reportFields.value.find((r) => r.fieldCode === fieldCode);
indicatorAggs[fieldCode] = {
aggregate: f?.defaultAggregate || 'SUM',
alias: f?.fieldName || fieldCode,
};
}
};
// ========== 筛选条件 ==========
const addFilter = () => {
filters.value.push({ fieldCode: '', operator: '=', value: '' });
};
const removeFilter = (idx: number) => {
filters.value.splice(idx, 1);
};
const onFilterFieldChange = (idx: number) => {
// 清空值,方便用户输入
filters.value[idx].value = '';
};
// ========== 排序 ==========
const addOrder = () => {
queryForm.orderBy.push({ fieldCode: '', direction: 'DESC' });
};
const removeOrder = (idx: number) => {
queryForm.orderBy.splice(idx, 1);
};
// ========== 查询 ==========
const buildQueryParams = () => {
const dimensions = [...queryForm.dimensions];
// 收集已勾选的指标
const indicators: { fieldCode: string; aggregate: string; alias: string }[] = [];
indicatorFields.value.forEach((f) => {
if (indicatorChecked[f.fieldCode]) {
indicators.push({
fieldCode: f.fieldCode,
aggregate: indicatorAggs[f.fieldCode]?.aggregate || 'SUM',
alias: indicatorAggs[f.fieldCode]?.alias || f.fieldName || f.fieldCode,
});
}
});
const validFilters = filters.value.filter((f) => f.fieldCode && f.value);
const validOrderBy = queryForm.orderBy.filter((o) => o.fieldCode);
return {
businessCode: queryForm.businessCode,
reportCode: queryForm.reportCode,
dimensions,
indicators,
filters: validFilters,
timeRange: {
startDate: timeRange.value?.[0] || '',
endDate: timeRange.value?.[1] || '',
},
timeGroup: queryForm.timeGroup,
orderBy: validOrderBy,
page: queryForm.page,
pageSize: queryForm.pageSize,
};
};
const handleQuery = async () => {
if (!queryForm.businessCode || !queryForm.reportCode) {
ElMessage.warning('请选择业务和报表');
return;
}
if (!timeRange.value || timeRange.value.length !== 2) {
ElMessage.warning('请选择时间范围');
return;
}
queryLoading.value = true;
queryDone.value = true;
try {
const params = buildQueryParams();
const res = await reportQuery(params);
const raw = res.data || {};
// 适配多种返回格式
const list = raw.list || raw.records || raw.rows || [];
const total = raw.total ?? list.length;
// 从接口获取列信息,或从数据推断
const columns = raw.columns || raw.header || [];
if (columns.length === 0 && list.length > 0) {
queryResult.columns = Object.keys(list[0]);
} else {
queryResult.columns = columns;
}
queryResult.list = list;
queryResult.total = total;
// 切换到图表时渲染
if (viewMode.value === 'chart') {
nextTick(() => renderChart());
}
} catch (_e) {
queryResult.list = [];
queryResult.columns = [];
queryResult.total = 0;
ElMessage.error('查询失败');
} finally {
queryLoading.value = false;
}
};
const resetQuery = () => {
resetQueryState();
timeRange.value = [];
queryForm.page = 1;
queryDone.value = false;
};
const resetQueryState = () => {
queryForm.dimensions = [];
queryForm.orderBy = [];
queryForm.timeGroup = 'day';
queryForm.pageSize = 20;
queryForm.page = 1;
filters.value = [];
// 清空指标状态
Object.keys(indicatorChecked).forEach((k) => delete indicatorChecked[k]);
Object.keys(indicatorAggs).forEach((k) => delete indicatorAggs[k]);
};
// ========== 图表渲染 ==========
watch(viewMode, (mode) => {
if (mode === 'chart' && queryDone.value && queryResult.list.length > 0) {
nextTick(() => renderChart());
}
if (mode === 'table') {
disposeChart();
}
});
const renderChart = () => {
if (!chartRef.value || queryResult.list.length === 0) return;
if (!chartReady.value) {
disposeChart();
return;
}
if (!chartInstance) {
chartInstance = echarts.init(chartRef.value);
}
const dimensions = queryForm.dimensions;
const dimField = dimensions[0] || queryResult.columns[0];
// x 轴:第一个维度
const xData = queryResult.list.map((row) => row[dimField] ?? '');
// 系列:已勾选的指标
// 注意:API 返回的数据中,指标字段的 key 使用的是 alias,而非 fieldCode
const series: echarts.SeriesOption[] = [];
indicatorFields.value.forEach((f) => {
if (indicatorChecked[f.fieldCode]) {
const alias = indicatorAggs[f.fieldCode]?.alias || f.fieldName || f.fieldCode;
series.push({
name: alias,
type: 'bar',
data: queryResult.list.map((row) => {
const val = Number(row[alias]);
return isNaN(val) ? row[alias] : val;
}),
});
}
});
chartInstance.setOption({
tooltip: { trigger: 'axis' },
legend: { type: 'scroll', bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '20%', containLabel: true },
xAxis: {
type: 'category',
data: xData,
axisLabel: { rotate: xData.length > 10 ? 45 : 0 },
},
yAxis: { type: 'value' },
series,
});
chartInstance.resize();
};
const disposeChart = () => {
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
};
// ========== 窗口 resize ==========
window.addEventListener('resize', () => {
if (chartInstance) chartInstance.resize();
});
</script>
<style scoped lang="scss">
.report-query-container {
.field-checkbox-group {
.el-checkbox {
margin-right: 16px;
margin-bottom: 6px;
}
}
.field-empty-hint {
color: var(--el-text-color-placeholder);
font-size: 13px;
}
.indicator-list {
width: 100%;
.indicator-row {
display: flex;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
}
}
.filter-list,
.order-list {
width: 100%;
.filter-row,
.order-row {
display: flex;
align-items: center;
margin-bottom: 8px;
}
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.chart-container {
width: 100%;
height: 450px;
}
.chart-tip {
margin-bottom: 12px;
}
}
</style>