mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-24 16:02:20 +08:00
feat: add queryBuild hooks
This commit is contained in:
+61
@@ -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<typeof Form.Item>;
|
||||
|
||||
const statusMap: Record<string, 'success' | 'warning' | 'error' | 'validating' | undefined> = {
|
||||
success: 'success',
|
||||
warning: 'warning',
|
||||
error: 'error',
|
||||
pending: 'validating',
|
||||
};
|
||||
|
||||
export const FormItemLite: React.FC<FormItemLiteProps> = ({ 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 (
|
||||
<Form.Item
|
||||
{...rest}
|
||||
// 关键:减少底部间距,便于与行内 Icon 按钮垂直对齐
|
||||
style={{ marginBottom: 0, ...(rest as any).style }}
|
||||
label={label}
|
||||
required={required}
|
||||
validateStatus={validateStatus}
|
||||
help={help}
|
||||
extra={extra}
|
||||
>
|
||||
{children}
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormItemLite;
|
||||
+369
-86
@@ -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<any> = ({ dataSource, onChange, onValueChange, ...rest }) => {
|
||||
return (
|
||||
<SchemaComponent
|
||||
schema={querySchema}
|
||||
scope={{
|
||||
t,
|
||||
formCollapse,
|
||||
fieldOptions,
|
||||
filterOptions,
|
||||
useOrderOptions,
|
||||
collectionOptions,
|
||||
useFormatterOptions,
|
||||
onCollectionChange,
|
||||
collection,
|
||||
useOrderReaction: useOrderReactionHook,
|
||||
collapsePanelBg: token.colorBgContainer,
|
||||
}}
|
||||
components={{
|
||||
ArrayItems,
|
||||
FormCollapse,
|
||||
FormItem,
|
||||
Space,
|
||||
Cascader,
|
||||
Select,
|
||||
Input,
|
||||
InputNumber,
|
||||
Checkbox,
|
||||
Switch,
|
||||
Filter,
|
||||
FilterDynamicComponent,
|
||||
<Cascader
|
||||
{...rest}
|
||||
options={dataSource}
|
||||
onChange={(v) => {
|
||||
onChange?.(v); // 先写入 Formily 表单值
|
||||
onValueChange?.(v); // 再触发业务侧副作用
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
import FormItemLite from './FormItemLite';
|
||||
|
||||
export const QueryBuilder: React.FC = () => {
|
||||
const {
|
||||
t,
|
||||
token,
|
||||
collectionOptions,
|
||||
fieldOptions,
|
||||
filterOptions,
|
||||
formCollapse, // 暂时保留(未使用),后续步骤再考虑移除
|
||||
useFormatterOptions,
|
||||
useOrderOptions,
|
||||
useOrderReactionHook,
|
||||
onCollectionChange,
|
||||
} = useQueryBuilderLogic();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 设置:数据源/集合 */}
|
||||
<ObjectField name="settings">
|
||||
<Field
|
||||
name="collection"
|
||||
title={t('Collection')}
|
||||
decorator={[FormItemLite]}
|
||||
component={[
|
||||
CascaderAdapter,
|
||||
{
|
||||
showSearch: true,
|
||||
placeholder: t('Collection'),
|
||||
dataSource: collectionOptions,
|
||||
onValueChange: onCollectionChange,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ObjectField>
|
||||
|
||||
{/* 查询构建 */}
|
||||
<ObjectField name="query">
|
||||
<ObjectField name="builder">
|
||||
<Collapse
|
||||
size="small"
|
||||
bordered={false}
|
||||
defaultActiveKey={['measures']}
|
||||
style={{ border: 'none', boxShadow: 'none' }}
|
||||
>
|
||||
{/* Measures */}
|
||||
<Collapse.Panel key="measures" header={t('Measures')} style={{ background: token.colorBgContainer }}>
|
||||
<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]}>
|
||||
<Field
|
||||
name="field"
|
||||
required
|
||||
decorator={[FormItemLite]}
|
||||
component={[
|
||||
CascaderAdapter,
|
||||
{
|
||||
placeholder: t('Select Field'),
|
||||
fieldNames: { label: 'title', value: 'name', children: 'children' },
|
||||
dataSource: fieldOptions,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Field
|
||||
name="aggregation"
|
||||
decorator={[FormItemLite]}
|
||||
component={[
|
||||
SelectAdapter,
|
||||
{
|
||||
placeholder: t('Aggregation'),
|
||||
style: { minWidth: 80 },
|
||||
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: { minWidth: 100 } }]}
|
||||
/>
|
||||
<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" onClick={() => field.push({})}>
|
||||
{t('Add field')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</ArrayField>
|
||||
</Collapse.Panel>
|
||||
|
||||
{/* Dimensions */}
|
||||
<Collapse.Panel key="dimensions" header={t('Dimensions')} style={{ background: token.colorBgContainer }}>
|
||||
<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]}>
|
||||
<Field
|
||||
name="field"
|
||||
required
|
||||
decorator={[FormItemLite]}
|
||||
component={[
|
||||
CascaderAdapter,
|
||||
{
|
||||
placeholder: t('Select Field'),
|
||||
fieldNames: { label: 'title', value: 'name', children: 'children' },
|
||||
dataSource: fieldOptions,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<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: { minWidth: 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" onClick={() => field.push({})}>
|
||||
{t('Add field')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</ArrayField>
|
||||
</Collapse.Panel>
|
||||
|
||||
{/* Filter */}
|
||||
<Collapse.Panel key="filter" header={t('Filter')} style={{ background: token.colorBgContainer }}>
|
||||
<Field
|
||||
name="filter"
|
||||
decorator={[FormItemLite, { style: { overflow: 'auto' } }]}
|
||||
component={[Filter, { dynamicComponent: 'FilterDynamicComponent', dataSource: filterOptions }]}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
|
||||
{/* Sort */}
|
||||
<Collapse.Panel
|
||||
key="sort"
|
||||
header={t('Sort')}
|
||||
style={{ background: token.colorBgContainer, border: 'none' }}
|
||||
>
|
||||
<ArrayField name="orders" reactions={[useOrderReactionHook]}>
|
||||
{(field) => (
|
||||
<>
|
||||
<div style={{ overflow: 'auto' }}>
|
||||
{field.value?.map((item, index) => (
|
||||
<ObjectField name={index} key={index}>
|
||||
<Space wrap align="center" size={[8, 4]}>
|
||||
<Field
|
||||
name="field"
|
||||
required
|
||||
decorator={[FormItemLite]}
|
||||
reactions={[useOrderOptions]}
|
||||
component={[CascaderAdapter, { placeholder: t('Select Field') }]}
|
||||
/>
|
||||
<Field
|
||||
name="order"
|
||||
decorator={[FormItemLite]}
|
||||
component={[
|
||||
SelectAdapter,
|
||||
{
|
||||
defaultValue: 'ASC',
|
||||
dataSource: [
|
||||
{ label: 'ASC', value: 'ASC' },
|
||||
{ label: 'DESC', value: 'DESC' },
|
||||
],
|
||||
style: { minWidth: 100 },
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
<Button type="dashed" onClick={() => field.push({})}>
|
||||
{t('Add field')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</ArrayField>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</ObjectField>
|
||||
|
||||
{/* Limit / Offset */}
|
||||
<Field
|
||||
name="limit"
|
||||
title={t('Limit')}
|
||||
decorator={[FormItemLite]}
|
||||
component={[InputNumber, { defaultValue: 2000, min: 1, style: { width: 100 } }]}
|
||||
/>
|
||||
<Field
|
||||
name="offset"
|
||||
title={t('Offset')}
|
||||
decorator={[FormItemLite]}
|
||||
component={[InputNumber, { defaultValue: 0, min: 0, style: { width: 100 } }]}
|
||||
/>
|
||||
</ObjectField>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+290
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -7,6 +7,8 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { lang } from '../locale';
|
||||
|
||||
export function convertDatasetFormats(data: Record<string, any>[]) {
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return {
|
||||
@@ -40,3 +42,72 @@ export function convertDatasetFormats(data: Record<string, any>[]) {
|
||||
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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"Enable cache": "Enable cache",
|
||||
"TTL (second)": "TTL (second)",
|
||||
"Field": "Field",
|
||||
"Select Field": "Select Field",
|
||||
"Aggregation": "Aggregation",
|
||||
"Alias": "Alias",
|
||||
"Format": "Format",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"Enable cache": "启用缓存",
|
||||
"TTL (second)": "缓存时间 (秒)",
|
||||
"Field": "字段",
|
||||
"Select Field": "选择字段",
|
||||
"Aggregation": "聚合",
|
||||
"Alias": "别名",
|
||||
"Format": "格式",
|
||||
|
||||
Reference in New Issue
Block a user