mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-21 05:44:51 +08:00
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:
+6
-5
@@ -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;
|
||||
|
||||
+162
-13
@@ -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 },
|
||||
};
|
||||
|
||||
// 纯函数:按图表类型规范化(补默认、删无关字段)
|
||||
|
||||
+66
-33
@@ -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
-35
@@ -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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+165
@@ -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: '' };
|
||||
}
|
||||
+293
-394
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
+46
-14
@@ -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]} />
|
||||
)}
|
||||
|
||||
+31
-32
@@ -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" />
|
||||
);
|
||||
});
|
||||
|
||||
-280
@@ -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,
|
||||
};
|
||||
};
|
||||
+3
-32
@@ -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": "过滤",
|
||||
|
||||
Reference in New Issue
Block a user