feat(图表): 透视表支持自定义汇总 #10997

This commit is contained in:
wisonic 2024-09-02 21:00:59 +08:00
parent 898a016f44
commit 9fd8c36229
13 changed files with 1488 additions and 38 deletions

View File

@ -1,11 +1,383 @@
package io.dataease.chart.charts.impl.table;
import io.dataease.chart.charts.impl.GroupChartHandler;
import io.dataease.engine.constant.DeTypeConstants;
import io.dataease.engine.constant.ExtFieldConstant;
import io.dataease.engine.sql.SQLProvider;
import io.dataease.engine.trans.Dimension2SQLObj;
import io.dataease.engine.trans.Quota2SQLObj;
import io.dataease.engine.utils.Utils;
import io.dataease.extensions.datasource.dto.DatasourceRequest;
import io.dataease.extensions.datasource.dto.DatasourceSchemaDTO;
import io.dataease.extensions.datasource.model.SQLMeta;
import io.dataease.extensions.datasource.provider.Provider;
import io.dataease.extensions.view.dto.*;
import io.dataease.extensions.view.util.FieldUtil;
import io.dataease.utils.BeanUtils;
import io.dataease.utils.IDUtils;
import io.dataease.utils.JsonUtil;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import java.util.*;
@Component
public class TablePivotHandler extends GroupChartHandler {
@Getter
private String type = "table-pivot";
@Override
public <T extends ChartCalcDataResult> T calcChartResult(ChartViewDTO view, AxisFormatResult formatResult, CustomFilterResult filterResult, Map<String, Object> sqlMap, SQLMeta sqlMeta, Provider provider) {
T result = super.calcChartResult(view, formatResult, filterResult, sqlMap, sqlMeta, provider);
Map<String, Object> customCalc = calcCustomExpr(view, filterResult, sqlMap, sqlMeta, provider);
result.getData().put("customCalc", customCalc);
return result;
}
private Map<String, Object> calcCustomExpr(ChartViewDTO view, CustomFilterResult filterResult, Map<String, Object> sqlMap, SQLMeta sqlMeta, Provider provider) {
Object totalStr = JsonUtil.toJSONString(view.getCustomAttr().get("tableTotal"));
TableTotal tableTotal = JsonUtil.parseObject((String) totalStr, TableTotal.class);
var dsMap = (Map<Long, DatasourceSchemaDTO>) sqlMap.get("dsMap");
List<String> dsList = new ArrayList<>();
for (Map.Entry<Long, DatasourceSchemaDTO> next : dsMap.entrySet()) {
dsList.add(next.getValue().getType());
}
boolean needOrder = Utils.isNeedOrder(dsList);
boolean crossDs = Utils.isCrossDs(dsMap);
DatasourceRequest datasourceRequest = new DatasourceRequest();
datasourceRequest.setDsList(dsMap);
var allFields = (List<ChartViewFieldDTO>) filterResult.getContext().get("allFields");
var rowAxis = view.getXAxis();
var colAxis = view.getXAxisExt();
var dataMap = new HashMap<String, Object>();
// 行总计列维度聚合加上自定义字段
var row = tableTotal.getRow();
if (row.isShowGrandTotals()) {
var yAxis = new ArrayList<ChartViewFieldDTO>();
for (TableCalcTotalCfg totalCfg : row.getCalcTotals().getCfg()) {
if (StringUtils.equalsIgnoreCase(totalCfg.getAggregation(), "CUSTOM")){
var field = new ChartViewFieldDTO();
field.setDeType(DeTypeConstants.DE_FLOAT);
BeanUtils.copyBean(field, totalCfg);
field.setId(IDUtils.snowID());
field.setExtField(ExtFieldConstant.EXT_CALC);
yAxis.add(field);
}
}
if (!yAxis.isEmpty()) {
Dimension2SQLObj.dimension2sqlObj(sqlMeta, colAxis, FieldUtil.transFields(allFields), crossDs, dsMap, Utils.getParams(FieldUtil.transFields(allFields)), view.getCalParams(), pluginManage);
Quota2SQLObj.quota2sqlObj(sqlMeta, yAxis, FieldUtil.transFields(allFields), crossDs, dsMap, Utils.getParams(FieldUtil.transFields(allFields)), view.getCalParams(), pluginManage);
String querySql = SQLProvider.createQuerySQL(sqlMeta, true, needOrder, view);
querySql = provider.rebuildSQL(querySql, sqlMeta, crossDs, dsMap);
datasourceRequest.setQuery(querySql);
logger.debug("calcite chart sql: " + querySql);
List<String[]> data = (List<String[]>) provider.fetchResultField(datasourceRequest).get("data");
nullToBlank(data);
var tmp = new HashMap<String, Object>();
dataMap.put("rowTotal", tmp);
tmp.put("data", buildCustomCalcResult(data, colAxis, yAxis));
tmp.put("sql", Base64.getEncoder().encodeToString(querySql.getBytes()));
}
}
// 行小计列维度聚合自定义指标数 * (行维度的数量 - 1)
if (row.isShowSubTotals()) {
var yAxis = new ArrayList<ChartViewFieldDTO>();
for (TableCalcTotalCfg totalCfg : row.getCalcSubTotals().getCfg()) {
if (StringUtils.equalsIgnoreCase(totalCfg.getAggregation(), "CUSTOM")){
var field = new ChartViewFieldDTO();
field.setDeType(DeTypeConstants.DE_FLOAT);
BeanUtils.copyBean(field, totalCfg);
field.setId(IDUtils.snowID());
field.setExtField(ExtFieldConstant.EXT_CALC);
yAxis.add(field);
}
}
if (!yAxis.isEmpty()) {
var tmpData = new ArrayList<Map<String, Object>>();
dataMap.put("rowSubTotal", tmpData);
for (int i = 0; i < rowAxis.size(); i++) {
if ( i == rowAxis.size() - 1) {
break;
}
var xAxis = new ArrayList<>(colAxis);
var subRowAxis = rowAxis.subList(0, i + 1);
xAxis.addAll(subRowAxis);
if (!yAxis.isEmpty()) {
Dimension2SQLObj.dimension2sqlObj(sqlMeta, xAxis, FieldUtil.transFields(allFields), crossDs, dsMap, Utils.getParams(FieldUtil.transFields(allFields)), view.getCalParams(), pluginManage);
Quota2SQLObj.quota2sqlObj(sqlMeta, yAxis, FieldUtil.transFields(allFields), crossDs, dsMap, Utils.getParams(FieldUtil.transFields(allFields)), view.getCalParams(), pluginManage);
String querySql = SQLProvider.createQuerySQL(sqlMeta, true, needOrder, view);
querySql = provider.rebuildSQL(querySql, sqlMeta, crossDs, dsMap);
datasourceRequest.setQuery(querySql);
logger.debug("calcite chart sql: " + querySql);
List<String[]> data = (List<String[]>) provider.fetchResultField(datasourceRequest).get("data");
nullToBlank(data);
var tmp = new HashMap<String, Object>();
tmp.put("data", buildCustomCalcResult(data, xAxis, yAxis));
tmp.put("sql", Base64.getEncoder().encodeToString(querySql.getBytes()));
tmpData.add(tmp);
}
}
}
}
// 列总计行维度聚合加上自定义字段
var col = tableTotal.getCol();
if (col.isShowGrandTotals()) {
var yAxis = new ArrayList<ChartViewFieldDTO>();
for (TableCalcTotalCfg totalCfg : col.getCalcTotals().getCfg()) {
if (StringUtils.equalsIgnoreCase(totalCfg.getAggregation(), "CUSTOM")){
var field = new ChartViewFieldDTO();
field.setDeType(DeTypeConstants.DE_FLOAT);
BeanUtils.copyBean(field, totalCfg);
field.setId(IDUtils.snowID());
field.setExtField(ExtFieldConstant.EXT_CALC);
yAxis.add(field);
}
}
if (!yAxis.isEmpty()) {
Dimension2SQLObj.dimension2sqlObj(sqlMeta, rowAxis, FieldUtil.transFields(allFields), crossDs, dsMap, Utils.getParams(FieldUtil.transFields(allFields)), view.getCalParams(), pluginManage);
Quota2SQLObj.quota2sqlObj(sqlMeta, yAxis, FieldUtil.transFields(allFields), crossDs, dsMap, Utils.getParams(FieldUtil.transFields(allFields)), view.getCalParams(), pluginManage);
String querySql = SQLProvider.createQuerySQL(sqlMeta, true, needOrder, view);
querySql = provider.rebuildSQL(querySql, sqlMeta, crossDs, dsMap);
datasourceRequest.setQuery(querySql);
logger.debug("calcite chart sql: " + querySql);
List<String[]> data = (List<String[]>) provider.fetchResultField(datasourceRequest).get("data");
nullToBlank(data);
var tmp = new HashMap<String, Object>();
dataMap.put("colTotal", tmp);
tmp.put("data", buildCustomCalcResult(data, rowAxis, yAxis));
tmp.put("sql", Base64.getEncoder().encodeToString(querySql.getBytes()));
}
}
// 列小计行维度聚合自定义指标数 * (列维度的数量 - 1)
if (col.isShowSubTotals()) {
var yAxis = new ArrayList<ChartViewFieldDTO>();
for (TableCalcTotalCfg totalCfg : col.getCalcSubTotals().getCfg()) {
if (StringUtils.equalsIgnoreCase(totalCfg.getAggregation(), "CUSTOM")){
var field = new ChartViewFieldDTO();
field.setDeType(DeTypeConstants.DE_FLOAT);
BeanUtils.copyBean(field, totalCfg);
field.setId(IDUtils.snowID());
field.setExtField(ExtFieldConstant.EXT_CALC);
yAxis.add(field);
}
}
if (!yAxis.isEmpty()) {
var tmpData = new ArrayList<Map<String, Object>>();
dataMap.put("colSubTotal", tmpData);
for (int i = 0; i < colAxis.size(); i++) {
if ( i == colAxis.size() - 1) {
break;
}
var xAxis = new ArrayList<>(rowAxis);
var subColAxis = colAxis.subList(0, i + 1);
xAxis.addAll(subColAxis);
if (!yAxis.isEmpty()) {
Dimension2SQLObj.dimension2sqlObj(sqlMeta, xAxis, FieldUtil.transFields(allFields), crossDs, dsMap, Utils.getParams(FieldUtil.transFields(allFields)), view.getCalParams(), pluginManage);
Quota2SQLObj.quota2sqlObj(sqlMeta, yAxis, FieldUtil.transFields(allFields), crossDs, dsMap, Utils.getParams(FieldUtil.transFields(allFields)), view.getCalParams(), pluginManage);
String querySql = SQLProvider.createQuerySQL(sqlMeta, true, needOrder, view);
querySql = provider.rebuildSQL(querySql, sqlMeta, crossDs, dsMap);
datasourceRequest.setQuery(querySql);
logger.debug("calcite chart sql: " + querySql);
List<String[]> data = (List<String[]>) provider.fetchResultField(datasourceRequest).get("data");
nullToBlank(data);
var tmp = new HashMap<String, Object>();
tmp.put("data", buildCustomCalcResult(data, xAxis, yAxis));
tmp.put("sql", Base64.getEncoder().encodeToString(querySql.getBytes()));
tmpData.add(tmp);
}
}
}
}
// 行列交叉部分总计无聚合直接算用列总计公式
if (row.isShowGrandTotals() && col.isShowGrandTotals()) {
var yAxis = new ArrayList<ChartViewFieldDTO>();
for (TableCalcTotalCfg totalCfg : col.getCalcTotals().getCfg()) {
if (StringUtils.equalsIgnoreCase(totalCfg.getAggregation(), "CUSTOM")){
var field = new ChartViewFieldDTO();
BeanUtils.copyBean(field, totalCfg);
field.setExtField(ExtFieldConstant.EXT_CALC);
field.setDeType(DeTypeConstants.DE_FLOAT);
field.setId(IDUtils.snowID());
yAxis.add(field);
}
}
if (!yAxis.isEmpty()) {
// 清掉聚合轴
Dimension2SQLObj.dimension2sqlObj(sqlMeta, Collections.emptyList(), FieldUtil.transFields(allFields), crossDs, dsMap, Utils.getParams(FieldUtil.transFields(allFields)), view.getCalParams(), pluginManage);
Quota2SQLObj.quota2sqlObj(sqlMeta, yAxis, FieldUtil.transFields(allFields), crossDs, dsMap, Utils.getParams(FieldUtil.transFields(allFields)), view.getCalParams(), pluginManage);
String querySql = SQLProvider.createQuerySQL(sqlMeta, true, needOrder, view);
querySql = provider.rebuildSQL(querySql, sqlMeta, crossDs, dsMap);
datasourceRequest.setQuery(querySql);
logger.debug("calcite chart sql: " + querySql);
List<String[]> data = (List<String[]>) provider.fetchResultField(datasourceRequest).get("data");
nullToBlank(data);
var tmp = new HashMap<String, Object>();
dataMap.put("rowColTotal", tmp);
var tmpData = new HashMap<String, String>();
for (int i = 0; i < yAxis.size(); i++) {
var a = yAxis.get(i);
tmpData.put(a.getDataeaseName(), data.getFirst()[i]);
}
tmp.put("data", tmpData);
tmp.put("sql", Base64.getEncoder().encodeToString(querySql.getBytes()));
}
}
// 行总计里面的列小计
if (row.isShowGrandTotals() && col.isShowSubTotals()) {
var yAxis = new ArrayList<ChartViewFieldDTO>();
for (TableCalcTotalCfg totalCfg : col.getCalcTotals().getCfg()) {
if (StringUtils.equalsIgnoreCase(totalCfg.getAggregation(), "CUSTOM")){
var field = new ChartViewFieldDTO();
BeanUtils.copyBean(field, totalCfg);
field.setExtField(ExtFieldConstant.EXT_CALC);
field.setDeType(DeTypeConstants.DE_FLOAT);
field.setId(IDUtils.snowID());
yAxis.add(field);
}
}
if (!yAxis.isEmpty()) {
var tmpData = new ArrayList<Map<String, Object>>();
dataMap.put("colSubInRowTotal", tmpData);
for (int i = 0; i < colAxis.size(); i++) {
if ( i == colAxis.size() - 1) {
break;
}
var xAxis = colAxis.subList(0, i + 1);
Dimension2SQLObj.dimension2sqlObj(sqlMeta, xAxis, FieldUtil.transFields(allFields), crossDs, dsMap, Utils.getParams(FieldUtil.transFields(allFields)), view.getCalParams(), pluginManage);
Quota2SQLObj.quota2sqlObj(sqlMeta, yAxis, FieldUtil.transFields(allFields), crossDs, dsMap, Utils.getParams(FieldUtil.transFields(allFields)), view.getCalParams(), pluginManage);
String querySql = SQLProvider.createQuerySQL(sqlMeta, true, needOrder, view);
querySql = provider.rebuildSQL(querySql, sqlMeta, crossDs, dsMap);
datasourceRequest.setQuery(querySql);
logger.debug("calcite chart sql: " + querySql);
List<String[]> data = (List<String[]>) provider.fetchResultField(datasourceRequest).get("data");
nullToBlank(data);
var tmp = new HashMap<String, Object>();
tmp.put("data", buildCustomCalcResult(data, xAxis, yAxis));
tmp.put("sql", Base64.getEncoder().encodeToString(querySql.getBytes()));
tmpData.add(tmp);
}
}
}
// 列总计里面的行小计
if (col.isShowGrandTotals() && row.isShowGrandTotals()) {
var yAxis = new ArrayList<ChartViewFieldDTO>();
for (TableCalcTotalCfg totalCfg : row.getCalcTotals().getCfg()) {
if (StringUtils.equalsIgnoreCase(totalCfg.getAggregation(), "CUSTOM")){
var field = new ChartViewFieldDTO();
BeanUtils.copyBean(field, totalCfg);
field.setExtField(ExtFieldConstant.EXT_CALC);
field.setDeType(DeTypeConstants.DE_FLOAT);
field.setId(IDUtils.snowID());
yAxis.add(field);
}
}
if (!yAxis.isEmpty()) {
var tmpData = new ArrayList<Map<String, Object>>();
dataMap.put("rowSubInColTotal", tmpData);
for (int i = 0; i < rowAxis.size(); i++) {
if ( i == rowAxis.size() - 1) {
break;
}
var xAxis = rowAxis.subList(0, i + 1);
Dimension2SQLObj.dimension2sqlObj(sqlMeta, xAxis, FieldUtil.transFields(allFields), crossDs, dsMap, Utils.getParams(FieldUtil.transFields(allFields)), view.getCalParams(), pluginManage);
Quota2SQLObj.quota2sqlObj(sqlMeta, yAxis, FieldUtil.transFields(allFields), crossDs, dsMap, Utils.getParams(FieldUtil.transFields(allFields)), view.getCalParams(), pluginManage);
String querySql = SQLProvider.createQuerySQL(sqlMeta, true, needOrder, view);
querySql = provider.rebuildSQL(querySql, sqlMeta, crossDs, dsMap);
datasourceRequest.setQuery(querySql);
logger.debug("calcite chart sql: " + querySql);
List<String[]> data = (List<String[]>) provider.fetchResultField(datasourceRequest).get("data");
nullToBlank(data);
var tmp = new HashMap<String, Object>();
tmp.put("data", buildCustomCalcResult(data, xAxis, yAxis));
tmp.put("sql", Base64.getEncoder().encodeToString(querySql.getBytes()));
tmpData.add(tmp);
}
}
}
// 行小计和列小计相交部分
if (row.isShowSubTotals() && col.isShowSubTotals()) {
var yAxis = new ArrayList<ChartViewFieldDTO>();
for (TableCalcTotalCfg totalCfg : col.getCalcTotals().getCfg()) {
if (StringUtils.equalsIgnoreCase(totalCfg.getAggregation(), "CUSTOM")){
var field = new ChartViewFieldDTO();
BeanUtils.copyBean(field, totalCfg);
field.setExtField(ExtFieldConstant.EXT_CALC);
field.setDeType(DeTypeConstants.DE_FLOAT);
field.setId(IDUtils.snowID());
yAxis.add(field);
}
}
if (!yAxis.isEmpty()) {
var tmpData = new ArrayList<List<Map<String, Object>>>();
dataMap.put("rowSubInColSub", tmpData);
for (int i = 0; i < rowAxis.size(); i++) {
if ( i == rowAxis.size() - 1) {
break;
}
var tmpList = new ArrayList<Map<String, Object>>();
tmpData.add(tmpList);
var subRow = rowAxis.subList(0, i + 1);
var xAxis = new ArrayList<>(subRow);
for (int j = 0; j < colAxis.size(); j++) {
if ( j == colAxis.size() - 1) {
break;
}
var subCol = colAxis.subList(0, j + 1);
xAxis.addAll(subCol);
Dimension2SQLObj.dimension2sqlObj(sqlMeta, xAxis, FieldUtil.transFields(allFields), crossDs, dsMap, Utils.getParams(FieldUtil.transFields(allFields)), view.getCalParams(), pluginManage);
Quota2SQLObj.quota2sqlObj(sqlMeta, yAxis, FieldUtil.transFields(allFields), crossDs, dsMap, Utils.getParams(FieldUtil.transFields(allFields)), view.getCalParams(), pluginManage);
String querySql = SQLProvider.createQuerySQL(sqlMeta, true, needOrder, view);
querySql = provider.rebuildSQL(querySql, sqlMeta, crossDs, dsMap);
datasourceRequest.setQuery(querySql);
logger.debug("calcite chart sql: " + querySql);
List<String[]> data = (List<String[]>) provider.fetchResultField(datasourceRequest).get("data");
nullToBlank(data);
var tmp = new HashMap<String, Object>();
tmp.put("data", buildCustomCalcResult(data, xAxis, yAxis));
tmp.put("sql", Base64.getEncoder().encodeToString(querySql.getBytes()));
tmpList.add(tmp);
}
}
}
}
return dataMap;
}
private Map<String, Object> buildCustomCalcResult(List<String[]> data, List<ChartViewFieldDTO> dimAxis, List<ChartViewFieldDTO> quotaAxis) {
var rootResult = new HashMap<String, Object>();
for (int i = 0; i < data.size(); i++) {
var rowData = data.get(i);
Map<String, Object> curSubMap = rootResult;
for (int j = 0; j < dimAxis.size(); j++) {
var tmpMap = curSubMap.get(rowData[j]);
if (tmpMap == null) {
tmpMap = new HashMap<String, Object>();
curSubMap.put(rowData[j], tmpMap);
curSubMap = (Map<String, Object>) tmpMap;
} else {
curSubMap = (Map<String, Object>) tmpMap;
}
if (j == dimAxis.size() - 1) {
for (int k = 0; k < quotaAxis.size(); k++) {
var qAxis = quotaAxis.get(k);
curSubMap.put(qAxis.getDataeaseName(), rowData[j + k + 1]);
}
}
}
}
return rootResult;
}
private void nullToBlank(List<String[]> data) {
data.forEach(r -> {
for (int i = 0; i < r.length; i++) {
if (r[i] == null) {
r[i] = "";
}
}
});
}
}

