Files
admin-ui/src/views/report-engine/query/index.vue
T
2026-06-25 15:13:52 +08:00

628 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>