From ab717450baac1663b11e3b40ec34b4eccbb5bb43 Mon Sep 17 00:00:00 2001 From: Ziqiang Date: Fri, 19 Sep 2025 17:37:19 +0800 Subject: [PATCH] feat: add queryBuild hooks --- .../src/client/flow/models/FormItemLite.tsx | 61 +++ .../src/client/flow/models/QueryBuilder.tsx | 455 ++++++++++++++---- .../client/flow/models/queryBuilder.logic.ts | 290 +++++++++++ .../src/client/flow/utils.ts | 71 +++ .../src/locale/en-US.json | 1 + .../src/locale/zh-CN.json | 1 + 6 files changed, 793 insertions(+), 86 deletions(-) create mode 100644 packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/FormItemLite.tsx create mode 100644 packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/queryBuilder.logic.ts diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/FormItemLite.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/FormItemLite.tsx new file mode 100644 index 00000000000..1792c0b8977 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/FormItemLite.tsx @@ -0,0 +1,61 @@ +/** + * 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 React from 'react'; +import { Form } from 'antd'; +import { useField } from '@formily/react'; + +export type FormItemLiteProps = React.ComponentProps; + +const statusMap: Record = { + success: 'success', + warning: 'warning', + error: 'error', + pending: 'validating', +}; + +export const FormItemLite: React.FC = ({ children, ...rest }) => { + const field: any = useField(); + + // 可见性控制 + if (field?.visible === false || field?.display === 'none' || field?.display === 'hidden') { + return null; + } + + const label = rest.label ?? field?.title; + const required = rest.required ?? field?.required; + + const validateStatus = rest.validateStatus ?? (field?.validateStatus ? statusMap[field.validateStatus] : undefined); + + const help = + rest.help ?? + field?.feedbackText ?? + (Array.isArray(field?.selfErrors) && field.selfErrors[0]) ?? + (Array.isArray(field?.selfWarnings) && field.selfWarnings[0]) ?? + undefined; + + const extra = rest.extra ?? field?.description; + + return ( + + {children} + + ); +}; + +export default FormItemLite; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/QueryBuilder.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/QueryBuilder.tsx index cc797c94b96..ad783a4ea75 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/QueryBuilder.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/QueryBuilder.tsx @@ -7,95 +7,378 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import React, { useMemo } from 'react'; -import { ArrayItems, FormCollapse, FormItem, Space, Switch, Cascader, Select, Input, Checkbox } from '@formily/antd-v5'; -import { Schema, useForm } from '@formily/react'; -import { SchemaComponent, InputNumber, Filter } from '@nocobase/client'; -import { theme } from 'antd'; -import { useT } from '../../locale'; -import { querySchema } from '../../configure/schemas/configure'; -import { - useCollectionFieldsOptions, - useCollectionFilterOptions, - useCollectionOptions, - useFieldsWithAssociation, - useFormatters, - useOrderFieldsOptions, - useOrderReaction, -} from '../../hooks'; -import { FilterDynamicComponent } from '../../configure/FilterDynamicComponent'; - -export const QueryBuilder: React.FC = () => { - const t = useT(); - const form = useForm(); - const { token } = theme.useToken(); - - // 获取当前选中的数据源/集合(来自 schema: settings.collection 的 Cascader) - const collectionPath: string[] | undefined = form?.values?.settings?.collection; - const [dataSource, collection] = collectionPath || []; - - // 计算 schema scope 所需的数据与方法 - const fields = useFieldsWithAssociation(dataSource, collection); - const fieldOptionsRaw = useCollectionFieldsOptions(dataSource, collection, 1); - const fieldOptions = useMemo(() => Schema.compile(fieldOptionsRaw, { t }), [fieldOptionsRaw, t]); - const filterOptions = useCollectionFilterOptions(dataSource, collection); - const collectionOptions = useCollectionOptions(); - const formCollapse = FormCollapse.createFormCollapse(['measures', 'dimensions', 'filter', 'sort']); - - const useFormatterOptions = useFormatters(fields); - const useOrderOptions = useOrderFieldsOptions(fieldOptions, fields); - const useOrderReactionHook = useOrderReaction(fieldOptions, fields); - - // 切换集合时,清空 builder 查询配置,避免跨集合污染 - const onCollectionChange = (value: string[]) => { - form.setValues({ - ...form.values, - settings: { - ...(form.values?.settings || {}), - collection: value, - }, - query: { - ...(form.values?.query || {}), - builder: { - measures: [], - dimensions: [], - filter: undefined, - orders: [], - }, - }, - }); - }; +import React from 'react'; +import { Collapse } from 'antd'; +import { Field, ObjectField, ArrayField } from '@formily/react'; +import { InputNumber, Filter } from '@nocobase/client'; +import { useQueryBuilderLogic } from './queryBuilder.logic'; +import { Space, Cascader, Select, Input, Checkbox, Button } from 'antd'; +import { DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'; +// 极简适配器:让 antd 原生组件符合 Formily 的 value/onChange 协议,并对 dataSource 做 options 映射 +// 顶部适配器定义区域 +const CascaderAdapter: React.FC = ({ dataSource, onChange, onValueChange, ...rest }) => { return ( - { + onChange?.(v); // 先写入 Formily 表单值 + onValueChange?.(v); // 再触发业务侧副作用 }} /> ); }; + +const SelectAdapter: React.FC = ({ dataSource, onChange, onValueChange, ...rest }) => { + return ( + { + const v = e?.target?.value; + onChange?.(v); + onValueChange?.(v); + }} + /> + ); +}; + +const CheckboxAdapter: React.FC = ({ value, onChange, onValueChange, content, children, ...rest }) => { + return ( + { + const v = e?.target?.checked; + onChange?.(v); + onValueChange?.(v); + }} + > + {children ?? content} + + ); +}; +import FormItemLite from './FormItemLite'; + +export const QueryBuilder: React.FC = () => { + const { + t, + token, + collectionOptions, + fieldOptions, + filterOptions, + formCollapse, // 暂时保留(未使用),后续步骤再考虑移除 + useFormatterOptions, + useOrderOptions, + useOrderReactionHook, + onCollectionChange, + } = useQueryBuilderLogic(); + + return ( + <> + {/* 设置:数据源/集合 */} + + + + + {/* 查询构建 */} + + + + {/* Measures */} + + + {(field) => ( + <> +
+ {field.value?.map((item, index) => ( + + + + + + +
+ + + )} +
+
+ + {/* Dimensions */} + + + {(field) => ( + <> +
+ {field.value?.map((item, index) => ( + + + + { + // 仅当存在可选项时显示 + // @ts-ignore + f.visible = !!(f.dataSource && f.dataSource.length); + }, + ]} + component={[SelectAdapter, { placeholder: t('Format'), style: { maxWidth: 120 } }]} + /> + +
+ + + )} +
+
+ + {/* Filter */} + + + + + {/* Sort */} + + + {(field) => ( + <> +
+ {field.value?.map((item, index) => ( + + + + + +
+ + + )} +
+
+
+
+ + {/* Limit / Offset */} + + +
+ + ); +}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/queryBuilder.logic.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/queryBuilder.logic.ts new file mode 100644 index 00000000000..a52ced4a10a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/models/queryBuilder.logic.ts @@ -0,0 +1,290 @@ +/** + * 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 { FormCollapse } from '@formily/antd-v5'; +import { useForm } from '@formily/react'; +import { theme } from 'antd'; +import { useT } from '../../locale'; +import { DEFAULT_DATA_SOURCE_KEY, useDataSourceManager } from '@nocobase/client'; +import { formatters } from '../utils'; + +export const useQueryBuilderLogic = () => { + const t = useT(); + const form = useForm(); + const { token } = theme.useToken(); + const dm = useDataSourceManager(); + + // 读取当前选择的数据源与集合 + const collectionPath: string[] | undefined = form?.values?.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: displayName, + children: (collections || []).map((c) => ({ + value: c.name, + label: c.title ?? c.name, + })), + })); + }, [dm]); + + // 工具:拼接级联字段别名 + 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: 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, + 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, form?.values?.settings?.collection]); + + // 过滤器字段与操作符(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: 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; + } + + // 嵌套关联 + 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, form?.values?.settings?.collection]); + + // 根据字段别名推断 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); + } + }; + + // 切换集合:清空 builder 配置 + const onCollectionChange = (value: string[]) => { + form.setValues({ + ...form.values, + settings: { + ...(form.values?.settings || {}), + collection: value, + }, + query: { + ...(form.values?.query || {}), + builder: { + measures: [], + dimensions: [], + filter: undefined, + orders: [], + }, + }, + }); + }; + + return { + t, + token, + // 暴露给 UI 的数据与 reactions + collectionOptions, + fieldOptions, + filterOptions, + formCollapse: FormCollapse.createFormCollapse(['measures', 'dimensions', 'filter', 'sort']), + useFormatterOptions, + useOrderOptions, + useOrderReactionHook, + onCollectionChange, + }; +}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/utils.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/utils.ts index 0df50197cee..a8b197d16ad 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/utils.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/flow/utils.ts @@ -7,6 +7,8 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +import { lang } from '../locale'; + export function convertDatasetFormats(data: Record[]) { if (!Array.isArray(data) || data.length === 0) { return { @@ -40,3 +42,72 @@ export function convertDatasetFormats(data: Record[]) { columns, }; } + +export const formatters = { + datetime: [ + { + label: lang('YYYY'), + value: 'YYYY', + }, + { + label: lang('MM'), + value: 'MM', + }, + { + label: lang('DD'), + value: 'DD', + }, + { + label: lang('YYYY-MM'), + value: 'YYYY-MM', + }, + { + label: lang('YYYY-MM-DD'), + value: 'YYYY-MM-DD', + }, + { + label: lang('YYYY-MM-DD hh:mm'), + value: 'YYYY-MM-DD hh:mm', + }, + { + label: lang('YYYY-MM-DD hh:mm:ss'), + value: 'YYYY-MM-DD hh:mm:ss', + }, + ], + date: [ + { + label: lang('YYYY'), + value: 'YYYY', + }, + { + label: lang('MM'), + value: 'MM', + }, + { + label: lang('DD'), + value: 'DD', + }, + { + label: lang('YYYY-MM'), + value: 'YYYY-MM', + }, + { + label: lang('YYYY-MM-DD'), + value: 'YYYY-MM-DD', + }, + ], + time: [ + { + label: lang('hh:mm:ss'), + value: 'hh:mm:ss', + }, + { + label: lang('hh:mm'), + value: 'hh:mm', + }, + { + label: lang('hh'), + value: 'hh', + }, + ], +}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-data-visualization/src/locale/en-US.json index 79f85a8f45c..ea22f8b8a7a 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/locale/en-US.json +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/locale/en-US.json @@ -27,6 +27,7 @@ "Enable cache": "Enable cache", "TTL (second)": "TTL (second)", "Field": "Field", + "Select Field": "Select Field", "Aggregation": "Aggregation", "Alias": "Alias", "Format": "Format", diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-data-visualization/src/locale/zh-CN.json index 72cd835c264..599a354c728 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/locale/zh-CN.json @@ -27,6 +27,7 @@ "Enable cache": "启用缓存", "TTL (second)": "缓存时间 (秒)", "Field": "字段", + "Select Field": "选择字段", "Aggregation": "聚合", "Alias": "别名", "Format": "格式",