diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ChartBlockModel.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ChartBlockModel.tsx index 753a9bad8a7..92413468e69 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ChartBlockModel.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ChartBlockModel.tsx @@ -85,7 +85,7 @@ export class ChartBlockModel extends DataBlockModel { return this.getStepParams('chartSettings', 'configure'); } - onInit(options) { + async onInit(options) { super.onInit(options); this.context.defineProperty('chartRef', { get: () => createRef(), @@ -119,7 +119,7 @@ export class ChartBlockModel extends DataBlockModel { if (initQuery) { this.applyQuery(initQuery); // 依赖 refresh 事件驱动渲染 - this.resource.refresh(); + await this.resource.refresh(); } } catch (e) { // 初始阶段不打断页面加载,错误信息写入预览 store 以便排查 @@ -199,6 +199,7 @@ export class ChartBlockModel extends DataBlockModel { (this.resource as SQLResource).setDebug(true); (this.resource as SQLResource).setSQL(query.sql); } else { + console.log('---applyQuery', query); (this.resource as ChartResource).setQueryParams(query); } } @@ -250,6 +251,7 @@ export class ChartBlockModel extends DataBlockModel { // 预览,暂存预览前的 stepParams,并刷新图表 async onPreview(params: { query: any; chart: any }, needQueryData?: boolean) { + console.log('---onPreview', params.query); const values = _.cloneDeep(params); if (!values) return; @@ -293,8 +295,6 @@ const PreviewButton = ({ style }) => { variant="outlined" style={style} onClick={async () => { - // 先提交以确保 form.values 最新且通过校验 - await form.submit(); // 这里通过普通的 form.values 拿不到数据 const formValues = ctx.getStepFormValues('chartSettings', 'configure'); // 写入配置参数,统一走 onPreview 方便回滚 @@ -338,7 +338,7 @@ ChartBlockModel.registerFlow({ uiMode: { type: 'embed', props: { - minWidth: '510px', // 最小宽度 支持 measures field 完整展示 6 个字不换行 + // minWidth: '510px', // 最小宽度 支持 measures field 完整展示 6 个字不换行 footer: (originNode, { OkBtn }) => (
@@ -377,6 +377,7 @@ ChartBlockModel.registerFlow({ }; }, async handler(ctx, params) { + console.log('---setting flow handler', params); const { query, chart } = params; if (!query || !chart) { return; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ChartOptionsBuilder.service.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ChartOptionsBuilder.service.ts index a10bf02513d..6e98a639fb0 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ChartOptionsBuilder.service.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ChartOptionsBuilder.service.ts @@ -8,7 +8,7 @@ */ // 图表类型 -export type ChartTypeKey = 'line' | 'bar' | 'barHorizontal' | 'pie' | 'scatter'; +export type ChartTypeKey = 'line' | 'bar' | 'barHorizontal' | 'pie' | 'scatter' | 'area'; const TYPE_FIELD_SPECS = { line: [ @@ -41,6 +41,13 @@ const TYPE_FIELD_SPECS = { { name: 'seriesField', valueType: 'string' }, { name: 'sizeField', valueType: 'string' }, ], + area: [ + { name: 'xField', valueType: 'string', required: true }, + { name: 'yField', valueType: 'string', required: true }, + { name: 'seriesField', valueType: 'string' }, + { name: 'smooth', valueType: 'boolean' }, + { name: 'stack', valueType: 'boolean' }, + ], }; const BOOL_LABEL_KEY = { smooth: 'Smooth', stack: 'Stack' }; @@ -65,7 +72,7 @@ export function getChartFormSpec(type: ChartTypeKey) { } if (fs.valueType === 'boolean') { return { - kind: 'checkbox', + kind: 'switch', name: fs.name, labelKey: (BOOL_LABEL_KEY as any)[fs.name] || fs.name, }; @@ -163,11 +170,26 @@ const normalizeScatter = (builder = {}, columns: string[]) => { return next; }; +const normalizeArea = (builder = {}, columns: string[]) => { + const next = stripInvalidColumns(builder, columns); + next.type = 'area'; + const { a, b } = pickFirstTwo(columns); + if (!next.xField && a) next.xField = a; + if (!next.yField && b) next.yField = b; + delete next.pieCategory; + delete next.pieValue; + delete next.pieRadiusInner; + delete next.pieRadiusOuter; + delete next.sizeField; + return next; +}; + const applyLine = (builder, columns: string[]) => normalizeLine({ ...builder }, columns); const applyBar = (builder, columns: string[]) => normalizeBar({ ...builder }, columns); const applyBarHorizontal = (builder, columns: string[]) => normalizeBarHorizontal({ ...builder }, columns); const applyPie = (builder, columns: string[]) => normalizePie({ ...builder }, columns); const applyScatter = (builder, columns: string[]) => normalizeScatter({ ...builder }, columns); +const applyArea = (builder, columns: string[]) => normalizeArea({ ...builder }, columns); const s = (v) => JSON.stringify(v ?? ''); @@ -208,7 +230,7 @@ return (function () { return code; }; -const genRawLineOrBar = (type: 'line' | 'bar', builder: any) => { +const genRawLine = (builder: any) => { const { xField, yField, @@ -218,6 +240,7 @@ const genRawLineOrBar = (type: 'line' | 'bar', builder: any) => { label = false, smooth = false, stack = false, + boundaryGap = false, } = builder || {}; if (!xField || !yField) { @@ -243,11 +266,12 @@ return (function () { const option = { tooltip: { show: ${!!tooltip} }, legend: { show: ${!!legend} }, - xAxis: { type: 'category' }, + xAxis: { type: 'category', boundaryGap: ${!!boundaryGap} }, yAxis: { type: 'value' }, series: Array.from(seriesMap.entries()).map(([key, arr]) => ({ - type: ${s(type)}, - name: key === '__default__' ? ${s('')} : String(key), + type: 'line', + // 默认系列名:无 seriesField 时用 yField,确保图例可见 + name: key === '__default__' ? yField : String(key), data: arr.map(p => [p.x, p.y]), smooth: ${!!smooth}, stack: ${!!stack} ? 'total' : undefined, @@ -259,8 +283,69 @@ return (function () { return code; }; +const genRawBar = (builder: any) => { + const { + xField, + yField, + seriesField, + tooltip = true, + legend = true, + label = false, + smooth = false, + stack = false, + boundaryGap = true, + } = builder || {}; + + if (!xField || !yField) { + return `return { tooltip: { show: ${!!tooltip} }, legend: { show: ${!!legend} } };`; + } + + const code = ` +return (function () { + const data = (ctx && ctx.data && ctx.data.objects) || []; + const xField = ${s(xField)}; + const yField = ${s(yField)}; + const seriesField = ${s(seriesField)}; + + const seriesMap = new Map(); + data.forEach(row => { + const seriesKey = seriesField ? row[seriesField] : '__default__'; + if (!seriesMap.has(seriesKey)) { + seriesMap.set(seriesKey, []); + } + seriesMap.get(seriesKey).push({ x: row[xField], y: row[yField] }); + }); + + const option = { + tooltip: { show: ${!!tooltip} }, + legend: { show: ${!!legend} }, + xAxis: { type: 'category', boundaryGap: ${!!boundaryGap} }, + yAxis: { type: 'value' }, + series: Array.from(seriesMap.entries()).map(([key, arr]) => ({ + type: 'bar', + // 默认系列名:无 seriesField 时用 yField + name: key === '__default__' ? yField : String(key), + data: arr.map(p => [p.x, p.y]), + stack: ${!!stack} ? 'total' : undefined, + label: { show: ${!!label} }, + })), + }; + return option; +})();`.trim(); + return code; +}; + const genRawBarHorizontal = (builder: any) => { - const { xField, yField, seriesField, tooltip = true, legend = true, label = false, stack = false } = builder || {}; + const { + xField, + yField, + seriesField, + tooltip = true, + legend = true, + label = false, + stack = false, + boundaryGap = true, + } = builder || {}; if (!xField || !yField) { return `return { tooltip: { show: ${!!tooltip} }, legend: { show: ${!!legend} } };`; @@ -287,7 +372,7 @@ return (function () { tooltip: { show: ${!!tooltip} }, legend: { show: ${!!legend} }, xAxis: { type: 'value' }, - yAxis: { type: 'category' }, + yAxis: { type: 'category', boundaryGap: ${!!boundaryGap} }, series: Array.from(seriesMap.entries()).map(([key, arr]) => ({ type: 'bar', name: key === '__default__' ? ${s('')} : String(key), @@ -302,7 +387,16 @@ return (function () { }; const genRawScatter = (builder: any) => { - const { xField, yField, seriesField, sizeField, tooltip = true, legend = true, label = false } = builder || {}; + const { + xField, + yField, + seriesField, + sizeField, + tooltip = true, + legend = true, + label = false, + boundaryGap = true, + } = builder || {}; if (!xField || !yField) { return `return { tooltip: { show: ${!!tooltip} }, legend: { show: ${!!legend} } };`; @@ -341,7 +435,7 @@ return (function () { const option = { tooltip: { show: ${!!tooltip} }, legend: { show: ${!!legend} }, - xAxis: { type: 'category', data: categories }, + xAxis: { type: 'category', data: categories, boundaryGap: ${!!boundaryGap} }, yAxis: { type: 'value' }, series: Array.from(seriesMap.entries()).map(([key, map]) => { const yArr = categories.map(cat => { @@ -354,7 +448,8 @@ return (function () { }); return { type: 'scatter', - name: key === '__default__' ? ${s('')} : String(key), + // 默认系列名:无 seriesField 时用 yField + name: key === '__default__' ? yField : String(key), data: yArr, symbolSize: (value, params) => sizeArr[params.dataIndex] || 20, label: { show: ${!!label} }, @@ -366,9 +461,62 @@ return (function () { return code; }; +const genRawArea = (builder: any) => { + const { + xField, + yField, + seriesField, + tooltip = true, + legend = true, + label = false, + smooth = false, + stack = false, + boundaryGap = false, + } = builder || {}; + + if (!xField || !yField) { + return `return { tooltip: { show: ${!!tooltip} }, legend: { show: ${!!legend} } };`; + } + + const code = ` +return (function () { + const data = (ctx && ctx.data && ctx.data.objects) || []; + const xField = ${s(xField)}; + const yField = ${s(yField)}; + const seriesField = ${s(seriesField)}; + + const seriesMap = new Map(); + data.forEach(row => { + const seriesKey = seriesField ? row[seriesField] : '__default__'; + if (!seriesMap.has(seriesKey)) { + seriesMap.set(seriesKey, []); + } + seriesMap.get(seriesKey).push({ x: row[xField], y: row[yField] }); + }); + + const option = { + tooltip: { show: ${!!tooltip} }, + legend: { show: ${!!legend} }, + xAxis: { type: 'category', boundaryGap: ${!!boundaryGap} }, + yAxis: { type: 'value' }, + series: Array.from(seriesMap.entries()).map(([key, arr]) => ({ + type: 'line', + name: key === '__default__' ? ${s('')} : String(key), + data: arr.map(p => [p.x, p.y]), + smooth: ${!!smooth}, + stack: ${!!stack} ? 'total' : undefined, + areaStyle: {}, + label: { show: ${!!label} }, + })), + }; + return option; +})();`.trim(); + return code; +}; + const TYPE_REGISTRY = { - line: { key: 'line', normalize: normalizeLine, applyType: applyLine, genRaw: (b) => genRawLineOrBar('line', b) }, - bar: { key: 'bar', normalize: normalizeBar, applyType: applyBar, genRaw: (b) => genRawLineOrBar('bar', b) }, + line: { key: 'line', normalize: normalizeLine, applyType: applyLine, genRaw: genRawLine }, + bar: { key: 'bar', normalize: normalizeBar, applyType: applyBar, genRaw: genRawBar }, barHorizontal: { key: 'barHorizontal', normalize: normalizeBarHorizontal, @@ -377,6 +525,7 @@ const TYPE_REGISTRY = { }, pie: { key: 'pie', normalize: normalizePie, applyType: applyPie, genRaw: genRawPie }, scatter: { key: 'scatter', normalize: normalizeScatter, applyType: applyScatter, genRaw: genRawScatter }, + area: { key: 'area', normalize: normalizeArea, applyType: applyArea, genRaw: genRawArea }, }; // 纯函数:按图表类型规范化(补默认、删无关字段) diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ChartOptionsBuilder.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ChartOptionsBuilder.tsx index 76b58ccd958..31caade10a0 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ChartOptionsBuilder.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ChartOptionsBuilder.tsx @@ -7,16 +7,16 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ import React, { useEffect, useMemo, useState, useRef } from 'react'; -import { Select, InputNumber, Checkbox, Form } from 'antd'; +import { Select, InputNumber, Switch, Form } from 'antd'; import { useT } from '../../locale'; import { normalizeBuilder, applyTypeChange, buildFieldOptions, getChartFormSpec } from './ChartOptionsBuilder.service'; import type { ChartTypeKey } from './ChartOptionsBuilder.service'; -import { sleep } from '../utils'; +import { sleep, appendColon } from '../utils'; +import { useFlowSettingsContext } from '@nocobase/flow-engine'; -// 表单项原子类型定义(保持在 UI 层) type FormItemSpec = | { kind: 'select'; name: string; label?: string; required?: boolean; allowClear?: boolean; placeholderKey?: string } - | { kind: 'checkbox'; name: string; labelKey: string } + | { kind: 'switch'; name: string; labelKey: string } | { kind: 'number'; name: string; labelKey: string; min?: number; max?: number }; export const ChartOptionsBuilder: React.FC<{ @@ -26,6 +26,14 @@ export const ChartOptionsBuilder: React.FC<{ }> = ({ columns, initialValues, onChange }) => { const t = useT(); const [form] = Form.useForm(); + const ctx = useFlowSettingsContext(); + const lang = ctx?.i18n?.language; + + // 为通用布尔项注入默认值,保证 UI 与生成配置一致 + const computedInitialValues = useMemo( + () => ({ legend: true, tooltip: true, label: false, ...initialValues }), + [initialValues], + ); // 程序化回填时抑制一次 onValuesChange,避免循环 const ignoreOnValuesChangeRef = useRef(false); @@ -71,21 +79,22 @@ export const ChartOptionsBuilder: React.FC<{
{/* 图表类型 */} - + {appendColon(t('Chart type'), lang)}} + name="type" + required + > ); } - if (spec.kind === 'checkbox') { + if (spec.kind === 'switch') { return ( - - {t(spec.labelKey)} + {appendColon(t(spec.labelKey), lang)}} + > + ); } if (spec.kind === 'number') { return ( - + {appendColon(t(spec.labelKey), lang)}} + name={spec.name} + > ); } return null; -}; +} const renderChartOptions = ( type: ChartTypeKey, - options: { t: (s: string) => string; fieldOptions: { label: string; value: string }[] }, + options: { t: (s: string) => string; fieldOptions: { label: string; value: string }[]; lang?: string }, ) => { const formSpecs = getChartFormSpec(type); return <>{(formSpecs as any[]).map((spec) => renderItem(spec as any, options))}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ConfigPanel.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ConfigPanel.tsx index f3d75278f52..1ccf3b12fc0 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ConfigPanel.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ConfigPanel.tsx @@ -33,40 +33,38 @@ export const ConfigPanel: React.FC = () => { }; return ( - <> - - - - ), - }, - { - key: 'chartOptions', - label: t('Chart options'), - children: ( - - - - ), - }, - { - key: 'events', - label: t('Events'), - children: ( - - - - ), - }, - ]} - /> - + {t('Data query')}, + children: ( + + + + ), + }, + { + key: 'chartOptions', + label: {t('Chart options')}, + children: ( + + + + ), + }, + { + key: 'events', + label: {t('Events')}, + children: ( + + + + ), + }, + ]} + /> ); }; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/QueryBuilder.service.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/QueryBuilder.service.ts new file mode 100644 index 00000000000..c6025ef35e8 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/QueryBuilder.service.ts @@ -0,0 +1,165 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { DEFAULT_DATA_SOURCE_KEY } from '@nocobase/client'; +import { formatters } from '../utils'; + +// 纯函数:构建字段树 +export function getFieldOptions(dm: any, compile: (v: any) => string, collectionPath?: string[]) { + const [dataSourceKey, collectionName] = collectionPath || []; + const ds = dm.getDataSource(dataSourceKey || DEFAULT_DATA_SOURCE_KEY); + const cm = ds?.collectionManager; + const fim = dm.collectionFieldInterfaceManager; + + if (!cm || !fim || !collectionName) return []; + + const collectionFields = cm.getCollectionFields(collectionName) || []; + + const toOption = (field: any, depth: number, prefix?: string) => { + if (!field?.interface) return undefined; + const iface = fim.getFieldInterface(field.interface); + if (!iface?.filterable) return undefined; + + const value = prefix ? `${prefix}.${field.name}` : field.name; + const opt: any = { + name: field.name, + title: compile(field?.uiSchema?.title ?? field.name), + key: value, + value: field.name, + }; + + if (depth < 1) { + const children = iface.filterable?.children || []; + if (children.length) { + opt.children = children.map((c: any) => ({ + ...c, + title: compile(c?.title ?? c?.name), + key: `${field.name}.${c.name}`, + value: c.name, + })); + } + if (iface.filterable?.nested && field.target) { + const targetFields = cm.getCollectionFields(field.target) || []; + const nested = targetFields.map((tf: any) => toOption(tf, depth + 1, field.name)).filter(Boolean); + opt.children = [...(opt.children || []), ...nested]; + } + } + return opt; + }; + + return collectionFields.map((f: any) => toOption(f, 0)).filter(Boolean); +} + +// 纯函数:将字段值规范为别名字符串(数组拼接、字符串原样、空值返回空串) +export function aliasOf(val: any): string { + return Array.isArray(val) ? val.filter(Boolean).join('.') : val || ''; +} + +// 纯函数:根据维度“字段值”返回格式化选项 +export function getFormatterOptionsByField(dm: any, collectionPath: string[] | undefined, dimField: any) { + const alias = aliasOf(dimField); + if (!alias) return []; + + const [dataSourceKey, collectionName] = collectionPath || []; + const ds = dm.getDataSource(dataSourceKey || DEFAULT_DATA_SOURCE_KEY); + const cm = ds?.collectionManager; + if (!cm || !collectionName) return []; + + const parts = alias.split('.'); + const [first, second] = parts; + + const rootFields = cm.getCollectionFields(collectionName) || []; + const root = rootFields.find((f: any) => f.name === first); + if (!root) return []; + + const iface = + second && root.target + ? (cm.getCollectionFields(root.target) || []).find((f: any) => f.name === second)?.interface + : root.interface; + + switch (iface) { + case 'datetime': + case 'datetimeTz': + case 'unixTimestamp': + case 'datetimeNoTz': + case 'createdAt': + case 'updatedAt': + return formatters.datetime; + case 'date': + return formatters.date; + case 'time': + return formatters.time; + default: + return []; + } +} + +// 新增:纯函数,构建“数据源/集合”选项(保持原有签名) +export function getCollectionOptions(dm: any, compile: (v: any) => string) { + const allCollections = dm.getAllCollections(); + return allCollections + .filter(({ key, isDBInstance }: any) => key === DEFAULT_DATA_SOURCE_KEY || isDBInstance) + .map(({ key, displayName, collections }: any) => ({ + value: key, + label: compile(displayName), + children: (collections || []).map((c: any) => ({ + value: c.name, + label: compile(c.title ?? c.name), + })), + })); +} + +export function validateQuery(query: Record): { success: boolean; message: string } { + console.log('---validateQuery', query); + if (!query) { + return { success: false, message: 'query is required' }; + } + if (!query.mode) { + return { success: false, message: 'please select query mode' }; + } + if (query.mode === 'sql' && !query.sql) { + return { success: false, message: 'please input SQL' }; + } + if (query.mode === 'builder') { + if (!query.collectionPath?.length) { + return { success: false, message: 'please select datasource and collection' }; + } + if (!query.measures?.length) { + return { success: false, message: 'please select measures' }; + } + // 允许filter整体为空(undefined/null),允许 items 为空或空数组 + const filter = query.filter; + if (filter && Array.isArray(filter.items) && filter.items.length > 0) { + // 递归检测是否存在非法空 path + const hasInvalidPath = (items: any[]): boolean => { + for (const it of items) { + if (it && typeof it === 'object') { + // 组:继续递归 + if (it.logic && Array.isArray(it.items)) { + if (hasInvalidPath(it.items)) return true; + } else { + // 规则项:校验 path + const path = (it as any).path; + if (typeof path === 'string' && path.trim().length === 0) { + return true; + } + } + } + } + return false; + }; + + if (hasInvalidPath(filter.items)) { + return { success: false, message: 'please select filter field' }; + } + } + } + + return { success: true, message: '' }; +} diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/QueryBuilder.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/QueryBuilder.tsx index 84635447ec9..e78d427a149 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/QueryBuilder.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/QueryBuilder.tsx @@ -7,426 +7,325 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import React from 'react'; -import { Field, ObjectField, ArrayField, observer, useForm } from '@formily/react'; -import { InputNumber, FilterGroup, VariableFilterItem } from '@nocobase/client'; -import { useFlowSettingsContext, createCollectionContextMeta } from '@nocobase/flow-engine'; -import { useQueryBuilderLogic } from './queryBuilder.logic'; -import { Space, Collapse, Cascader, Select, Input, Checkbox, Button, Divider } from 'antd'; +import React, { useEffect } from 'react'; +import { FilterGroup, VariableFilterItem } from '@nocobase/client'; +import { useFlowSettingsContext } from '@nocobase/flow-engine'; +import { Form, Space, Collapse, Cascader, Select, Input, Checkbox, Button, InputNumber } from 'antd'; import { DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined, PlusOutlined } from '@ant-design/icons'; -import FormItemLite from './FormItemLite'; import { useT } from '../../locale'; +import { DEFAULT_DATA_SOURCE_KEY, useDataSourceManager, useCompile } from '@nocobase/client'; +import { getFieldOptions, getCollectionOptions, getFormatterOptionsByField } from './QueryBuilder.service'; +import { appendColon } from '../utils'; -// 极简适配器:让 antd 原生组件符合 Formily 的 value/onChange 协议,并对 dataSource 做 options 映射 -const CascaderAdapter: React.FC = ({ dataSource, onChange, onValueChange, ...rest }) => { - return ( - { - onChange?.(v); // 先写入 Formily 表单值 - onValueChange?.(v); // 再触发业务侧副作用 - }} - /> - ); +export type QueryBuilderRef = { + validate: () => Promise; }; -const SelectAdapter: React.FC = ({ dataSource, onChange, onValueChange, ...rest }) => { - return ( - { - const v = e?.target?.value; - onChange?.(v); - onValueChange?.(v); - }} - /> - ); -}; - -const CheckboxAdapter: React.FC = ({ value, onChange, onValueChange, content, children, ...rest }) => { - return ( - { - const v = e?.target?.checked; - onChange?.(v); - onValueChange?.(v); - }} - > - {children ?? content} - - ); -}; - -export const QueryBuilder: React.FC = observer(() => { - const { - token, - collectionOptions, - fieldOptions, - filterOptions, - useFormatterOptions, - useOrderOptions, - useOrderReactionHook, - onCollectionChange, - } = useQueryBuilderLogic(); - +export const QueryBuilder = React.forwardRef< + QueryBuilderRef, + { + initialValues?: any; + onChange?: (v: any) => void; + } +>(({ initialValues, onChange }, ref) => { const t = useT(); - const form = useForm(); + const [form] = Form.useForm(); + const ctx = useFlowSettingsContext(); + const lang = ctx?.i18n?.language; - // 兼容:老数据迁移到新字段 - React.useEffect(() => { - const legacy = form?.values?.query?.settings?.collection; - const current = form?.values?.query?.collectionPath; - if (!current && legacy) { - form.setValuesIn('query.collectionPath', legacy); - } - }, [form]); + React.useImperativeHandle(ref, () => ({ + validate: () => form.validateFields(), + })); + + // 从内表单读取 collectionPath,驱动字段树 + const collectionPath = Form.useWatch('collectionPath', form); + + // 构建集合选项(改为 service 纯函数) + const dm = useDataSourceManager(); + const compile = useCompile(); + const collectionOptions = React.useMemo(() => getCollectionOptions(dm, compile), [dm, compile]); + const fieldOptions = React.useMemo(() => getFieldOptions(dm, compile, collectionPath), [dm, compile, collectionPath]); + + // 切换集合后,清理依赖旧集合的字段配置 + const onCollectionChange = (val: any) => { + form.setFieldsValue({ + collectionPath: val, + measures: [], + dimensions: [], + orders: [], + filter: undefined, + }); + onChange?.(form.getFieldsValue(true)); + }; + + const handleValuesChange = (_: any, allValues: any) => { + console.log('---handleValuesChange', allValues); + onChange?.(allValues); + }; + + // 工具:数组上移/下移 + const moveItem = (name: string, index: number, dir: -1 | 1) => { + const arr = form.getFieldValue(name) || []; + const target = index + dir; + if (target < 0 || target >= arr.length) return; + const next = arr.slice(); + const [item] = next.splice(index, 1); + next.splice(target, 0, item); + form.setFieldsValue({ [name]: next }); + onChange?.({ ...(form.getFieldsValue(true) || {}), [name]: next }); + }; return ( - <> - {/* 设置:数据源/集合 */} - - -
- - - {/* Measures */} - + + {/* 设置:数据源/集合 */} + {appendColon(t('Collection'), lang)}} + rules={[{ required: true }]} > - - {(field) => ( - <> -
- {field.value?.map((item, index) => ( - - - - - - -
- - - )} -
-
- - {/* Dimensions */} - - - {(field) => ( - <> -
- {field.value?.map((item, index) => ( - - - - { - // 仅当存在可选项时显示 - // @ts-ignore - f.visible = !!(f.dataSource && f.dataSource.length); - }, - ]} - component={[SelectAdapter, { placeholder: t('Format'), style: { maxWidth: 120 } }]} - /> - -
- - - )} -
-
- - {/* Filter */} - - (); - return ( - { - console.log('---onChange v', v); - props.onChange(v); - }} - FilterItem={(p) => } - /> - ); - }, - ]} + - + + + {/* Measures */} +
{appendColon(t('Measures'), lang)}
+
+ + {(fields, { add, remove }) => ( + <> +
+ {fields.map((field, idx) => ( + + + + + + + + + {t('Distinct')} + +
+ + + )} +
+
+ + {/* Dimensions 标题 */} +
{appendColon(t('Dimensions'), lang)}
+
+ + {(fields, { add, remove }) => ( + <> +
+ {fields.map((field, idx) => { + const fieldName = field.name; + const dimField = form.getFieldValue(['dimensions', fieldName, 'field']); + const fmtOptions = getFormatterOptionsByField(dm, collectionPath, dimField); + return ( + + + + + {/* 仅当 fmtOptions 有值时展示 Format 选择项 */} + {fmtOptions?.length ? ( + + + +
+ + + )} +
+
+ + {/* Filter 标题 */} +
{appendColon(t('Filter'), lang)}
+
+ + { + form.setFieldsValue({ filter: v }); + onChange?.(form.getFieldsValue(true)); + }} + FilterItem={(p) => { + return ; + }} + /> + +
{/* Sort */} - - - {(field) => ( +
{appendColon(t('Sort'), lang)}
+
+ + {(fields, { add, remove }) => ( <>
- {field.value?.map((item, index) => ( - - - {/* */} - ( + + + + + + - -
- )} - - - +
+
-
+ {/* Limit */} + {appendColon(t('Limit'), lang)}}> + + - {/* Limit */} - - - {/* Offset */} - - + {/* Offset */} + {appendColon(t('Offset'), lang)}}> + + + +
); }); diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/QueryPanel.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/QueryPanel.tsx index ec753609c80..2b355d59dff 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/QueryPanel.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/QueryPanel.tsx @@ -17,7 +17,8 @@ import { QueryBuilder } from './QueryBuilder'; import { ResultPanel } from './ResultPanel'; import { ChartBlockModel } from './ChartBlockModel'; import { useFlowSettingsContext } from '@nocobase/flow-engine'; -import { sleep } from '../utils'; +import { configStore } from './config-store'; +import { validateQuery } from './QueryBuilder.service'; const QueryMode: React.FC = connect(({ value = 'builder', onChange, onClick }) => { const t = useT(); @@ -39,12 +40,15 @@ const QueryMode: React.FC = connect(({ value = 'builder', onChange, onClick }) = ); }); -// 核心修改:将顶部“模式切换 + Result/Run”合并到本组件的一行头部 export const QueryPanel: React.FC = observer(() => { const t = useT(); const form = useForm(); const ctx = useFlowSettingsContext(); const mode = form?.values?.query?.mode || 'builder'; + const qbRef = React.useRef(null); + + const [showResult, setShowResult] = useState(false); + const [running, setRunning] = useState(false); React.useEffect(() => { // 在 SQL 模式下,隐藏并取消校验 builder 模式相关字段,避免全表单 submit 时的必填校验 @@ -89,22 +93,50 @@ export const QueryPanel: React.FC = observer(() => { } }, [mode, form]); - const [showResult, setShowResult] = useState(false); - const [running, setRunning] = useState(false); + // 图形化模式 + const handleBuilderChange = async (next: any) => { + console.log('handleBuilderChange', next); + const query = form?.values?.query || {}; + form?.setValuesIn?.('query', { + ...next, + mode: query.mode, + sql: query.sql, + }); + }; + + // SQL 模式 + // const handleSqlChange = async (sql: string) => { + // form?.setValuesIn?.('query.sql', sql); + // }; const handleRunQuery = async () => { try { setRunning(true); - // builder 模式先提交表单做校验;sql 模式不需要校验 - if (form.values?.mode === 'builder') { - await form.submit(); + // 触发下层 QueryBuilder 的校验 + if (mode === 'builder') { + try { + await qbRef.current?.validate(); + } catch { + setRunning(false); + return; + } } - // 写入查询参数,统一走 onPreview 方便回滚 + + // 业务自定义校验 + const query = form.values?.query; + const { success, message } = validateQuery(query); + if (!success) { + configStore.setError(ctx.model.uid, message); + setShowResult(true); + return; + } + + // 通过校验后,写入查询参数并预览 await ctx.model.onPreview(form.values, true); - // 显示数据结果面板 setShowResult(true); } catch (error: any) { - console.error(error); + configStore.setError(ctx.model.uid, error?.message); + setShowResult(true); } finally { setRunning(false); } @@ -119,7 +151,7 @@ export const QueryPanel: React.FC = observer(() => { alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px', - // 解决父容器裁剪导致圆角/边框被吃掉 + // 解决父容器裁剪导致圆角/边框被吃掉的问题 paddingTop: 1, paddingLeft: 1, }} @@ -133,18 +165,18 @@ export const QueryPanel: React.FC = observer(() => { {t('Run query')}
- {/* 下面保持不变 */} + {showResult ? (
) : mode === 'builder' ? ( - + ) : ( )} diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ResultPanel.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ResultPanel.tsx index 29ec6fff148..fbed3bc4424 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ResultPanel.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/ResultPanel.tsx @@ -12,10 +12,40 @@ import { Tabs, Table, Typography, Alert } from 'antd'; import { TableOutlined, CodeOutlined } from '@ant-design/icons'; import { useT } from '../../locale'; import { configStore } from './config-store'; -import { observer } from '@formily/react'; import { useFlowSettingsContext } from '@nocobase/flow-engine'; const { Paragraph } = Typography; +export const ResultPanel: React.FC = () => { + const t = useT(); + const ctx = useFlowSettingsContext(); + const uid = ctx.model.uid; + const data = configStore.results[uid]?.result; + const error = configStore.results[uid]?.error; + + return !error ? ( + {t('Table')}, + icon: , + children: , + }, + { + key: 'json', + label: {t('JSON')}, + icon: , + children: , + }, + ]} + /> + ) : ( + + ); +}; + const TableResult: React.FC<{ data: any[]; }> = ({ data }) => { @@ -46,34 +76,3 @@ const JSONResult: React.FC<{ ); }; - -export const ResultPanel: React.FC = observer(() => { - const t = useT(); - const ctx = useFlowSettingsContext(); - const uid = ctx.model.uid; - const data = configStore.results[uid]?.result; - const error = configStore.results[uid]?.error; - - return !error ? ( - , - children: , - }, - { - key: 'json', - label: t('JSON'), - icon: , - children: , - }, - ]} - /> - ) : ( - - ); -}); diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/queryBuilder.logic.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/queryBuilder.logic.ts deleted file mode 100644 index cc7a0ff5548..00000000000 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/queryBuilder.logic.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -import { useMemo } from 'react'; -import { useForm } from '@formily/react'; -import { theme } from 'antd'; -import { DEFAULT_DATA_SOURCE_KEY, useDataSourceManager, useCompile } from '@nocobase/client'; -import { formatters } from '../utils'; - -export const useQueryBuilderLogic = () => { - const form = useForm(); - const { token } = theme.useToken(); - const dm = useDataSourceManager(); - const compile = useCompile(); - - // 读取当前选择的数据源与集合(兼容:新字段优先,回退老字段) - const collectionPath: string[] | undefined = - form?.values?.query?.collectionPath || form?.values?.query?.settings?.collection; - const [dataSourceKey, collectionName] = collectionPath || []; - const ds = dm.getDataSource(dataSourceKey || DEFAULT_DATA_SOURCE_KEY); - const cm = ds?.collectionManager; - const fim = dm.collectionFieldInterfaceManager; - - // 构建集合选项(数据源 -> 集合),不再进行 ACL 过滤 - const collectionOptions = useMemo(() => { - const allCollections = dm.getAllCollections(); - return allCollections - .filter(({ key, isDBInstance }) => key === DEFAULT_DATA_SOURCE_KEY || isDBInstance) - .map(({ key, displayName, collections }) => ({ - value: key, - label: compile(displayName), - children: (collections || []).map((c) => ({ - value: c.name, - label: compile(c.title ?? c.name), - })), - })); - }, [dm, compile]); - - // 工具:拼接级联字段别名 - const aliasOf = (val: any): string => { - if (Array.isArray(val)) return val.filter(Boolean).join('.'); - return val ? String(val) : ''; - }; - - // 字段树(用于 Cascader):最多一层关联 - const fieldOptions = useMemo(() => { - if (!cm || !fim || !collectionName) return []; - const collectionFields = cm.getCollectionFields(collectionName) || []; - - const toOption = (field: any, depth: number, prefix?: string) => { - if (!field?.interface) return undefined; - const iface = fim.getFieldInterface(field.interface); - if (!iface?.filterable) return undefined; - - const value = prefix ? `${prefix}.${field.name}` : field.name; - const opt: any = { - name: field.name, - title: compile(field?.uiSchema?.title ?? field.name), - key: value, - value: field.name, - }; - - // 限制最大深度为 1(与原 QueryBuilder 使用一致) - if (depth < 1) { - // children: filterable.children(如 year/month 等虚拟子项) - const children = iface.filterable?.children || []; - if (children.length) { - opt.children = children.map((c: any) => ({ - ...c, - title: compile(c?.title ?? c?.name), - key: `${field.name}.${c.name}`, - value: c.name, - })); - } - // 关联目标字段作为 children - if (iface.filterable?.nested && field.target) { - const targetFields = cm.getCollectionFields(field.target) || []; - const nested = targetFields.map((tf) => toOption(tf, depth + 1, field.name)).filter(Boolean); - opt.children = [...(opt.children || []), ...nested]; - } - } - - return opt; - }; - - return collectionFields.map((f) => toOption(f, 0)).filter(Boolean); - }, [cm, fim, collectionName, compile, form?.values?.query?.collectionPath]); - - // 过滤器字段与操作符(Filter 用):最多两层关联 - const filterOptions = useMemo(() => { - if (!cm || !fim || !collectionName) return []; - const fields = cm.getCollectionFields(collectionName) || []; - - const toOption = (field: any, depth: number): any => { - if (!field?.interface) return undefined; - const iface = fim.getFieldInterface(field.interface); - if (!iface?.filterable) return undefined; - - const ops = (iface.filterable.operators || []).filter((op: any) => !op?.visible || op.visible(field)) || []; - - const opt: any = { - name: field.name, - title: compile(field?.uiSchema?.title ?? field.name), - schema: field?.uiSchema, - operators: ops, - interface: field.interface, - }; - - if (depth >= 2) return opt; - - // children: filterable.children(虚拟) - if (iface.filterable?.children?.length) { - opt.children = iface.filterable.children.map((c: any) => ({ - ...c, - title: compile(c?.title ?? c?.name), - })); - } - - // 嵌套关联 - if (iface.filterable?.nested && field.target) { - const targetFields = cm.getCollectionFields(field.target) || []; - const nested = targetFields.map((tf) => toOption(tf, depth + 1)).filter(Boolean); - opt.children = [...(opt.children || []), ...nested]; - } - - return opt; - }; - - return fields.map((f) => toOption(f, 0)).filter(Boolean); - }, [cm, fim, collectionName, compile, form?.values?.query?.collectionPath]); - - // 根据字段别名推断 interface(仅处理实际字段/一层关联) - const getInterfaceByAlias = (alias: string): string | undefined => { - if (!alias || !cm || !collectionName) return; - const parts = alias.split('.'); - const [first, second] = parts; - - const rootFields = cm.getCollectionFields(collectionName) || []; - const root = rootFields.find((f) => f.name === first); - if (!root) return; - - if (!second) return root.interface; - - if (root.target) { - const targetFields = cm.getCollectionFields(root.target) || []; - const child = targetFields.find((f) => f.name === second); - return child?.interface; - } - return undefined; - }; - - // 维度格式化选项 reactions - const useFormatterOptions = (field: any) => { - const selected = field.query('.field').get('value'); - const alias = aliasOf(selected); - if (!alias) { - field.dataSource = []; - return; - } - const iface = getInterfaceByAlias(alias); - switch (iface) { - case 'datetime': - case 'datetimeTz': - case 'unixTimestamp': - case 'datetimeNoTz': - case 'createdAt': - case 'updatedAt': - field.dataSource = formatters.datetime; - return; - case 'date': - field.dataSource = formatters.date; - return; - case 'time': - field.dataSource = formatters.time; - return; - default: - field.dataSource = []; - } - }; - - // 展开 fieldOptions 为别名集合(非聚合排序用) - const flattenAllAliases = (options: any[], prefix?: string): string[] => { - const acc: string[] = []; - options.forEach((opt) => { - const self = prefix ? `${prefix}.${opt.value || opt.name}` : opt.value || opt.name; - if (opt.children?.length) { - acc.push(...flattenAllAliases(opt.children, opt.name)); - } else if (self) { - acc.push(self); - } - }); - return acc; - }; - - // 从当前表单 query 中提取选中的字段别名(聚合时作为排序字段列表) - const getSelectedAliases = (query: any): string[] => { - const aliases: string[] = []; - const dims = query?.dimensions || []; - const meas = query?.measures || []; - - dims.forEach((d: any) => { - const a = aliasOf(d?.field); - if (a) aliases.push(a); - }); - meas.forEach((m: any) => { - const a = m?.alias || aliasOf(m?.field); - if (a) aliases.push(a); - }); - - // 去重 - return Array.from(new Set(aliases)); - }; - - // 排序字段候选 reactions - const useOrderOptions = (field: any) => { - const query = field.query('query').get('value') || {}; - const hasAgg = (query.measures || []).some((m: any) => !!m?.aggregation); - - if (hasAgg) { - // 聚合:排序字段为维度与度量(用别名表示) - const selected = getSelectedAliases(query); - field.componentProps.fieldNames = {}; // 扁平 options - field.dataSource = selected.map((v) => ({ label: v, value: v })); - return; - } - - // 非聚合:使用完整字段树 - field.componentProps.fieldNames = { label: 'title', value: 'name', children: 'children' }; - field.dataSource = fieldOptions; - }; - - // 排序数组合法性校验 reactions - const useOrderReactionHook = (arrayField: any) => { - const query = arrayField.query('query').get('value') || {}; - const hasAgg = (query.measures || []).some((m: any) => !!m?.aggregation); - - const allowed = hasAgg ? new Set(getSelectedAliases(query)) : new Set(flattenAllAliases(fieldOptions)); - - const orders = arrayField.value || []; - const next = orders.filter((item: any) => { - const a = aliasOf(item?.field); - // 修复点:未选择字段(a 为空)时保留该条目,避免刚 push 就被清洗掉 - if (!a) return true; - return allowed.has(a); - }); - - // 仅在结果有变化时再写回,避免不必要的 set - const changed = next.length !== orders.length || next.some((v: any, i: number) => v !== orders[i]); - if (changed) { - arrayField.setValue(next); - } - }; - - // 切换collection:清空 builder 配置 - const onCollectionChange = (value: string[]) => { - form.setValuesIn('query.collectionPath', value); - form.setValuesIn('query.measures', []); - form.setValuesIn('query.dimensions', []); - form.setValuesIn('query.filter', undefined); - form.setValuesIn('query.orders', []); - }; - - return { - token, - // 暴露给 UI 的数据与 reactions - collectionOptions, - fieldOptions, - filterOptions, - useFormatterOptions, - useOrderOptions, - useOrderReactionHook, - onCollectionChange, - }; -}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/resources/ChartResource.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/resources/ChartResource.ts index 5ade80d24a4..1524153b99e 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/resources/ChartResource.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/resources/ChartResource.ts @@ -10,21 +10,7 @@ import { BaseRecordResource, FilterItem } from '@nocobase/flow-engine'; import { parseField, removeUnparsableFilter, isEmptyFilterObject } from '../../utils'; import { transformFilter } from '@nocobase/utils/client'; - -function fixWrongData(filter: any): any { - // 处理异常结构,最外面多了一层 $and 或 $or - if (Array.isArray(filter.$and) && filter.$and.length === 1 && filter.$and[0]?.logic) { - filter = filter.$and[0]; - } - if (Array.isArray(filter.$or) && filter.$or.length === 1 && filter.$or[0]?.logic) { - filter = filter.$or[0]; - } - // 处理异常结构,缺少 items 字段 - if ((filter?.logic === '$and' || filter?.logic === '$or') && !Array.isArray(filter.items)) { - filter.items = []; - } - return filter; -} +import { validateQuery } from '../models/QueryBuilder.service'; export class ChartResource extends BaseRecordResource { resourceName = 'charts'; @@ -41,7 +27,7 @@ export class ChartResource extends BaseRecordResource { // 整体数据查询参数,内部 QueryBuilder 调用 setQueryParams(query: Record, mark?: string) { - const { success, message } = this.validateQuery(query); + const { success, message } = validateQuery(query); if (!success) { // 这里过程性校验 不强制报错,只做提示 console.warn(message); @@ -98,22 +84,6 @@ export class ChartResource extends BaseRecordResource { return this; } - validateQuery(query: Record): { success: boolean; message: string } { - if (!query) { - return { success: false, message: 'validate: query is required' }; - } - if (!query.mode) { - return { success: false, message: 'validate: query mode is required' }; - } - if (query.mode === 'sql' && !query.sql) { - return { success: false, message: 'validate: sql is required when mode is sql' }; - } - if (query.mode === 'builder' && (!query.collectionPath?.length || !query.measures?.length)) { - return { success: false, message: 'validate: collection and measures are required when mode is builder' }; - } - return { success: true, message: '' }; - } - // 解析 queryBuider 表单值为请求参数 parseQuery(query: Record) { const [dataSource, collection] = query.collectionPath || []; @@ -176,6 +146,7 @@ export class ChartResource extends BaseRecordResource { // debounce 刷新数据 async refresh() { + console.log('---ChartResource refresh'); if (this.refreshTimer) { clearTimeout(this.refreshTimer); } diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/utils.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/utils.ts index 1eaaefaec3a..2942293029a 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/utils.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/utils.ts @@ -117,3 +117,18 @@ export function sleep(ms: number): Promise { setTimeout(resolve, ms); }); } + +export function appendColon(label: string, lang?: string): string { + if (typeof label !== 'string') { + return ''; + } + const trimmed = label.trim(); + if (!trimmed) { + return ''; + } + // 先移除末尾已有的半角/全角冒号(以及其后的空白) + const noColon = trimmed.replace(/[::]\s*$/u, ''); + const isZh = typeof lang === 'string' && /^zh([-_]|$)/i.test(lang); + const colon = isZh ? ':' : ':'; + return `${noColon}${colon}`; +} diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/utils.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/utils.ts index b8729b84edd..203b1f9a51f 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/utils.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/utils.ts @@ -112,10 +112,22 @@ export const removeUnparsableFilter = (filter: any) => { const newLogic = filter.map((condition) => removeUnparsableFilter(condition)).filter(Boolean); return newLogic.length > 0 ? newLogic : null; } else { - const newLogic = {}; - for (const key in filter) { - const value = removeUnparsableFilter(filter[key]); - if (value !== null && value !== undefined && !(typeof value === 'object' && Object.keys(value).length === 0)) { + const newLogic: any = {}; + for (const [key, rawVal] of Object.entries(filter)) { + // 跳过无效键:空字符串或仅空白 + if (typeof key === 'string' && key.trim().length === 0) { + continue; + } + const value = removeUnparsableFilter(rawVal); + // 丢弃空值/空对象/空数组 + if ( + value !== null && + value !== undefined && + !( + typeof value === 'object' && + ((Array.isArray(value) && value.length === 0) || Object.keys(value).length === 0) + ) + ) { newLogic[key] = value; } } diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-data-visualization/src/locale/en-US.json index aa705342927..0893b470f75 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/locale/en-US.json +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/locale/en-US.json @@ -21,7 +21,8 @@ "Query": "Query", "Data": "Data", "Run query": "Run query", - "Data result": "Data result", + "View data": "View data", + "Hide data": "Hide data", "Measures": "Measures", "Dimensions": "Dimensions", "Filter": "Filter", diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-data-visualization/src/locale/zh-CN.json index 217dc8b589a..744585d73a2 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/locale/zh-CN.json @@ -21,7 +21,8 @@ "Query": "查询", "Data": "数据", "Run query": "运行查询", - "Data result": "数据结果", + "View data": "查看数据", + "Hide data": "收起数据", "Measures": "度量", "Dimensions": "维度", "Filter": "过滤",