View File

@ -100,6 +100,9 @@ public class Quota2SQLObj {
String cast = String.format(SQLConstants.CAST, originField, Objects.equals(y.getDeType(), DeTypeConstants.DE_INT) ? SQLConstants.DEFAULT_INT_FORMAT : SQLConstants.DEFAULT_FLOAT_FORMAT);
if (StringUtils.equalsIgnoreCase(y.getSummary(), "count_distinct")) {
fieldName = String.format(SQLConstants.AGG_FIELD, "COUNT", "DISTINCT " + cast);
} else if (y.getSummary() == null){
// 透视表自定义汇总不用聚合
fieldName = cast;
} else {
fieldName = String.format(SQLConstants.AGG_FIELD, y.getSummary(), cast);
}

View File

@ -478,9 +478,13 @@ declare interface CalcTotals {
/**
* 汇总聚合配置
*/
declare interface CalcTotalCfg {
declare interface CalcTotalCfg extends Axis {
dataeaseName: string
aggregation: 'MIN' | 'MAX' | 'AVG' | 'SUM' | ''
aggregation: 'MIN' | 'MAX' | 'AVG' | 'SUM' | 'CUSTOM' | ''
/**
* 自定义汇总表达式
*/
originName: string
}
/**

View File

@ -32,6 +32,7 @@ declare interface Chart {
fields: ChartViewField[]
tableRow: []
}
customCalc: any
}
xAxis?: Axis[]
xAxisExt?: Axis[]

View File

@ -0,0 +1,628 @@
<script lang="ts" setup>
import { ref, reactive, onMounted, onBeforeUnmount, watch, unref, computed, nextTick } from 'vue'
import { useI18n } from '@/hooks/web/useI18n'
import CodeMirror from '@/views/visualized/data/dataset/form/CodeMirror.vue'
import { getFunction } from '@/api/dataset'
import { fieldType } from '@/utils/attr'
import { cloneDeep } from 'lodash-es'
import { guid } from '@/views/visualized/data/dataset/form/util'
export interface CalcFieldType {
id?: string
datasourceId?: string // id
datasetTableId?: string // union node id
datasetGroupId?: string // null
originName: string //
name: string //
dataeaseName?: string //
groupType: 'd' | 'q' // d=q=
type: string
params?: Array<{ id: string; name: string; value: number }>
checked: boolean
deType: number //
deExtractType?: number //
extField?: number // 0=2=
fieldShortName?: string //
}
const { t } = useI18n()
const myCm = ref()
const searchField = ref('')
const searchFunction = ref('')
const mirror = ref()
const props = defineProps({
crossDs: {
type: Boolean,
default: () => false
}
})
const defaultForm = {
originName: '', //
name: '', //
groupType: 'd', // d=q=
type: 'VARCHAR',
deType: 0, //
extField: 2,
id: '',
params: [],
checked: true
}
const state = reactive({
functionData: [],
quotaData: []
})
const formQuota = reactive({
id: null,
name: '',
value: null
})
const dialogFormVisible = ref(false)
const fieldForm = reactive<CalcFieldType>({ ...(defaultForm as CalcFieldType) })
const setFieldForm = () => {
const str = mirror.value.state.doc.toString()
const name2Auto = []
fieldForm.originName = setNameIdTrans('name', 'id', str, name2Auto)
}
const setNameIdTrans = (from, to, originName, name2Auto?: string[]) => {
let name2Id = originName
const nameIdMap = state.quotaData.reduce((pre, next) => {
pre[next[from]] = next[to]
return pre
}, {})
const on = originName.match(/\[(.+?)\]/g)
if (on) {
on.forEach(itm => {
const ele = itm.slice(1, -1)
if (name2Auto) {
name2Auto.push(nameIdMap[ele])
}
name2Id = name2Id.replace(`[${ele}]`, `[${nameIdMap[ele]}]`)
})
}
return name2Id
}
let quotaDataList = []
const initEdit = (obj, quotaData) => {
formQuota.id = null
Object.assign(fieldForm, { ...defaultForm, ...obj })
state.quotaData = quotaData.concat(fieldForm.params || [])
quotaDataList = cloneDeep(quotaData.concat(fieldForm.params || []))
if (!obj.originName) {
mirror.value.dispatch({
changes: {
from: 0,
to: mirror.value.viewState.state.doc.length,
insert: ''
}
})
return
}
nextTick(() => {
mirror.value.dispatch({
changes: {
from: 0,
to: mirror.value.viewState.state.doc.length,
insert: setNameIdTrans('id', 'name', obj.originName)
}
})
})
}
const insertFieldToCodeMirror = (value: string) => {
mirror.value.dispatch({
changes: { from: mirror.value.viewState.state.selection.ranges[0].from, insert: value },
selection: { anchor: mirror.value.viewState.state.selection.ranges[0].from }
})
}
onMounted(() => {
mirror.value = myCm.value.codeComInit()
})
onBeforeUnmount(() => {
mirror.value.destroy?.()
})
const insertParamToCodeMirror = (value: string) => {
mirror.value.dispatch({
changes: { from: mirror.value.viewState.state.selection.ranges[0].from, insert: value },
selection: { anchor: mirror.value.viewState.state.selection.ranges[0].from }
})
}
let functions = []
const initFunction = () => {
getFunction().then(res => {
functions = cloneDeep(res)
state.functionData = cloneDeep(res)
})
}
watch(
() => searchField.value,
val => {
if (val && val !== '') {
state.quotaData = JSON.parse(
JSON.stringify(
quotaDataList.filter(
ele =>
ele.name.toLocaleLowerCase().includes(val.toLocaleLowerCase()) && ele.extField === 0
)
)
)
} else {
state.quotaData = JSON.parse(JSON.stringify(quotaDataList)).filter(ele => ele.extField === 0)
}
}
)
watch(
() => searchFunction.value,
val => {
if (val && val !== '') {
state.functionData = JSON.parse(
JSON.stringify(
functions.filter(ele => {
return ele.func.toLocaleLowerCase().includes(val.toLocaleLowerCase())
})
)
)
} else {
state.functionData = cloneDeep(functions)
}
}
)
defineExpose({
initEdit,
setFieldForm,
fieldForm
})
const parmasTitle = ref('')
const updateParmasToQuota = () => {
const [o] = fieldForm.params
parmasTitle.value = '编辑计算参数'
Object.assign(formQuota, o || {})
dialogFormVisible.value = true
}
const delParmasToQuota = () => {
const [o] = fieldForm.params
fieldForm.params = []
const str = mirror.value.state.doc.toString()
const name2Auto = []
fieldForm.originName = setNameIdTrans('name', 'id', str, name2Auto).replaceAll(`[${o.id}]`, '')
state.quotaData = state.quotaData.filter(ele => ele.id !== o.id)
mirror.value.dispatch({
changes: {
from: 0,
to: mirror.value.viewState.state.doc.length,
insert: setNameIdTrans('id', 'name', fieldForm.originName)
}
})
}
initFunction()
</script>
<template>
<div @keydown.stop @keyup.stop class="calcu-field">
<div class="calcu-cont">
<div style="flex: 1">
<div style="max-width: 488px">
<div class="mb8 field-exp">
<span>{{ t('dataset.field_exp') }}</span>
<span>*</span>
<el-tooltip class="item" effect="dark" placement="top">
<template #content>
<div v-if="props.crossDs">{{ t('dataset.calc_tips.tip1') }}</div>
<div v-else>{{ t('dataset.calc_tips.tip1_1') }}</div>
<div>{{ t('dataset.calc_tips.tip2') }}</div>
</template>
<el-icon size="16px">
<Icon name="icon_info_outlined"></Icon>
</el-icon>
</el-tooltip>
</div>
<code-mirror
:quotaMap="state.quotaData.map(ele => ele.name)"
:dimensionMap="[]"
ref="myCm"
height="500px"
dom-id="calcField"
></code-mirror>
</div>
</div>
<div class="padding-lr">
<span class="mb8">
{{ t('dataset.click_ref_field') }}
<el-tooltip class="item" effect="dark" placement="bottom">
<template #content>
{{ t('dataset.calc_tips.tip3') }}
<br />
{{ t('dataset.calc_tips.tip4') }}
<br />
{{ t('dataset.calc_tips.tip5') }}
</template>
<el-icon size="16px">
<Icon name="icon_info_outlined"></Icon>
</el-icon>
</el-tooltip>
</span>
<div class="padding-lr-content">
<el-input v-model="searchField" :placeholder="t('dataset.edit_search')" clearable>
<template #prefix>
<el-icon>
<Icon name="icon_search-outline_outlined"></Icon>
</el-icon>
</template>
</el-input>
<div class="quota-btn_de">
<span>{{ t('chart.quota') }}</span>
</div>
<div class="field-height">
<div v-if="state.quotaData.length" class="field-list">
<span
v-for="item in state.quotaData"
:key="item.id"
class="item-quota flex-align-center ellipsis"
:title="item.name"
@click="insertFieldToCodeMirror('[' + item.name + ']')"
>
<el-icon v-if="!item.groupType">
<Icon name="icon_adjustment_outlined"></Icon>
</el-icon>
<el-icon v-else>
<Icon
:name="`field_${fieldType[item.deType]}`"
:className="`field-icon-${fieldType[item.deType]}`"
></Icon>
</el-icon>
{{ item.name }}
<div v-if="!item.groupType" class="icon-right">
<el-icon @click.stop="updateParmasToQuota" class="hover-icon">
<Icon name="icon_edit_outlined"></Icon>
</el-icon>
<el-icon @click.stop="delParmasToQuota" class="hover-icon">
<Icon name="icon_delete-trash_outlined"></Icon>
</el-icon>
</div>
</span>
</div>
<div v-else class="class-na">{{ t('dataset.na') }}</div>
</div>
</div>
</div>
<div class="padding-lr">
<span class="mb8">
{{ t('dataset.click_ref_function') }}
<el-tooltip class="item" effect="dark" placement="bottom">
<template #content>
<div v-if="props.crossDs">
{{ t('dataset.calc_tips.tip6') }}
<br />
{{ t('dataset.calc_tips.tip8') }}
<br />
https://calcite.apache.org/docs/reference.html
</div>
<div v-else>{{ t('dataset.calc_tips.tip7') }}</div>
</template>
<el-icon size="16px">
<Icon name="icon_info_outlined"></Icon>
</el-icon>
</el-tooltip>
</span>
<div class="padding-lr-content">
<el-input
v-model="searchFunction"
style="margin-bottom: 8px"
:placeholder="t('dataset.edit_search')"
clearable
>
<template #prefix>
<el-icon>
<Icon name="icon_search-outline_outlined"></Icon>
</el-icon>
</template>
</el-input>
<el-row class="function-height">
<div v-if="state.functionData.length" style="width: 100%">
<el-popover
v-for="(item, index) in state.functionData"
:key="index"
class="function-pop"
placement="right"
width="200"
trigger="hover"
:open-delay="500"
>
<template #reference>
<span
class="function-style flex-align-center"
@click="insertParamToCodeMirror(item.func)"
>{{ item.func }}</span
>
</template>
<p class="pop-title">{{ item.name }}</p>
<p class="pop-info">{{ item.func }}</p>
<p class="pop-info">{{ item.desc }}</p>
</el-popover>
</div>
<div v-else class="class-na">{{ t('chart.no_function') }}</div>
</el-row>
</div>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.calcu-field {
.calcu-cont {
color: #606266;
font-size: 14px;
box-sizing: border-box;
display: flex;
justify-content: space-between;
}
.mr12 {
margin-right: 12px;
}
.mr0 {
margin-right: 0;
:deep(.ed-select__prefix--light) {
padding: 0;
border: none;
margin: 0;
}
}
.btn-select {
width: 100px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
border: 1px solid #bbbfc4;
border-radius: 4px;
.is-active {
background: var(--ed-color-primary-1a, rgba(51, 112, 255, 0.1));
}
.ed-button:not(.is-active) {
color: #1f2329;
}
.ed-button.is-text {
height: 24px;
width: 44px;
line-height: 24px;
}
.ed-button + .ed-button {
margin-left: 4px;
}
}
.mb8 {
margin-bottom: 8px;
display: inline-flex;
align-items: center;
color: #1f2329;
&.field-exp {
& > :nth-child(2) {
margin: 0 -0.67px 0 2px;
color: #f54a45;
font-family: '阿里巴巴普惠体 3.0 55 Regular L3';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
}
}
.ed-icon {
color: #646a73;
margin-left: 4.67px;
}
}
}
.padding-lr {
margin-left: 12px;
width: 214px;
overflow-y: hidden;
.padding-lr-content {
padding: 12px;
border: 1px solid var(--deCardStrokeColor, #dee0e3);
box-sizing: border-box;
height: 500px;
border-radius: 4px;
}
}
.hover-icon_quota {
cursor: pointer;
border-radius: 4px;
font-size: 16px;
position: relative;
&[aria-expanded='true'] {
&::after {
content: '';
position: absolute;
width: 24px;
height: 24px;
background: rgba(31, 35, 41, 0.1);
border-radius: 4px;
transform: translate(-50%, -50%);
top: 50%;
left: 50%;
}
}
&:hover {
&::after {
content: '';
position: absolute;
width: 24px;
height: 24px;
background: rgba(31, 35, 41, 0.1);
border-radius: 4px;
transform: translate(-50%, -50%);
top: 50%;
left: 50%;
}
}
&:active {
&::after {
content: '';
position: absolute;
width: 24px;
height: 24px;
background: rgba(31, 35, 41, 0.2);
border-radius: 4px;
transform: translate(-50%, -50%);
top: 50%;
left: 50%;
}
}
}
.quota-btn_de {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
color: #1f2329;
}
.field-height {
height: calc(50% - 41px);
margin-top: 12px;
overflow-y: auto;
& > :nth-child(1) {
color: #1f2329;
}
.not-allow {
cursor: not-allowed;
color: #bbbfc4 !important;
}
}
.item-dimension,
.item-quota {
padding: 1px 8px;
border: solid 1px #dee0e3;
background-color: white;
color: #1f2329;
.ed-icon {
font-size: 16px;
margin-right: 4px;
}
height: 28px;
margin-top: 4px;
word-break: break-all;
border-radius: 4px;
.icon-right {
display: none;
margin-left: auto;
align-items: center;
.ed-icon {
margin: 0 0 0 6px;
}
}
}
.item-dimension:hover {
border-color: var(--ed-color-primary, #3370ff);
background: var(--ed-color-primary-1a, rgba(51, 112, 255, 0.1));
cursor: pointer;
}
.item-quota {
.ed-icon {
color: #04b49c;
}
}
.item-quota:hover {
background: rgba(4, 180, 156, 0.1);
border-color: #04b49c;
cursor: pointer;
.icon-right {
display: flex;
}
}
.function-style {
min-height: 28px;
padding: 0px 8px;
margin-bottom: 4px;
border-radius: 4px;
color: #1f2329;
&:hover {
background: rgba(31, 35, 41, 0.1);
}
}
.function-style:hover {
border-color: var(--ed-color-primary, #3370ff);
cursor: pointer;
}
.function-height {
height: calc(100% - 29px);
overflow: auto;
width: calc(100% + 16px);
margin-left: -8px;
}
.function-pop :deep(.ed-popover) {
padding: 6px !important;
}
.pop-title {
margin: 6px 0 0 0;
font-size: 14px;
font-weight: 500;
}
.pop-info {
margin: 6px 0 0 0;
font-size: 10px;
}
.class-na {
margin-top: 8px;
text-align: center;
font-size: 14px;
color: var(--deTextDisable);
}
</style>
<style lang="less">
.calcu-field {
.cm-scroller {
height: 320px;
border: 1px solid #bbbfc4;
border-radius: 4px;
overflow-y: auto;
background: #fff;
}
.cm-focused {
outline: none;
}
}
.ed-select-dropdown__item {
display: flex;
align-items: center;
}
</style>

View File

@ -1,8 +1,9 @@
<script lang="ts" setup>
import { onMounted, PropType, reactive, watch } from 'vue'
import { onMounted, PropType, reactive, watch, ref, inject, nextTick } from 'vue'
import { useI18n } from '@/hooks/web/useI18n'
import { DEFAULT_TABLE_TOTAL } from '@/views/chart/components/editor/util/chart'
import { cloneDeep, defaultsDeep } from 'lodash-es'
import CustomAggrEdit from './CustomAggrEdit.vue'
const { t } = useI18n()
@ -31,26 +32,18 @@ const aggregations = [
{ name: t('chart.sum'), value: 'SUM' },
{ name: t('chart.avg'), value: 'AVG' },
{ name: t('chart.max'), value: 'MAX' },
{ name: t('chart.min'), value: 'MIN' }
{ name: t('chart.min'), value: 'MIN' },
{ name: t('commons.custom'), value: 'CUSTOM' }
]
const state = reactive({
tableTotalForm: cloneDeep(DEFAULT_TABLE_TOTAL) as ChartTableTotalAttr,
rowSubTotalItem: {
dataeaseName: '',
aggregation: ''
} as CalcTotalCfg,
rowTotalItem: {
dataeaseName: '',
aggregation: ''
} as CalcTotalCfg,
colSubTotalItem: {
dataeaseName: '',
aggregation: ''
} as CalcTotalCfg,
colTotalItem: {
dataeaseName: '',
aggregation: ''
} as CalcTotalCfg
rowSubTotalItem: {} as DeepPartial<CalcTotalCfg>,
rowTotalItem: {} as DeepPartial<CalcTotalCfg>,
colSubTotalItem: {} as DeepPartial<CalcTotalCfg>,
colTotalItem: {} as DeepPartial<CalcTotalCfg>,
totalCfg: [] as CalcTotalCfg[],
totalCfgAttr: '',
totalItem: {} as DeepPartial<CalcTotalCfg>
})
const emit = defineEmits(['onTableTotalChange'])
@ -86,7 +79,7 @@ const init = () => {
totals.forEach(total => {
setupTotalCfg(total.cfg, yAxis)
})
const totalTupleArr: [CalcTotalCfg, CalcTotalCfg[]][] = [
const totalTupleArr: [DeepPartial<CalcTotalCfg>, CalcTotalCfg[]][] = [
[state.rowTotalItem, state.tableTotalForm.row.calcTotals.cfg],
[state.rowSubTotalItem, state.tableTotalForm.row.calcSubTotals.cfg],
[state.colTotalItem, state.tableTotalForm.col.calcTotals.cfg],
@ -97,6 +90,7 @@ const init = () => {
if (!totalCfg.length) {
total.dataeaseName = ''
total.aggregation = ''
total.originName = ''
return
}
const totalIndex = totalCfg.findIndex(i => i.dataeaseName === total.dataeaseName)
@ -105,6 +99,7 @@ const init = () => {
} else {
total.dataeaseName = totalCfg[0].dataeaseName
total.aggregation = totalCfg[0].aggregation
total.originName = totalCfg[0].originName
}
})
}
@ -114,6 +109,7 @@ const changeTotal = (totalItem, totals) => {
const item = totals[i]
if (item.dataeaseName === totalItem.dataeaseName) {
totalItem.aggregation = item.aggregation
totalItem.originName = item.originName
return
}
}
@ -126,6 +122,9 @@ const changeTotalAggr = (totalItem, totals, colOrNum) => {
break
}
}
if (totalItem.aggregation == 'CUSTOM' && !totalItem.originName) {
return
}
changeTableTotal(colOrNum)
}
const setupTotalCfg = (totalCfg, axis) => {
@ -150,10 +149,51 @@ const setupTotalCfg = (totalCfg, axis) => {
axis.forEach(i => {
totalCfg.push({
dataeaseName: i.dataeaseName,
aggregation: cfgMap[i.dataeaseName] ? cfgMap[i.dataeaseName].aggregation : 'SUM'
aggregation: cfgMap[i.dataeaseName] ? cfgMap[i.dataeaseName].aggregation : 'SUM',
originName: cfgMap[i.dataeaseName] ? cfgMap[i.dataeaseName].originName : ''
})
})
}
const quota = inject('quota', () => [])
const dimension = inject('dimension', () => [])
const calcEdit = ref()
const editCalcField = ref(false)
const editField = (totalItem, totalCfg, attr) => {
editCalcField.value = true
state.totalCfg = totalCfg
state.totalCfgAttr = attr
state.totalItem = totalItem
nextTick(() => {
calcEdit.value.initEdit(
totalItem,
quota().filter(ele => ele.id !== '-1')
)
})
}
const closeEditCalc = () => {
editCalcField.value = false
}
const confirmEditCalc = () => {
calcEdit.value.setFieldForm()
const obj = cloneDeep(calcEdit.value.fieldForm)
state.totalCfg?.forEach(item => {
if (item.dataeaseName === obj.dataeaseName) {
item.originName = obj.originName
setFieldDefaultValue(item)
state.totalItem.originName = item.originName
}
})
closeEditCalc()
changeTableTotal(state.totalCfgAttr)
}
const setFieldDefaultValue = field => {
field.extField = 2
field.chartId = props.chart.id
field.datasetGroupId = props.chart.tableId
field.lastSyncTime = null
field.columnIndex = dimension().length + quota().length
field.deExtractType = field.deType
}
onMounted(() => {
init()
})
@ -232,7 +272,7 @@ onMounted(() => {
/>
</el-select>
</el-col>
<el-col :span="11" :offset="2">
<el-col :span="state.rowTotalItem.aggregation === 'CUSTOM' ? 8 : 11" :offset="2">
<el-select
:effect="themes"
v-model="state.rowTotalItem.aggregation"
@ -253,6 +293,19 @@ onMounted(() => {
/>
</el-select>
</el-col>
<el-col v-if="state.rowTotalItem.aggregation === 'CUSTOM'" :span="2" :offset="1">
<el-icon>
<Setting
@click="
editField(
state.rowTotalItem,
state.tableTotalForm.row.calcTotals.cfg,
'row.calcTotals.cfg'
)
"
/>
</el-icon>
</el-col>
</el-form-item>
<el-form-item
v-if="chart.type === 'table-pivot'"
@ -360,7 +413,7 @@ onMounted(() => {
/>
</el-select>
</el-col>
<el-col :span="11" :offset="2">
<el-col :span="state.rowSubTotalItem.aggregation === 'CUSTOM' ? 8 : 11" :offset="2">
<el-select
:effect="themes"
v-model="state.rowSubTotalItem.aggregation"
@ -382,6 +435,19 @@ onMounted(() => {
/>
</el-select>
</el-col>
<el-col v-if="state.rowSubTotalItem.aggregation === 'CUSTOM'" :span="2" :offset="1">
<el-icon>
<Setting
@click="
editField(
state.rowSubTotalItem,
state.tableTotalForm.row.calcSubTotals.cfg,
'row.calcSubTotals.cfg'
)
"
/>
</el-icon>
</el-col>
</el-form-item>
</div>
@ -455,7 +521,7 @@ onMounted(() => {
/>
</el-select>
</el-col>
<el-col :span="11" :offset="2">
<el-col :span="state.colTotalItem.aggregation === 'CUSTOM' ? 8 : 11" :offset="2">
<el-select
:effect="themes"
v-model="state.colTotalItem.aggregation"
@ -476,6 +542,19 @@ onMounted(() => {
/>
</el-select>
</el-col>
<el-col v-if="state.colTotalItem.aggregation === 'CUSTOM'" :span="2" :offset="1">
<el-icon>
<Setting
@click="
editField(
state.colTotalItem,
state.tableTotalForm.col.calcTotals.cfg,
'col.calcTotals.cfg'
)
"
/>
</el-icon>
</el-col>
</el-form-item>
<el-form-item
v-if="chart.type === 'table-pivot'"
@ -582,7 +661,7 @@ onMounted(() => {
/>
</el-select>
</el-col>
<el-col :span="11" :offset="2">
<el-col :span="state.colSubTotalItem.aggregation === 'CUSTOM' ? 8 : 11" :offset="2">
<el-select
:effect="themes"
v-model="state.colSubTotalItem.aggregation"
@ -604,9 +683,35 @@ onMounted(() => {
/>
</el-select>
</el-col>
<el-col v-if="state.colSubTotalItem.aggregation === 'CUSTOM'" :span="2" :offset="1">
<el-icon>
<Setting
@click="
editField(
state.colSubTotalItem,
state.tableTotalForm.col.calcSubTotals.cfg,
'col.calcSubTotals.cfg'
)
"
/>
</el-icon>
</el-col>
</el-form-item>
</div>
</el-form>
<!--图表计算字段-->
<el-dialog
v-model="editCalcField"
width="1000px"
title="自定义聚合公式"
:close-on-click-modal="false"
>
<custom-aggr-edit ref="calcEdit" />
<template #footer>
<el-button secondary @click="closeEditCalc()">{{ t('dataset.cancel') }} </el-button>
<el-button type="primary" @click="confirmEditCalc()">{{ t('dataset.confirm') }} </el-button>
</template>
</el-dialog>
</template>
<style lang="less" scoped>

View File

@ -210,6 +210,7 @@ const filedList = computed(() => {
})
provide('filedList', () => filedList.value)
provide('quota', () => state.quota)
watch(
[() => view.value['tableId']],
() => {

View File

@ -1,14 +1,64 @@
import { EXTRA_FIELD, PivotSheet, S2Event, S2Options, TOTAL_VALUE, S2Theme, Totals } from '@antv/s2'
import {
EXTRA_FIELD,
PivotSheet,
S2Event,
S2Options,
TOTAL_VALUE,
S2Theme,
Totals,
PivotDataSet,
Query,
VALUE_FIELD,
QueryDataType,
TotalStatus,
Aggregation
} from '@antv/s2'
import { formatterItem, valueFormatter } from '../../../formatter'
import { hexColorToRGBA, isAlphaColor, parseJson } from '../../../util'
import { S2ChartView, S2DrawOptions } from '../../types/impl/s2'
import { TABLE_EDITOR_PROPERTY_INNER } from './common'
import { useI18n } from '@/hooks/web/useI18n'
import { isNumber, maxBy, merge, minBy } from 'lodash-es'
import { isNumber, keys, maxBy, merge, minBy, some, isEmpty, get } from 'lodash-es'
import { copyContent } from '../../common/common_table'
import Decimal from 'decimal.js'
type DataItem = Record<string, any>
const { t } = useI18n()
class CustomPivotDataset extends PivotDataSet {
getTotalValue(query: Query, totalStatus?: TotalStatus) {
const { options } = this.spreadsheet
const effectiveStatus = some(totalStatus)
const status = effectiveStatus ? totalStatus : this.getTotalStatus(query)
const { aggregation, calcFunc } =
getAggregationAndCalcFuncByQuery(status, options?.totals) || {}
// 聚合方式从用户配置的 s2Options.totals , 在触发前端兜底计算汇总逻辑时, 如果没有汇总的配置, 默认按 [求和] 计算,避免排序失效.
const defaultAggregation =
isEmpty(options?.totals) && !this.spreadsheet.isHierarchyTreeType() ? Aggregation.SUM : ''
const calcAction = calcActionByType[aggregation || defaultAggregation]
// 前端计算汇总值
if (calcAction || calcFunc) {
const data = this.getMultiData(query, {
queryType: QueryDataType.DetailOnly
})
let totalValue: number
if (calcFunc) {
totalValue = calcFunc(query, data, this.spreadsheet, status)
} else if (calcAction) {
totalValue = calcAction(data, VALUE_FIELD)
}
return {
...query,
[VALUE_FIELD]: totalValue,
[query[EXTRA_FIELD]]: totalValue
}
}
}
}
/**
* 透视表
*/
@ -75,7 +125,7 @@ export class TablePivot extends S2ChartView<PivotSheet> {
)
// fields
const fields = chart.data.fields
const { fields, customCalc } = chart.data
if (!fields || fields.length === 0) {
if (chartObj) {
chartObj.destroy()
@ -168,6 +218,11 @@ export class TablePivot extends S2ChartView<PivotSheet> {
tableTotal.col.calcTotals,
tableTotal.col.calcSubTotals
]
const axisMap = {
row: chart.xAxis,
col: chart.xAxisExt,
quota: chart.yAxis
}
totals.forEach(total => {
if (total.cfg?.length) {
delete total.aggregation
@ -175,8 +230,8 @@ export class TablePivot extends S2ChartView<PivotSheet> {
p[n.dataeaseName] = n
return p
}, {})
total.calcFunc = (query, data) => {
return customCalcFunc(query, data, totalCfgMap)
total.calcFunc = (query, data, _, status) => {
return customCalcFunc(query, data, status, totalCfgMap, axisMap, customCalc)
}
}
})
@ -205,7 +260,8 @@ export class TablePivot extends S2ChartView<PivotSheet> {
tooltip: {
getContainer: () => containerDom
},
hierarchyType: basicStyle.tableLayoutMode ?? 'grid'
hierarchyType: basicStyle.tableLayoutMode ?? 'grid',
dataSet: spreadSheet => new CustomPivotDataset(spreadSheet)
}
// tooltip
@ -383,7 +439,7 @@ export class TablePivot extends S2ChartView<PivotSheet> {
super('table-pivot', [])
}
}
function customCalcFunc(query, data, totalCfgMap) {
function customCalcFunc(query, data, status, totalCfgMap, axisMap, customCalc) {
if (!data?.length || !query[EXTRA_FIELD]) {
return 0
}
@ -412,6 +468,13 @@ function customCalcFunc(query, data, totalCfgMap) {
})
return result?.[query[EXTRA_FIELD]]
}
case 'CUSTOM': {
const val = getCustomCalcResult(query, axisMap, status, customCalc || {})
if (val === '') {
return val
}
return parseFloat(val)
}
default: {
return data.reduce((p, n) => {
return p + parseFloat(n[query[EXTRA_FIELD]] ?? 0)
@ -419,3 +482,233 @@ function customCalcFunc(query, data, totalCfgMap) {
}
}
}
function getCustomCalcResult(query, axisMap, status: TotalStatus, customCalc) {
const quotaField = query[EXTRA_FIELD]
const { row, col } = axisMap
// 行列交叉总计
if (status.isRowTotal && status.isColTotal) {
return customCalc.rowColTotal?.data?.[quotaField]
}
// 列总计
if (status.isColTotal && !status.isRowSubTotal) {
const { colTotal } = customCalc
const path = getTreePath(query, row)
let val
if (path.length && colTotal) {
path.push(quotaField)
val = get(colTotal.data, path)
}
return val
}
// 列小计
if (status.isColSubTotal && !status.isRowTotal && !status.isRowSubTotal) {
const { colSubTotal } = customCalc
const subLevel = getSubLevel(query, col)
const rowPath = getTreePath(query, row)
const colPath = getTreePath(query, col)
const path = [...rowPath, ...colPath]
const { data } = colSubTotal[subLevel]
let val
if (path.length && data) {
path.push(quotaField)
val = get(data, path)
}
return val
}
// 行总计
if (status.isRowTotal && !status.isColSubTotal) {
const { rowTotal } = customCalc
const path = getTreePath(query, col)
let val
if (path.length && rowTotal) {
path.push(quotaField)
val = get(rowTotal.data, path)
}
return val
}
// 行小计
if (status.isRowSubTotal && !status.isColTotal && !status.isColSubTotal) {
const { rowSubTotal } = customCalc
const rowLevel = getSubLevel(query, row)
const colPath = getTreePath(query, col)
const rowPath = getTreePath(query, row)
const path = [...colPath, ...rowPath]
const { data } = rowSubTotal[rowLevel]
let val
if (path.length && rowSubTotal) {
path.push(quotaField)
val = get(data, path)
}
return val
}
// 行总计里面的列小计
if (status.isRowTotal && status.isColSubTotal) {
const { colSubInRowTotal } = customCalc
const colLevel = getSubLevel(query, col)
const { data } = colSubInRowTotal[colLevel]
const colPath = getTreePath(query, col)
let val
if (colPath.length && colSubInRowTotal) {
colPath.push(quotaField)
val = get(data, colPath)
}
return val
}
// 列总计里面的行小计
if (status.isColTotal && status.isRowSubTotal) {
const { rowSubInColTotal } = customCalc
const rowSubLevel = getSubLevel(query, row)
const data = rowSubInColTotal[rowSubLevel]?.data
const path = getTreePath(query, row)
let val
if (path.length && rowSubInColTotal) {
path.push(quotaField)
val = get(data, path)
}
return val
}
// 列小计里面的行小计
if (status.isColSubTotal && status.isRowSubTotal) {
const { rowSubInColSub } = customCalc
const rowSubLevel = getSubLevel(query, row)
const colSubLevel = getSubLevel(query, col)
const { data } = rowSubInColSub[rowSubLevel][colSubLevel]
const rowPath = getTreePath(query, row)
const colPath = getTreePath(query, col)
const path = [...rowPath, ...colPath]
let val
if (path.length && rowSubInColSub) {
path.push(quotaField)
val = get(data, path)
}
return val
}
}
function getSubLevel(query, axis) {
const fields: [] = axis.map(a => a.dataeaseName)
let subLevel = -1
const queryFields = keys(query)
for (let i = fields.length - 1; i >= 0; i--) {
const field = fields[i]
const index = queryFields.findIndex(f => f === field)
if (index !== -1) {
subLevel++
}
}
return subLevel
}
function getTreePath(query, axis) {
const path = []
const fields = keys(query)
axis.forEach(a => {
const index = fields.findIndex(f => f === a.dataeaseName)
if (index !== -1) {
path.push(query[a.dataeaseName])
}
})
return path
}
function getAggregationAndCalcFuncByQuery(totalsStatus, totalsOptions) {
const { isRowTotal, isRowSubTotal, isColTotal, isColSubTotal } = totalsStatus
const { row, col } = totalsOptions || {}
const { calcTotals: rowCalcTotals = {}, calcSubTotals: rowCalcSubTotals = {} } = row || {}
const { calcTotals: colCalcTotals = {}, calcSubTotals: colCalcSubTotals = {} } = col || {}
const getCalcTotals = (dimensionTotals: CalcTotals, isTotal: boolean) => {
if ((dimensionTotals.aggregation || dimensionTotals.calcFunc) && isTotal) {
return {
aggregation: dimensionTotals.aggregation,
calcFunc: dimensionTotals.calcFunc
}
}
}
// 优先级: 列总计/小计 > 行总计/小计
return (
getCalcTotals(colCalcTotals, isColTotal) ||
getCalcTotals(colCalcSubTotals, isColSubTotal) ||
getCalcTotals(rowCalcTotals, isRowTotal) ||
getCalcTotals(rowCalcSubTotals, isRowSubTotal)
)
}
export const isNotNumber = (value: unknown) => {
if (typeof value === 'number') {
return Number.isNaN(value)
}
if (!value) {
return true
}
if (typeof value === 'string') {
return Number.isNaN(Number(value))
}
return true
}
const processFieldValues = (data: DataItem[], field: string, filterIllegalValue = false) => {
if (!data?.length) {
return []
}
return data.reduce<Array<Decimal>>((resultArr, item) => {
const fieldValue = get(item, field)
const notNumber = isNotNumber(fieldValue)
if (filterIllegalValue && notNumber) {
// 过滤非法值
return resultArr
}
const val = notNumber ? 0 : fieldValue
resultArr.push(new Decimal(val))
return resultArr
}, [])
}
export const getDataSumByField = (data: DataItem[], field: string): number => {
const fieldValues = processFieldValues(data, field)
if (!fieldValues.length) {
return 0
}
return Decimal.sum(...fieldValues).toNumber()
}
export const getDataExtremumByField = (
method: 'min' | 'max',
data: DataItem[],
field: string
): number => {
// 防止预处理时默认值 0 影响极值结果处理时需过滤非法值
const fieldValues = processFieldValues(data, field, true)
if (!fieldValues?.length) {
return
}
return Decimal[method](...fieldValues).toNumber()
}
export const getDataAvgByField = (data: DataItem[], field: string): number => {
const fieldValues = processFieldValues(data, field)
if (!fieldValues?.length) {
return 0
}
return Decimal.sum(...fieldValues)
.dividedBy(fieldValues.length)
.toNumber()
}
const calcActionByType: {
[type in Aggregation]: (data: DataItem[], field: string) => number
} = {
[Aggregation.SUM]: getDataSumByField,
[Aggregation.MIN]: (data, field) => getDataExtremumByField('min', data, field),
[Aggregation.MAX]: (data, field) => getDataExtremumByField('max', data, field),
[Aggregation.AVG]: getDataAvgByField
}

View File

@ -173,8 +173,8 @@ const renderChart = (viewInfo: Chart, resetPageInfo: boolean) => {
recursionTransObj(customStyleTrans, actualChart.customStyle, scale.value, terminal.value)
setupPage(actualChart, resetPageInfo)
myChart?.facet.timer?.stop()
myChart?.facet.cancelScrollFrame()
myChart?.facet?.timer?.stop()
myChart?.facet?.cancelScrollFrame()
myChart?.destroy()
myChart = null
const chartView = chartViewManager.getChartView(
@ -220,7 +220,7 @@ const setupPage = (chart: ChartObj, resetPageInfo?: boolean) => {
}
const mouseMove = () => {
myChart?.facet.timer?.stop()
myChart?.facet?.timer?.stop()
}
const mouseLeave = () => {
@ -673,7 +673,7 @@ const tablePageClass = computed(() => {
</div>
<el-dialog v-model="state.imgEnlarge" append-to-body>
<div class="enlarge-image">
<img :src="state.imgSrc" />
<img :src="state.imgSrc" style="width: 100%; height: 100%; object-fit: contain" />
</div>
</el-dialog>
</template>
@ -728,7 +728,7 @@ const tablePageClass = computed(() => {
display: flex;
width: 100%;
height: 100%;
overflow: auto;
overflow: hidden;
flex-direction: row;
justify-content: center;
}

View File

@ -0,0 +1,10 @@
package io.dataease.extensions.view.dto;
import lombok.Data;
import java.util.List;
@Data
public class TableCalcTotal {
private List<TableCalcTotalCfg> cfg;
}

View File

@ -0,0 +1,13 @@
package io.dataease.extensions.view.dto;
import lombok.Data;
@Data
public class TableCalcTotalCfg {
private String dataeaseName;
private String aggregation;
private String originName;
private int extField;
private long chartId;
private long datasetGroupId;
}

View File

@ -0,0 +1,9 @@
package io.dataease.extensions.view.dto;
import lombok.Data;
@Data
public class TableTotal {
private TableTotalCfg row;
private TableTotalCfg col;
}

View File

@ -0,0 +1,11 @@
package io.dataease.extensions.view.dto;
import lombok.Data;
@Data
public class TableTotalCfg {
private boolean showGrandTotals;
private boolean showSubTotals;
private TableCalcTotal calcTotals;
private TableCalcTotal calcSubTotals;
}