diff --git a/webapp/packages/supersonic-fe/config/routes.ts b/webapp/packages/supersonic-fe/config/routes.ts index efeb253da..e867876ff 100644 --- a/webapp/packages/supersonic-fe/config/routes.ts +++ b/webapp/packages/supersonic-fe/config/routes.ts @@ -66,6 +66,33 @@ const ROUTES = [ }, ], }, + + { + path: '/tag', + name: 'tag', + component: './SemanticModel/Insights', + envEnableList: [ENV_KEY.SEMANTIC], + routes: [ + { + path: '/tag', + redirect: '/tag/market', + }, + { + path: '/tag/market', + component: './SemanticModel/Insights/Market', + hideInMenu: true, + envEnableList: [ENV_KEY.SEMANTIC], + }, + { + path: '/tag/detail/:tagId', + name: 'tagDetail', + hideInMenu: true, + component: './SemanticModel/Insights/Detail', + envEnableList: [ENV_KEY.SEMANTIC], + }, + ], + }, + { path: '/plugin', name: 'plugin', diff --git a/webapp/packages/supersonic-fe/package.json b/webapp/packages/supersonic-fe/package.json index df1cc8181..dfa6ecdcb 100644 --- a/webapp/packages/supersonic-fe/package.json +++ b/webapp/packages/supersonic-fe/package.json @@ -100,6 +100,7 @@ "react-syntax-highlighter": "^15.4.3", "sql-formatter": "^2.3.3", "supersonic-chat-sdk": "0.0.0", + "supersonic-insights-flow-components": "^1.4.6", "umi": "3.5", "umi-request": "^1.4.0" }, diff --git a/webapp/packages/supersonic-fe/src/components/BatchCtrlDropDownButton/index.tsx b/webapp/packages/supersonic-fe/src/components/BatchCtrlDropDownButton/index.tsx index 7a6a76bda..dab146284 100644 --- a/webapp/packages/supersonic-fe/src/components/BatchCtrlDropDownButton/index.tsx +++ b/webapp/packages/supersonic-fe/src/components/BatchCtrlDropDownButton/index.tsx @@ -5,6 +5,7 @@ import { StopOutlined, CloudDownloadOutlined, DeleteOutlined, + ExportOutlined, } from '@ant-design/icons'; export type BatchCtrlDropDownButtonProps = { @@ -14,6 +15,7 @@ export type BatchCtrlDropDownButtonProps = { downloadLoading?: boolean; disabledList?: string[]; hiddenList?: string[]; + extenderEnable?: boolean; }; const { RangePicker } = DatePicker; @@ -24,10 +26,21 @@ const BatchCtrlDropDownButton: FC = ({ downloadLoading, disabledList = [], hiddenList = [], + extenderEnable = false, }) => { const [popoverOpenState, setPopoverOpenState] = useState(false); const [pickerType, setPickerType] = useState('day'); const dateRangeRef = useRef([]); + + const exportTagButton = { + key: 'exportTagButton', + label: '导出为标签', + icon: , + disabled: disabledList?.includes('exportTagButton'), + }; + + const extenderList: any[] = extenderEnable ? [exportTagButton] : []; + const dropdownButtonItems: any[] = [ { key: 'batchStart', @@ -55,6 +68,11 @@ const BatchCtrlDropDownButton: FC = ({ icon: , disabled: disabledList?.includes('batchDownload'), }, + { + key: 'divider', + type: 'divider', + }, + ...extenderList, { key: 'batchDeleteDivider', type: 'divider', diff --git a/webapp/packages/supersonic-fe/src/locales/zh-CN/menu.ts b/webapp/packages/supersonic-fe/src/locales/zh-CN/menu.ts index 7e6b5a776..9e216dfa3 100644 --- a/webapp/packages/supersonic-fe/src/locales/zh-CN/menu.ts +++ b/webapp/packages/supersonic-fe/src/locales/zh-CN/menu.ts @@ -10,6 +10,8 @@ export default { 'menu.semanticModel': '语义建模', 'menu.metric': '指标市场', 'menu.metric.metricDetail': '指标详情页', + 'menu.tag': '标签市场', + 'menu.tag.tagDetail': '标签详情页', 'menu.database': '数据库管理', 'menu.chatSetting': '问答设置', 'menu.plugin': '插件市场', diff --git a/webapp/packages/supersonic-fe/src/pages/SemanticModel/Insights/Detail.tsx b/webapp/packages/supersonic-fe/src/pages/SemanticModel/Insights/Detail.tsx new file mode 100644 index 000000000..05ad5e1f5 --- /dev/null +++ b/webapp/packages/supersonic-fe/src/pages/SemanticModel/Insights/Detail.tsx @@ -0,0 +1,151 @@ +import { message, Tabs, Button, Space } from 'antd'; +import React, { useState, useEffect } from 'react'; +import { getTagData } from '../service'; +import { connect, useParams, history } from 'umi'; +import type { StateType } from '../model'; +import styles from './style.less'; +import { ArrowLeftOutlined } from '@ant-design/icons'; +import TagTrendSection from './components/TagTrendSection'; +import { ISemantic } from '../data'; +import TagInfoSider from './components/TagInfoSider'; +import { getDimensionList, queryMetric } from '../service'; +import type { TabsProps } from 'antd'; + +type Props = Record; + +const TagDetail: React.FC = () => { + const params: any = useParams(); + const tagId = params.tagId; + const [tagData, setTagData] = useState(); + const [dimensionMap, setDimensionMap] = useState>({}); + + const [metricMap, setMetricMap] = useState>({}); + + const [relationDimensionOptions, setRelationDimensionOptions] = useState< + { value: string; label: string; modelId: number }[] + >([]); + + useEffect(() => { + queryTagData(tagId); + }, [tagId]); + + const queryTagData = async (tagId: number) => { + const { code, data, msg } = await getTagData(tagId); + if (code === 200) { + queryDimensionList(data.modelId); + queryMetricList(data.modelId); + setTagData({ ...data }); + return; + } + message.error(msg); + }; + + const tabItems: TabsProps['items'] = [ + { + key: 'trend', + label: '图表', + children: ( + // <> + + ), + }, + // { + // key: 'metricCaliberInput', + // label: '基础信息', + // children: <>, + // }, + // { + // key: 'metricDataRemark', + // label: '备注', + // children: <>, + // }, + ]; + + const queryDimensionList = async (modelId: number) => { + const { code, data, msg } = await getDimensionList({ modelId }); + if (code === 200 && Array.isArray(data?.list)) { + const { list } = data; + setDimensionMap( + list.reduce( + (infoMap: Record, item: ISemantic.IDimensionItem) => { + infoMap[`${item.id}`] = item; + return infoMap; + }, + {}, + ), + ); + } else { + message.error(msg); + } + }; + + const queryMetricList = async (modelId: number) => { + const { code, data, msg } = await queryMetric({ + modelId: modelId, + }); + const { list } = data || {}; + if (code === 200) { + setMetricMap( + list.reduce( + (infoMap: Record, item: ISemantic.IMetricItem) => { + infoMap[`${item.id}`] = item; + return infoMap; + }, + {}, + ), + ); + } else { + message.error(msg); + } + }; + + return ( + <> +
+
+
+ { + history.push('/tag/market'); + }} + > + + + 返回列表页 + + + ), + }} + size="large" + className={styles.tagDetailTab} + /> +
+
+ +
+
+
+ + ); +}; + +export default connect(({ domainManger }: { domainManger: StateType }) => ({ + domainManger, +}))(TagDetail); diff --git a/webapp/packages/supersonic-fe/src/pages/SemanticModel/Insights/Market.tsx b/webapp/packages/supersonic-fe/src/pages/SemanticModel/Insights/Market.tsx new file mode 100644 index 000000000..ef10ff3c1 --- /dev/null +++ b/webapp/packages/supersonic-fe/src/pages/SemanticModel/Insights/Market.tsx @@ -0,0 +1,375 @@ +import type { ActionType, ProColumns } from '@ant-design/pro-table'; +import ProTable from '@ant-design/pro-table'; +import { message, Space, Popconfirm } from 'antd'; +import React, { useRef, useState, useEffect } from 'react'; +import type { Dispatch } from 'umi'; +import { connect, useModel } from 'umi'; +import type { StateType } from '../model'; +import { SENSITIVE_LEVEL_ENUM } from '../constant'; +import { getTagList, deleteTag, batchUpdateTagStatus } from '../service'; +import TagFilter from './components/TagFilter'; +import TagInfoCreateForm from './components/TagInfoCreateForm'; +import { SemanticNodeType, StatusEnum } from '../enum'; +import moment from 'moment'; +import styles from './style.less'; +import { ISemantic } from '../data'; +import BatchCtrlDropDownButton from '@/components/BatchCtrlDropDownButton'; +import { ColumnsConfig } from '../components/TableColumnRender'; + +type Props = { + dispatch: Dispatch; + domainManger: StateType; +}; + +type QueryMetricListParams = { + id?: string; + name?: string; + bizName?: string; + sensitiveLevel?: string; + type?: string; + [key: string]: any; +}; + +const ClassMetricTable: React.FC = ({ domainManger, dispatch }) => { + const { initialState = {} } = useModel('@@initialState'); + + const { currentUser = {} } = initialState as any; + const { selectDomainId, selectModelId: modelId } = domainManger; + const [createModalVisible, setCreateModalVisible] = useState(false); + const defaultPagination = { + current: 1, + pageSize: 20, + total: 0, + }; + const [pagination, setPagination] = useState(defaultPagination); + const [loading, setLoading] = useState(false); + const [dataSource, setDataSource] = useState([]); + const [tagItem, setTagItem] = useState(); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [filterParams, setFilterParams] = useState>({ + showType: localStorage.getItem('metricMarketShowType') === '1' ? true : false, + }); + + const [downloadLoading, setDownloadLoading] = useState(false); + + const [hasAllPermission, setHasAllPermission] = useState(true); + + const actionRef = useRef(); + + useEffect(() => { + queryTagList(filterParams); + }, []); + + const queryBatchUpdateStatus = async (ids: React.Key[], status: StatusEnum) => { + if (Array.isArray(ids) && ids.length === 0) { + return; + } + const { code, msg } = await batchUpdateTagStatus({ + ids, + status, + }); + if (code === 200) { + queryTagList(filterParams); + return; + } + message.error(msg); + }; + + const queryTagList = async (params: QueryMetricListParams = {}, disabledLoading = false) => { + if (!disabledLoading) { + setLoading(true); + } + const { code, data, msg } = await getTagList({ + ...pagination, + ...params, + createdBy: params.onlyShowMe ? currentUser.name : null, + pageSize: params.showType ? 100 : params.pageSize || pagination.pageSize, + }); + setLoading(false); + const { list, pageSize, pageNum, total } = data || {}; + let resData: any = {}; + if (code === 200) { + if (!params.showType) { + setPagination({ + ...pagination, + pageSize: Math.min(pageSize, 100), + current: pageNum, + total, + }); + } + + setDataSource(list); + resData = { + data: list || [], + success: true, + }; + } else { + message.error(msg); + setDataSource([]); + resData = { + data: [], + total: 0, + success: false, + }; + } + return resData; + }; + + const deleteMetricQuery = async (id: number) => { + const { code, msg } = await deleteTag(id); + if (code === 200) { + setTagItem(undefined); + queryTagList(filterParams); + } else { + message.error(msg); + } + }; + + const handleMetricEdit = (tagItem: ISemantic.ITagItem) => { + setTagItem(tagItem); + setCreateModalVisible(true); + }; + + const columnsConfig = ColumnsConfig({ + indicatorInfo: { + url: '/tag/detail/', + starType: 'tag', + }, + }); + + const columns: ProColumns[] = [ + { + dataIndex: 'id', + title: 'ID', + width: 80, + fixed: 'left', + search: false, + }, + { + dataIndex: 'name', + title: '标签', + width: 280, + fixed: 'left', + render: columnsConfig.indicatorInfo.render, + }, + { + dataIndex: 'sensitiveLevel', + title: '敏感度', + width: 150, + valueEnum: SENSITIVE_LEVEL_ENUM, + render: columnsConfig.sensitiveLevel.render, + }, + + { + dataIndex: 'description', + title: '描述', + search: false, + width: 300, + render: columnsConfig.description.render, + }, + { + dataIndex: 'status', + title: '状态', + width: 180, + search: false, + render: columnsConfig.state.render, + }, + { + dataIndex: 'createdBy', + title: '创建人', + // width: 150, + search: false, + }, + { + dataIndex: 'updatedAt', + title: '更新时间', + search: false, + render: (value: any) => { + return value && value !== '-' ? moment(value).format('YYYY-MM-DD HH:mm:ss') : '-'; + }, + }, + { + title: '操作', + dataIndex: 'x', + valueType: 'option', + width: 180, + render: (_, record) => { + if (record.hasAdminRes) { + return ( + + { + handleMetricEdit(record); + }} + > + 编辑 + + + { + deleteMetricQuery(record.id); + }} + > + { + setTagItem(record); + }} + > + 删除 + + + + ); + } else { + return <>; + } + }, + }, + ]; + + const handleFilterChange = async (filterParams: { + key: string; + sensitiveLevel: string[]; + showFilter: string[]; + type: string; + }) => { + const { sensitiveLevel, type, showFilter } = filterParams; + const params: QueryMetricListParams = { ...filterParams }; + const sensitiveLevelValue = sensitiveLevel?.[0]; + const showFilterValue = showFilter?.[0]; + const typeValue = type?.[0]; + showFilterValue ? (params[showFilterValue] = true) : null; + params.sensitiveLevel = sensitiveLevelValue; + params.type = typeValue; + setFilterParams(params); + await queryTagList( + { + ...params, + ...defaultPagination, + }, + filterParams.key ? false : true, + ); + }; + + const rowSelection = { + onChange: (selectedRowKeys: React.Key[]) => { + const permissionList: boolean[] = []; + selectedRowKeys.forEach((id: React.Key) => { + const target = dataSource.find((item) => { + return item.id === id; + }); + if (target) { + permissionList.push(target.hasAdminRes); + } + }); + if (permissionList.includes(false)) { + setHasAllPermission(false); + } else { + setHasAllPermission(true); + } + setSelectedRowKeys(selectedRowKeys); + }, + // getCheckboxProps: (record: ISemantic.ITagItem) => ({ + // disabled: !record.hasAdminRes, + // }), + }; + + const onMenuClick = (key: string) => { + switch (key) { + case 'batchStart': + queryBatchUpdateStatus(selectedRowKeys, StatusEnum.ONLINE); + break; + case 'batchStop': + queryBatchUpdateStatus(selectedRowKeys, StatusEnum.OFFLINE); + break; + default: + break; + } + }; + + return ( + <> +
+ { + if (_.showType !== undefined) { + setLoading(true); + setDataSource([]); + } + handleFilterChange(values); + }} + /> +
+ <> + { + return false; + }} + sticky={{ offsetHeader: 0 }} + rowSelection={{ + type: 'checkbox', + ...rowSelection, + }} + toolBarRender={() => [ + { + queryBatchUpdateStatus(selectedRowKeys, StatusEnum.DELETED); + }} + hiddenList={['batchDownload']} + disabledList={hasAllPermission ? [] : ['batchStart', 'batchStop', 'batchDelete']} + onMenuClick={onMenuClick} + />, + ]} + loading={loading} + onChange={(data: any) => { + const { current, pageSize, total } = data; + const pagin = { + current, + pageSize, + total, + }; + setPagination(pagin); + queryTagList({ ...pagin, ...filterParams }); + }} + options={{ reload: false, density: false, fullScreen: false }} + /> + + + {createModalVisible && ( + { + setCreateModalVisible(false); + queryTagList({ ...filterParams, ...defaultPagination }); + }} + onCancel={() => { + setCreateModalVisible(false); + }} + /> + )} + + ); +}; +export default connect(({ domainManger }: { domainManger: StateType }) => ({ + domainManger, +}))(ClassMetricTable); diff --git a/webapp/packages/supersonic-fe/src/pages/SemanticModel/Insights/components/ClassTagTable.tsx b/webapp/packages/supersonic-fe/src/pages/SemanticModel/Insights/components/ClassTagTable.tsx new file mode 100644 index 000000000..9d24be86f --- /dev/null +++ b/webapp/packages/supersonic-fe/src/pages/SemanticModel/Insights/components/ClassTagTable.tsx @@ -0,0 +1,349 @@ +import type { ActionType, ProColumns } from '@ant-design/pro-table'; +import ProTable from '@ant-design/pro-table'; +import { message, Button, Space, Popconfirm, Input, Select } from 'antd'; +import React, { useRef, useState, useEffect } from 'react'; +import type { Dispatch } from 'umi'; +import { StatusEnum } from '../../enum'; +import { connect } from 'umi'; +import type { StateType } from '../../model'; +import { SENSITIVE_LEVEL_ENUM, SENSITIVE_LEVEL_OPTIONS } from '../../constant'; +import { getTagList, deleteTag, batchUpdateTagStatus } from '../../service'; +import TagInfoCreateForm from './TagInfoCreateForm'; +import BatchCtrlDropDownButton from '@/components/BatchCtrlDropDownButton'; +import TableHeaderFilter from '../../components/TableHeaderFilter'; +import moment from 'moment'; +import styles from '../style.less'; +import { ISemantic } from '../../data'; +import { ColumnsConfig } from '../../components/TableColumnRender'; + +type Props = { + dispatch: Dispatch; + domainManger: StateType; +}; + +const ClassTagTable: React.FC = ({ domainManger, dispatch }) => { + const { selectModelId: modelId, selectDomainId } = domainManger; + const [createModalVisible, setCreateModalVisible] = useState(false); + const [tagItem, setTagItem] = useState(); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [tableData, setTableData] = useState([]); + const [loading, setLoading] = useState(false); + const defaultPagination = { + current: 1, + pageSize: 20, + total: 0, + }; + const [pagination, setPagination] = useState(defaultPagination); + + const [filterParams, setFilterParams] = useState>({}); + + const actionRef = useRef(); + + const queryBatchUpdateStatus = async (ids: React.Key[], status: StatusEnum) => { + if (Array.isArray(ids) && ids.length === 0) { + return; + } + const { code, msg } = await batchUpdateTagStatus({ + ids, + status, + }); + if (code === 200) { + queryTagList({ ...filterParams, ...defaultPagination }); + return; + } + message.error(msg); + }; + + useEffect(() => { + queryTagList({ ...filterParams, ...defaultPagination }); + }, [filterParams]); + + const queryTagList = async (params: any) => { + setLoading(true); + const { code, data, msg } = await getTagList({ + ...pagination, + ...params, + modelIds: [modelId], + }); + setLoading(false); + const { list, pageSize, pageNum, total } = data || {}; + if (code === 200) { + setPagination({ + ...pagination, + pageSize: Math.min(pageSize, 100), + current: pageNum, + total, + }); + setTableData(list); + } else { + message.error(msg); + setTableData([]); + } + }; + + const columnsConfig = ColumnsConfig(); + + const columns: ProColumns[] = [ + { + dataIndex: 'id', + title: 'ID', + width: 80, + fixed: 'left', + search: false, + }, + { + dataIndex: 'name', + title: '标签', + width: 280, + fixed: 'left', + // width: '30%', + search: false, + render: columnsConfig.indicatorInfo.render, + }, + { + dataIndex: 'key', + title: '标签搜索', + hideInTable: true, + }, + { + dataIndex: 'sensitiveLevel', + title: '敏感度', + width: 160, + valueEnum: SENSITIVE_LEVEL_ENUM, + render: columnsConfig.sensitiveLevel.render, + }, + + { + dataIndex: 'description', + title: '描述', + width: 300, + search: false, + render: columnsConfig.description.render, + }, + { + dataIndex: 'status', + title: '状态', + width: 160, + search: false, + render: columnsConfig.state.render, + }, + { + dataIndex: 'createdBy', + title: '创建人', + width: 150, + search: false, + }, + { + dataIndex: 'updatedAt', + title: '更新时间', + width: 180, + search: false, + render: (value: any) => { + return value && value !== '-' ? moment(value).format('YYYY-MM-DD HH:mm:ss') : '-'; + }, + }, + { + title: '操作', + dataIndex: 'x', + valueType: 'option', + width: 150, + render: (_, record) => { + return ( + + + {record.status === StatusEnum.ONLINE ? ( + + ) : ( + + )} + { + const { code, msg } = await deleteTag(record.id); + if (code === 200) { + setTagItem(undefined); + queryTagList({ ...filterParams, ...defaultPagination }); + } else { + message.error(msg); + } + }} + > + + + + ); + }, + }, + ]; + + const rowSelection = { + onChange: (selectedRowKeys: React.Key[]) => { + setSelectedRowKeys(selectedRowKeys); + }, + }; + + const onMenuClick = (key: string) => { + switch (key) { + case 'batchStart': + queryBatchUpdateStatus(selectedRowKeys, StatusEnum.ONLINE); + break; + case 'batchStop': + queryBatchUpdateStatus(selectedRowKeys, StatusEnum.OFFLINE); + break; + default: + break; + } + }; + + return ( + <> + { + setFilterParams((preState) => { + return { + ...preState, + key: value, + }; + }); + }} + /> + ), + }, + { + label: '敏感度', + component: ( + + + + + + + + + + + + +

+ 在录入标签时,请务必详细填写标签口径。口径描述对于理解标签的含义、计算方法和使用场景至关重要。一个清晰、准确的口径描述可以帮助其他用户更好地理解和使用该标签,避免因为误解而导致错误的数据分析和决策。在填写口径时,建议包括以下信息: +

+

1. 标签的计算方法:详细说明标签是如何计算的,包括涉及的公式、计算步骤等。

+

2. 数据来源:描述标签所依赖的数据来源,包括数据表、字段等信息。

+

3. 使用场景:说明该标签适用于哪些业务场景,以及如何在这些场景中使用该标签。

+

4. 任何其他相关信息:例如数据更新频率、数据质量要求等。

+

+ 请确保口径描述清晰、简洁且易于理解,以便其他用户能够快速掌握标签的核心要点。 +

+ + } + /> + } + rules={[{ required: true, message: '请输入业务口径' }]} + > +