Feat/plugin data vi (#7597)

* feat: update chart data flow, add auto preview

* feat: chart update i18n

* feat: chart plugin extend chart  type

* fix: revert core changes

* fix: revert core changes

* feat: chart query builder refactor, remove formily

* feat: chart add Area type

* feat: chart plugin line/bar/area add default seriesField

* fix: chart query data validate filter
This commit is contained in:
Ziqiang
2025-10-15 20:25:54 +08:00
committed by GitHub
parent dbead2e9a8
commit f0a0f80c23
14 changed files with 840 additions and 844 deletions
@@ -85,7 +85,7 @@ export class ChartBlockModel extends DataBlockModel<ChartBlockModelStructure> {
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<ChartBlockModelStructure> {
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<ChartBlockModelStructure> {
(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<ChartBlockModelStructure> {
// 预览,暂存预览前的 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 }) => (
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
<CancelButton style={{ marginRight: 6 }} />
@@ -377,6 +377,7 @@ ChartBlockModel.registerFlow({
};
},
async handler(ctx, params) {
console.log('---setting flow handler', params);
const { query, chart } = params;
if (!query || !chart) {
return;
@@ -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 },
};
// 纯函数:按图表类型规范化(补默认、删无关字段)
@@ -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<any>();
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<{
<div style={{ padding: 1 }}>
<Form
form={form}
layout="horizontal"
labelCol={{ flex: '120px' }}
wrapperCol={{ flex: 'auto' }}
labelAlign="right"
colon={false}
style={{ textAlign: 'left' }}
initialValues={initialValues}
layout="vertical"
colon
initialValues={computedInitialValues}
onValuesChange={handleValuesChange}
>
{/* 图表类型 */}
<Form.Item label={t('Chart type')} name="type">
<Form.Item
label={<span style={{ fontWeight: 500 }}>{appendColon(t('Chart type'), lang)}</span>}
name="type"
required
>
<Select
style={{ width: 160 }}
options={[
{ label: t('Line'), value: 'line' },
{ label: t('Area'), value: 'area' },
{ label: t('Column'), value: 'bar' },
{ label: t('Bar'), value: 'barHorizontal' },
{ label: t('Pie'), value: 'pie' },
@@ -95,65 +104,89 @@ export const ChartOptionsBuilder: React.FC<{
</Form.Item>
{/* 图表属性 */}
{renderChartOptions(type, { t, fieldOptions })}
{renderChartOptions(type, { t, fieldOptions, lang })}
{/* 公共属性 */}
{/* <Form.Item label={t('Height')} name="height">
<InputNumber min={100} style={{ width: 160 }} />
</Form.Item> */}
<Form.Item name="legend" valuePropName="checked" colon={false} label=" ">
<Checkbox>{t('Legend')}</Checkbox>
<Form.Item
name="legend"
valuePropName="checked"
label={<span style={{ fontWeight: 500 }}>{appendColon(t('Legend'), lang)}</span>}
>
<Switch />
</Form.Item>
<Form.Item name="tooltip" valuePropName="checked" colon={false} label=" ">
<Checkbox>{t('Tooltip')}</Checkbox>
<Form.Item
name="tooltip"
valuePropName="checked"
label={<span style={{ fontWeight: 500 }}>{appendColon(t('Tooltip'), lang)}</span>}
>
<Switch />
</Form.Item>
<Form.Item name="label" valuePropName="checked" colon={false} label=" ">
<Checkbox>{t('Label')}</Checkbox>
<Form.Item
name="label"
valuePropName="checked"
label={<span style={{ fontWeight: 500 }}>{appendColon(t('Label'), lang)}</span>}
>
<Switch />
</Form.Item>
</Form>
</div>
);
};
const renderItem = (
function renderItem(
spec: FormItemSpec,
ctx: { t: (s: string) => string; fieldOptions: { label: string; value: string }[] },
) => {
const { t, fieldOptions } = ctx;
ctx: { t: (s: string) => string; fieldOptions: { label: string; value: string }[]; lang?: string },
) {
const { t, fieldOptions, lang } = ctx;
if (spec.kind === 'select') {
const label = spec.label ? spec.label : undefined;
const placeholder = spec.placeholderKey ? t(spec.placeholderKey) : undefined;
return (
<Form.Item key={spec.name} label={label ?? undefined} name={spec.name} required={spec.required}>
<Form.Item
key={spec.name}
label={<span style={{ fontWeight: 500 }}>{appendColon(spec.label ?? '', lang)}</span>}
name={spec.name}
required={spec.required}
>
<Select
style={{ width: 160 }}
allowClear={!!spec.allowClear}
placeholder={placeholder}
placeholder={spec.placeholderKey ? t(spec.placeholderKey) : undefined}
options={fieldOptions}
/>
</Form.Item>
);
}
if (spec.kind === 'checkbox') {
if (spec.kind === 'switch') {
return (
<Form.Item key={spec.name} name={spec.name} valuePropName="checked" colon={false} label=" ">
<Checkbox>{t(spec.labelKey)}</Checkbox>
<Form.Item
key={spec.name}
name={spec.name}
valuePropName="checked"
label={<span style={{ fontWeight: 500 }}>{appendColon(t(spec.labelKey), lang)}</span>}
>
<Switch />
</Form.Item>
);
}
if (spec.kind === 'number') {
return (
<Form.Item key={spec.name} label={t(spec.labelKey)} name={spec.name}>
<Form.Item
key={spec.name}
label={<span style={{ fontWeight: 500 }}>{appendColon(t(spec.labelKey), lang)}</span>}
name={spec.name}
>
<InputNumber min={spec.min} max={spec.max} style={{ width: 160 }} />
</Form.Item>
);
}
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))}</>;
@@ -33,40 +33,38 @@ export const ConfigPanel: React.FC = () => {
};
return (
<>
<Collapse
activeKey={activeKeys}
onChange={setActiveKeys}
items={[
{
key: 'query',
label: t('Data query'),
children: (
<Card style={getCardStyle('query')} styles={{ body: { padding: 0 } }}>
<QueryPanel />
</Card>
),
},
{
key: 'chartOptions',
label: t('Chart options'),
children: (
<Card style={getCardStyle('chartOptions')} styles={{ body: { padding: 0 } }}>
<ChartOptionsPanel />
</Card>
),
},
{
key: 'events',
label: t('Events'),
children: (
<Card style={getCardStyle('events')} styles={{ body: { padding: 0 } }}>
<EventsPanel />
</Card>
),
},
]}
/>
</>
<Collapse
activeKey={activeKeys}
onChange={setActiveKeys}
items={[
{
key: 'query',
label: <span style={{ fontWeight: 500 }}>{t('Data query')}</span>,
children: (
<Card style={getCardStyle('query')} styles={{ body: { padding: 0 } }}>
<QueryPanel />
</Card>
),
},
{
key: 'chartOptions',
label: <span style={{ fontWeight: 500 }}>{t('Chart options')}</span>,
children: (
<Card style={getCardStyle('chartOptions')} styles={{ body: { padding: 0 } }}>
<ChartOptionsPanel />
</Card>
),
},
{
key: 'events',
label: <span style={{ fontWeight: 500 }}>{t('Events')}</span>,
children: (
<Card style={getCardStyle('events')} styles={{ body: { padding: 0 } }}>
<EventsPanel />
</Card>
),
},
]}
/>
);
};
@@ -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<string, any>): { 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: '' };
}
@@ -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<any> = ({ dataSource, onChange, onValueChange, ...rest }) => {
return (
<Cascader
{...rest}
options={dataSource}
onChange={(v) => {
onChange?.(v); // 先写入 Formily 表单值
onValueChange?.(v); // 再触发业务侧副作用
}}
/>
);
export type QueryBuilderRef = {
validate: () => Promise<any>;
};
const SelectAdapter: React.FC<any> = ({ dataSource, onChange, onValueChange, ...rest }) => {
return (
<Select
{...rest}
options={dataSource}
onChange={(v) => {
onChange?.(v);
onValueChange?.(v);
}}
/>
);
};
const InputAdapter: React.FC<any> = ({ onChange, onValueChange, ...rest }) => {
return (
<Input
{...rest}
onChange={(e) => {
const v = e?.target?.value;
onChange?.(v);
onValueChange?.(v);
}}
/>
);
};
const CheckboxAdapter: React.FC<any> = ({ value, onChange, onValueChange, content, children, ...rest }) => {
return (
<Checkbox
{...rest}
checked={!!value}
onChange={(e) => {
const v = e?.target?.checked;
onChange?.(v);
onValueChange?.(v);
}}
>
{children ?? content}
</Checkbox>
);
};
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<any>();
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 (
<>
{/* 设置:数据源/集合 */}
<Field
name="collectionPath"
title={t('Collection')}
decorator={[FormItemLite]}
component={[
CascaderAdapter,
{
showSearch: true,
placeholder: t('Collection'),
dataSource: collectionOptions,
onValueChange: onCollectionChange,
style: { width: 222, marginBottom: 4 },
},
]}
/>
<div style={{ margin: '4px 0' }} />
<Collapse
size="small"
bordered={false}
defaultActiveKey={['measures']}
style={{ border: 'none', boxShadow: 'none' }}
>
{/* Measures */}
<Collapse.Panel
key="measures"
header={t('Measures')}
style={{ background: token.colorBgContainer, marginBottom: 8 }}
<div style={{ paddingTop: 8 }}>
<Form form={form} layout="vertical" initialValues={initialValues} onValuesChange={handleValuesChange}>
{/* 设置:数据源/集合 */}
<Form.Item
name="collectionPath"
label={<span style={{ fontWeight: 500 }}>{appendColon(t('Collection'), lang)}</span>}
rules={[{ required: true }]}
>
<ArrayField name="measures" required>
{(field) => (
<>
<div style={{ overflow: 'auto' }}>
{field.value?.map((item, index) => (
<ObjectField name={index} key={index}>
<Space wrap align="center" size={[8, 4]} style={{ marginBottom: 8 }}>
<Field
name="field"
required
decorator={[FormItemLite]}
component={[
CascaderAdapter,
{
placeholder: t('Select Field'),
fieldNames: { label: 'title', value: 'name', children: 'children' },
dataSource: fieldOptions,
style: { minWidth: 100 },
},
]}
/>
<Field
name="aggregation"
decorator={[FormItemLite]}
component={[
SelectAdapter,
{
placeholder: t('Aggregation'),
style: { minWidth: 75 },
dataSource: [
{ label: t('Sum'), value: 'sum' },
{ label: t('Count'), value: 'count' },
{ label: t('Avg'), value: 'avg' },
{ label: t('Max'), value: 'max' },
{ label: t('Min'), value: 'min' },
],
},
]}
/>
<Field
name="alias"
decorator={[FormItemLite]}
component={[InputAdapter, { placeholder: t('Alias'), style: { width: 85 } }]}
/>
<Field
name="distinct"
decorator={[FormItemLite]}
component={[CheckboxAdapter]}
content={t('Distinct')}
/>
<Button
size="small"
type="text"
onClick={() => field.remove(index)}
icon={<DeleteOutlined />}
/>
<Button
size="small"
type="text"
disabled={index === 0}
onClick={() => field.moveUp(index)}
icon={<ArrowUpOutlined />}
/>
<Button
size="small"
type="text"
disabled={index === (field.value?.length ?? 0) - 1}
onClick={() => field.moveDown(index)}
icon={<ArrowDownOutlined />}
/>
</Space>
</ObjectField>
))}
</div>
<Button type="dashed" icon={<PlusOutlined />} onClick={() => field.push({})}>
{t('Add field')}
</Button>
</>
)}
</ArrayField>
</Collapse.Panel>
{/* Dimensions */}
<Collapse.Panel
key="dimensions"
header={t('Dimensions')}
style={{ background: token.colorBgContainer, marginBottom: 8 }}
>
<ArrayField name="dimensions">
{(field) => (
<>
<div style={{ overflow: 'auto' }}>
{field.value?.map((item, index) => (
<ObjectField name={index} key={index}>
<Space wrap align="center" size={[8, 4]} style={{ marginBottom: 8 }}>
<Field
name="field"
required
decorator={[FormItemLite]}
component={[
CascaderAdapter,
{
placeholder: t('Select Field'),
fieldNames: { label: 'title', value: 'name', children: 'children' },
dataSource: fieldOptions,
style: { minWidth: 100 },
},
]}
/>
<Field
name="format"
decorator={[FormItemLite]}
reactions={[
useFormatterOptions,
(f) => {
// 仅当存在可选项时显示
// @ts-ignore
f.visible = !!(f.dataSource && f.dataSource.length);
},
]}
component={[SelectAdapter, { placeholder: t('Format'), style: { maxWidth: 120 } }]}
/>
<Field
name="alias"
decorator={[FormItemLite]}
component={[InputAdapter, { placeholder: t('Alias'), style: { width: 100 } }]}
/>
<Button
size="small"
type="text"
onClick={() => field.remove(index)}
icon={<DeleteOutlined />}
/>
<Button
size="small"
type="text"
disabled={index === 0}
onClick={() => field.moveUp(index)}
icon={<ArrowUpOutlined />}
/>
<Button
size="small"
type="text"
disabled={index === (field.value?.length ?? 0) - 1}
onClick={() => field.moveDown(index)}
icon={<ArrowDownOutlined />}
/>
</Space>
</ObjectField>
))}
</div>
<Button type="dashed" icon={<PlusOutlined />} onClick={() => field.push({})}>
{t('Add field')}
</Button>
</>
)}
</ArrayField>
</Collapse.Panel>
{/* Filter */}
<Collapse.Panel
key="filter"
header={t('Filter')}
style={{ background: token.colorBgContainer, marginBottom: 8 }}
>
<Field
name="filter"
decorator={[FormItemLite, { style: { overflow: 'auto' } }]}
component={[
function FilterGroupWrapper(props) {
const ctx = useFlowSettingsContext<any>();
return (
<FilterGroup
value={props.value}
onChange={(v) => {
console.log('---onChange v', v);
props.onChange(v);
}}
FilterItem={(p) => <VariableFilterItem {...p} model={ctx.model} rightAsVariable />}
/>
);
},
]}
<Cascader
showSearch
placeholder={t('Collection')}
options={collectionOptions}
onChange={onCollectionChange}
style={{ width: 222 }}
/>
</Collapse.Panel>
</Form.Item>
{/* Measures */}
<div style={{ fontWeight: 500, marginBottom: 8 }}>{appendColon(t('Measures'), lang)}</div>
<div style={{ marginBottom: 16 }}>
<Form.List name="measures">
{(fields, { add, remove }) => (
<>
<div style={{ overflow: 'auto' }}>
{fields.map((field, idx) => (
<Space align="center" size={[8, 4]} wrap={false} style={{ marginBottom: 8 }} key={field.key}>
<Form.Item name={[field.name, 'field']} style={{ marginBottom: 0 }}>
<Cascader
style={{ minWidth: 114 }}
placeholder={t('Select Field')}
fieldNames={{ label: 'title', value: 'name', children: 'children' }}
options={fieldOptions}
/>
</Form.Item>
<Form.Item name={[field.name, 'aggregation']} style={{ marginBottom: 0 }}>
<Select
style={{ minWidth: 74 }}
placeholder={t('Aggregation')}
options={[
{ label: t('Sum'), value: 'sum' },
{ label: t('Count'), value: 'count' },
{ label: t('Avg'), value: 'avg' },
{ label: t('Max'), value: 'max' },
{ label: t('Min'), value: 'min' },
]}
/>
</Form.Item>
<Form.Item name={[field.name, 'alias']} style={{ marginBottom: 0 }}>
<Input placeholder={t('Alias')} />
</Form.Item>
<Form.Item name={[field.name, 'distinct']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox style={{ minWidth: 60 }}>{t('Distinct')}</Checkbox>
</Form.Item>
<Button size="small" type="text" onClick={() => remove(field.name)} icon={<DeleteOutlined />} />
{fields.length > 1 && (
<>
<Button
size="small"
type="text"
disabled={idx === 0}
onClick={() => moveItem('measures', idx, -1)}
icon={<ArrowUpOutlined />}
/>
<Button
size="small"
type="text"
disabled={idx === fields.length - 1}
onClick={() => moveItem('measures', idx, 1)}
icon={<ArrowDownOutlined />}
/>
</>
)}
</Space>
))}
</div>
<Button
type="link"
icon={<PlusOutlined />}
onClick={() => add({})}
style={{ marginTop: -8, padding: 0 }}
>
{t('Add field')}
</Button>
</>
)}
</Form.List>
</div>
{/* Dimensions 标题 */}
<div style={{ fontWeight: 500, marginBottom: 8 }}>{appendColon(t('Dimensions'), lang)}</div>
<div style={{ marginBottom: 16 }}>
<Form.List name="dimensions">
{(fields, { add, remove }) => (
<>
<div style={{ overflow: 'auto' }}>
{fields.map((field, idx) => {
const fieldName = field.name;
const dimField = form.getFieldValue(['dimensions', fieldName, 'field']);
const fmtOptions = getFormatterOptionsByField(dm, collectionPath, dimField);
return (
<Space align="center" size={[8, 4]} wrap={false} style={{ marginBottom: 8 }} key={field.key}>
<Form.Item name={[field.name, 'field']} style={{ marginBottom: 0 }}>
<Cascader
style={{ minWidth: 114 }}
placeholder={t('Select Field')}
fieldNames={{ label: 'title', value: 'name', children: 'children' }}
options={fieldOptions}
/>
</Form.Item>
{/* 仅当 fmtOptions 有值时展示 Format 选择项 */}
{fmtOptions?.length ? (
<Form.Item name={[field.name, 'format']} style={{ marginBottom: 0 }}>
<Select
placeholder={t('Format')}
popupMatchSelectWidth={false}
options={fmtOptions.map((o: any) => ({ label: o.label, value: o.value }))}
/>
</Form.Item>
) : null}
<Form.Item name={[field.name, 'alias']} style={{ marginBottom: 0 }}>
<Input placeholder={t('Alias')} />
</Form.Item>
<Button size="small" type="text" onClick={() => remove(field.name)} icon={<DeleteOutlined />} />
<Button
size="small"
type="text"
disabled={idx === 0}
onClick={() => moveItem('dimensions', idx, -1)}
icon={<ArrowUpOutlined />}
/>
<Button
size="small"
type="text"
disabled={idx === fields.length - 1}
onClick={() => moveItem('dimensions', idx, 1)}
icon={<ArrowDownOutlined />}
/>
</Space>
);
})}
</div>
<Button
type="link"
icon={<PlusOutlined />}
onClick={() => add({})}
style={{ marginTop: -8, padding: 0 }}
>
{t('Add field')}
</Button>
</>
)}
</Form.List>
</div>
{/* Filter 标题 */}
<div style={{ fontWeight: 500, marginBottom: 8 }}>{appendColon(t('Filter'), lang)}</div>
<div style={{ marginBottom: 16 }}>
<Form.Item name="filter" style={{ overflow: 'auto' }}>
<FilterGroup
value={form.getFieldValue('filter')}
onChange={(v) => {
form.setFieldsValue({ filter: v });
onChange?.(form.getFieldsValue(true));
}}
FilterItem={(p) => {
return <VariableFilterItem {...p} model={ctx.model} rightAsVariable />;
}}
/>
</Form.Item>
</div>
{/* Sort */}
<Collapse.Panel key="sort" header={t('Sort')} style={{ background: token.colorBgContainer, marginBottom: 4 }}>
<ArrayField name="orders" reactions={[useOrderReactionHook]}>
{(field) => (
<div style={{ fontWeight: 500, marginBottom: 4 }}>{appendColon(t('Sort'), lang)}</div>
<div style={{ marginBottom: 16 }}>
<Form.List name="orders">
{(fields, { add, remove }) => (
<>
<div style={{ overflow: 'auto' }}>
{field.value?.map((item, index) => (
<ObjectField name={index} key={index}>
<Space wrap align="center" size={[8, 4]} style={{ marginBottom: 8 }}>
{/* <Field
name="field"
required
decorator={[FormItemLite]}
reactions={[useOrderOptions]}
component={[CascaderAdapter, { placeholder: t('Select Field'), style: { minWidth: 100 } }]}
/> */}
<Field
name="field"
required
decorator={[FormItemLite]}
component={[
CascaderAdapter,
{
placeholder: t('Select Field'),
fieldNames: { label: 'title', value: 'name', children: 'children' },
dataSource: fieldOptions,
style: { minWidth: 100 },
},
{fields.map((field, idx) => (
<Space wrap align="center" size={[8, 4]} style={{ marginBottom: 8 }} key={field.key}>
<Form.Item name={[field.name, 'field']} style={{ marginBottom: 0 }}>
<Cascader
placeholder={t('Select Field')}
fieldNames={{ label: 'title', value: 'name', children: 'children' }}
options={fieldOptions}
style={{ minWidth: 114 }}
/>
</Form.Item>
<Form.Item name={[field.name, 'order']} style={{ marginBottom: 0 }}>
<Select
defaultValue="ASC"
style={{ minWidth: 100 }}
options={[
{ label: 'ASC', value: 'ASC' },
{ label: 'DESC', value: 'DESC' },
]}
/>
<Field
name="order"
decorator={[FormItemLite]}
component={[
SelectAdapter,
{
defaultValue: 'ASC',
dataSource: [
{ label: 'ASC', value: 'ASC' },
{ label: 'DESC', value: 'DESC' },
],
style: { minWidth: 100 },
},
</Form.Item>
<Form.Item name={[field.name, 'nulls']} style={{ marginBottom: 0 }}>
<Select
defaultValue="default"
style={{ minWidth: 110 }}
options={[
{ label: t('Default'), value: 'default' },
{ label: t('NULLS first'), value: 'first' },
{ label: t('NULLS last'), value: 'last' },
]}
/>
<Field
name="nulls"
decorator={[FormItemLite]}
component={[
SelectAdapter,
{
defaultValue: 'default',
dataSource: [
{ label: t('Default'), value: 'default' },
{ label: t('NULLS first'), value: 'first' },
{ label: t('NULLS last'), value: 'last' },
],
style: { minWidth: 110 },
},
]}
/>
<Button
size="small"
type="text"
onClick={() => field.remove(index)}
icon={<DeleteOutlined />}
/>
<Button
size="small"
type="text"
disabled={index === 0}
onClick={() => field.moveUp(index)}
icon={<ArrowUpOutlined />}
/>
<Button
size="small"
type="text"
disabled={index === (field.value?.length ?? 0) - 1}
onClick={() => field.moveDown(index)}
icon={<ArrowDownOutlined />}
/>
</Space>
</ObjectField>
</Form.Item>
<Button size="small" type="text" onClick={() => remove(field.name)} icon={<DeleteOutlined />} />
<Button
size="small"
type="text"
disabled={idx === 0}
onClick={() => moveItem('orders', idx, -1)}
icon={<ArrowUpOutlined />}
/>
<Button
size="small"
type="text"
disabled={idx === fields.length - 1}
onClick={() => moveItem('orders', idx, 1)}
icon={<ArrowDownOutlined />}
/>
</Space>
))}
</div>
<Button type="dashed" icon={<PlusOutlined />} onClick={() => field.push({})}>
<Button
type="link"
icon={<PlusOutlined />}
onClick={() => add({})}
style={{ marginTop: -8, padding: 0 }}
>
{t('Add field')}
</Button>
</>
)}
</ArrayField>
</Collapse.Panel>
</Collapse>
</Form.List>
</div>
<div style={{ margin: '4px 0' }} />
{/* Limit */}
<Form.Item name="limit" label={<span style={{ fontWeight: 500 }}>{appendColon(t('Limit'), lang)}</span>}>
<InputNumber min={0} style={{ width: 120 }} />
</Form.Item>
{/* Limit */}
<Field
name="limit"
title={t('Limit')}
decorator={[FormItemLite]}
component={[InputNumber, { defaultValue: 2000, min: 1, style: { width: 100, marginBottom: 8 } }]}
/>
{/* Offset */}
<Field
name="offset"
title={t('Offset')}
decorator={[FormItemLite]}
component={[InputNumber, { defaultValue: 0, min: 0, style: { width: 100, marginBottom: 8 } }]}
/>
</>
{/* Offset */}
<Form.Item name="offset" label={<span style={{ fontWeight: 500 }}>{appendColon(t('Offset'), lang)}</span>}>
<InputNumber min={0} style={{ width: 120 }} />
</Form.Item>
</Form>
</div>
);
});
@@ -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<ChartBlockModel>();
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')}
</Button>
<Button type="link" aria-expanded={showResult} onClick={() => setShowResult((v) => !v)}>
{t('Data result')}
{showResult ? t('Hide data') : t('View data')}
{showResult ? <DownOutlined /> : <RightOutlined />}
</Button>
</Space>
</div>
{/* 下面保持不变 */}
{showResult ? (
<div style={{ marginTop: 8 }}>
<ResultPanel />
</div>
) : mode === 'builder' ? (
<QueryBuilder />
<QueryBuilder ref={qbRef} initialValues={form?.values?.query} onChange={handleBuilderChange} />
) : (
<Field name="sql" component={[SQLEditor]} />
)}
@@ -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 ? (
<Tabs
size="small"
type="card"
items={[
{
key: 'table',
label: <span style={{ fontSize: 12 }}>{t('Table')}</span>,
icon: <TableOutlined />,
children: <TableResult data={data || []} />,
},
{
key: 'json',
label: <span style={{ fontSize: 12 }}>{t('JSON')}</span>,
icon: <CodeOutlined />,
children: <JSONResult data={data || []} />,
},
]}
/>
) : (
<Alert showIcon message={t('Query Error')} description={error} type="error" />
);
};
const TableResult: React.FC<{
data: any[];
}> = ({ data }) => {
@@ -46,34 +76,3 @@ const JSONResult: React.FC<{
</Paragraph>
);
};
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 ? (
<Tabs
size="small"
type="card"
items={[
{
key: 'table',
label: t('Table'),
icon: <TableOutlined />,
children: <TableResult data={data || []} />,
},
{
key: 'json',
label: t('JSON'),
icon: <CodeOutlined />,
children: <JSONResult data={data || []} />,
},
]}
/>
) : (
<Alert showIcon message={t('Query error')} description={error} type="error" />
);
});
@@ -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,
};
};
@@ -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<TData = any> extends BaseRecordResource<TData> {
resourceName = 'charts';
@@ -41,7 +27,7 @@ export class ChartResource<TData = any> extends BaseRecordResource<TData> {
// 整体数据查询参数,内部 QueryBuilder 调用
setQueryParams(query: Record<string, any>, 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<TData = any> extends BaseRecordResource<TData> {
return this;
}
validateQuery(query: Record<string, any>): { 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<string, any>) {
const [dataSource, collection] = query.collectionPath || [];
@@ -176,6 +146,7 @@ export class ChartResource<TData = any> extends BaseRecordResource<TData> {
// debounce 刷新数据
async refresh() {
console.log('---ChartResource refresh');
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
@@ -117,3 +117,18 @@ export function sleep(ms: number): Promise<void> {
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}`;
}
@@ -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;
}
}
@@ -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",
@@ -21,7 +21,8 @@
"Query": "查询",
"Data": "数据",
"Run query": "运行查询",
"Data result": "数据结果",
"View data": "查看数据",
"Hide data": "收起数据",
"Measures": "度量",
"Dimensions": "维度",
"Filter": "过滤",