first commit

This commit is contained in:
jerryjzhang
2023-06-12 18:44:01 +08:00
commit dc4fc69b57
879 changed files with 573090 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
import { Button, Result } from 'antd';
import React from 'react';
import { history } from 'umi';
const NoAuthPage: React.FC = () => (
<Result
status="403"
title="当前页面无权限"
subTitle={1 ? '请联系项目管理员 jerryjzhang 开通权限' : '请申请加入自己业务的项目'}
extra={
<Button type="primary" onClick={() => history.push('/homepage')}>
</Button>
}
/>
);
export default NoAuthPage;

View File

@@ -0,0 +1,18 @@
import { Button, Result } from 'antd';
import React from 'react';
import { history } from 'umi';
const NoFoundPage: React.FC = () => (
<Result
status="404"
title="404"
subTitle="Sorry, the page you visited does not exist."
extra={
<Button type="primary" onClick={() => history.push('/homepage')}>
Back Home
</Button>
}
/>
);
export default NoFoundPage;

View File

@@ -0,0 +1,270 @@
import IconFont from '@/components/IconFont';
import { getTextWidth, groupByColumn, isMobile } from '@/utils/utils';
import { AutoComplete, Select, Tag } from 'antd';
import classNames from 'classnames';
import { debounce } from 'lodash';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { ForwardRefRenderFunction } from 'react';
import { searchRecommend } from 'supersonic-chat-sdk';
import { SemanticTypeEnum, SEMANTIC_TYPE_MAP } from '../constants';
import styles from './style.less';
import { PLACE_HOLDER } from '@/common/constants';
type Props = {
inputMsg: string;
chatId?: number;
onInputMsgChange: (value: string) => void;
onSendMsg: (msg: string, domainId?: number) => void;
};
const { OptGroup, Option } = Select;
let isPinyin = false;
let isSelect = false;
const compositionStartEvent = () => {
isPinyin = true;
};
const compositionEndEvent = () => {
isPinyin = false;
};
const ChatFooter: ForwardRefRenderFunction<any, Props> = (
{ inputMsg, chatId, onInputMsgChange, onSendMsg },
ref,
) => {
const [stepOptions, setStepOptions] = useState<Record<string, any[]>>({});
const [open, setOpen] = useState(false);
const [focused, setFocused] = useState(false);
const inputRef = useRef<any>();
const fetchRef = useRef(0);
const inputFocus = () => {
inputRef.current?.focus();
};
const inputBlur = () => {
inputRef.current?.blur();
};
useImperativeHandle(ref, () => ({
inputFocus,
inputBlur,
}));
const initEvents = () => {
const autoCompleteEl = document.getElementById('chatInput');
autoCompleteEl!.addEventListener('compositionstart', compositionStartEvent);
autoCompleteEl!.addEventListener('compositionend', compositionEndEvent);
};
const removeEvents = () => {
const autoCompleteEl = document.getElementById('chatInput');
if (autoCompleteEl) {
autoCompleteEl.removeEventListener('compositionstart', compositionStartEvent);
autoCompleteEl.removeEventListener('compositionend', compositionEndEvent);
}
};
useEffect(() => {
initEvents();
return () => {
removeEvents();
};
}, []);
const debounceGetWordsFunc = useCallback(() => {
const getAssociateWords = async (msg: string, chatId?: number) => {
if (isPinyin) {
return;
}
fetchRef.current += 1;
const fetchId = fetchRef.current;
const res = await searchRecommend(msg, chatId);
if (fetchId !== fetchRef.current) {
return;
}
const recommends = msg ? res.data.data || [] : [];
const stepOptionList = recommends.map((item: any) => item.subRecommend);
if (stepOptionList.length > 0 && stepOptionList.every((item: any) => item !== null)) {
const data = groupByColumn(recommends, 'domainName');
const optionsData =
isMobile && recommends.length > 6
? Object.keys(data)
.slice(0, 4)
.reduce((result, key) => {
result[key] = data[key].slice(
0,
Object.keys(data).length > 2 ? 2 : Object.keys(data).length > 1 ? 3 : 6,
);
return result;
}, {})
: data;
setStepOptions(optionsData);
} else {
setStepOptions({});
}
setOpen(recommends.length > 0);
};
return debounce(getAssociateWords, 20);
}, []);
const [debounceGetWords] = useState<any>(debounceGetWordsFunc);
useEffect(() => {
if (!isSelect) {
debounceGetWords(inputMsg, chatId);
} else {
isSelect = false;
}
if (!inputMsg) {
setStepOptions({});
}
}, [inputMsg]);
useEffect(() => {
if (!focused) {
setOpen(false);
}
}, [focused]);
useEffect(() => {
const autoCompleteDropdown = document.querySelector(
`.${styles.autoCompleteDropdown}`,
) as HTMLElement;
if (!autoCompleteDropdown) {
return;
}
const textWidth = getTextWidth(inputMsg);
if (Object.keys(stepOptions).length > 0) {
autoCompleteDropdown.style.marginLeft = `${textWidth}px`;
}
}, [stepOptions]);
const sendMsg = (value: string) => {
const option = Object.keys(stepOptions)
.reduce((result: any[], item) => {
result = result.concat(stepOptions[item]);
return result;
}, [])
.find((item) =>
Object.keys(stepOptions).length === 1
? item.recommend === value
: `${item.domainName || ''}${item.recommend}` === value,
);
if (option && isSelect) {
onSendMsg(option.recommend, option.domainId);
} else {
onSendMsg(value);
}
};
const autoCompleteDropdownClass = classNames(styles.autoCompleteDropdown, {
[styles.external]: true,
[styles.mobile]: isMobile,
});
const onSelect = (value: string) => {
isSelect = true;
sendMsg(value);
setOpen(false);
setTimeout(() => {
isSelect = false;
}, 200);
};
const chatFooterClass = classNames(styles.chatFooter, {
[styles.mobile]: isMobile,
});
return (
<div className={chatFooterClass}>
<div className={styles.composer}>
<div className={styles.composerInputWrapper}>
<AutoComplete
className={styles.composerInput}
placeholder={PLACE_HOLDER}
value={inputMsg}
onChange={onInputMsgChange}
onSelect={onSelect}
autoFocus={!isMobile}
backfill
ref={inputRef}
id="chatInput"
onKeyDown={(e) => {
if ((e.code === 'Enter' || e.code === 'NumpadEnter') && !isSelect) {
const chatInputEl: any = document.getElementById('chatInput');
sendMsg(chatInputEl.value);
setOpen(false);
}
}}
onFocus={() => {
setFocused(true);
}}
onBlur={() => {
setFocused(false);
}}
dropdownClassName={autoCompleteDropdownClass}
listHeight={500}
allowClear
open={open}
getPopupContainer={isMobile ? (triggerNode) => triggerNode.parentNode : undefined}
>
{Object.keys(stepOptions).map((key) => {
return (
<OptGroup key={key} label={key}>
{stepOptions[key].map((option) => (
<Option
key={`${option.recommend}${option.domainName ? `_${option.domainName}` : ''}`}
value={
Object.keys(stepOptions).length === 1
? option.recommend
: `${option.domainName || ''}${option.recommend}`
}
className={styles.searchOption}
>
<div className={styles.optionContent}>
{option.schemaElementType && (
<Tag
className={styles.semanticType}
color={
option.schemaElementType === SemanticTypeEnum.DIMENSION ||
option.schemaElementType === SemanticTypeEnum.DOMAIN
? 'blue'
: option.schemaElementType === SemanticTypeEnum.VALUE
? 'geekblue'
: 'orange'
}
>
{SEMANTIC_TYPE_MAP[option.schemaElementType] ||
option.schemaElementType ||
'维度'}
</Tag>
)}
{option.subRecommend}
</div>
</Option>
))}
</OptGroup>
);
})}
</AutoComplete>
<div
className={classNames(styles.sendBtn, {
[styles.sendBtnActive]: inputMsg?.length > 0,
})}
onClick={() => {
sendMsg(inputMsg);
}}
>
<IconFont type="icon-ios-send" />
</div>
</div>
</div>
</div>
);
};
export default forwardRef(ChatFooter);

View File

@@ -0,0 +1,153 @@
.chatFooter {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
margin-top: 6px;
margin-right: 20px;
margin-bottom: 40px;
.composer {
display: flex;
height: 46px;
.composerInputWrapper {
flex: 1;
.composerInput {
width: 100%;
height: 100%;
:global {
.ant-select-selector {
box-sizing: border-box;
height: 100%;
overflow: hidden;
color: rgba(0, 0, 0, 0.87);
font-size: 16px;
word-break: break-all;
background: #fff;
border: 0;
border-radius: 24px;
box-shadow: rgba(0, 0, 0, 0.07) 0 -0.5px 0, rgba(0, 0, 0, 0.1) 0 0 18px;
transition: border-color 0.15s ease-in-out;
resize: none;
.ant-select-selection-search-input {
height: 100% !important;
padding: 0 20px;
}
.ant-select-selection-search {
right: 0 !important;
left: 0 !important;
}
.ant-select-selection-placeholder {
padding-left: 10px !important;
line-height: 45px;
}
}
.ant-select-clear {
right: auto;
left: 500px;
width: 16px;
height: 16px;
margin-top: -8px;
font-size: 16px;
}
}
}
:global {
.ant-select-focused {
.ant-select-selector {
box-shadow: rgb(74, 114, 245) 0 0 3px !important;
}
}
}
}
}
.sendBtn {
position: absolute;
top: 50%;
right: 6px;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
color: #fff;
font-size: 20px;
background-color: rgb(184, 184, 191);
border: unset;
border-radius: 50%;
transform: translateY(-50%);
transition: background-color 0.3s ease 0s;
&.sendBtnActive {
background-color: var(--chat-blue);
}
}
&.mobile {
height: 40px;
margin: 12px;
margin-bottom: 20px;
.composer {
height: 40px;
:global {
.ant-select-selector {
font-size: 14px !important;
}
.ant-select-selection-placeholder {
line-height: 39px !important;
}
}
}
}
}
.searchOption {
padding: 6px 20px;
color: #212121;
font-size: 16px;
}
.mobile {
.searchOption {
min-height: 26px;
padding: 2px 12px;
font-size: 14px;
}
}
.domain {
margin-top: 2px;
color: var(--text-color-fourth);
font-size: 13px;
line-height: 12px;
}
.autoCompleteDropdown {
left: 285px !important;
width: fit-content !important;
min-width: 50px !important;
border-radius: 6px;
&.external {
left: 226px !important;
}
&.mobile {
left: 20px !important;
}
}
.semanticType {
margin-right: 10px;
}

View File

@@ -0,0 +1,203 @@
import IconFont from '@/components/IconFont';
import { Dropdown, Menu, message } from 'antd';
import classNames from 'classnames';
import {
useEffect,
useState,
forwardRef,
ForwardRefRenderFunction,
useImperativeHandle,
} from 'react';
import { useLocation } from 'umi';
import ConversationHistory from './components/ConversationHistory';
import ConversationModal from './components/ConversationModal';
import { deleteConversation, getAllConversations, saveConversation } from './service';
import styles from './style.less';
import { ConversationDetailType } from './type';
type Props = {
currentConversation?: ConversationDetailType;
onSelectConversation: (conversation: ConversationDetailType, name?: string) => void;
};
const Conversation: ForwardRefRenderFunction<any, Props> = (
{ currentConversation, onSelectConversation },
ref,
) => {
const location = useLocation();
const { q, cid } = (location as any).query;
const [originConversations, setOriginConversations] = useState<ConversationDetailType[]>([]);
const [conversations, setConversations] = useState<ConversationDetailType[]>([]);
const [editModalVisible, setEditModalVisible] = useState(false);
const [editConversation, setEditConversation] = useState<ConversationDetailType>();
const [historyVisible, setHistoryVisible] = useState(false);
useImperativeHandle(ref, () => ({
updateData,
onAddConversation,
}));
const updateData = async () => {
const { data } = await getAllConversations();
const conversationList = (data || []).slice(0, 5);
setOriginConversations(data || []);
setConversations(conversationList);
return conversationList;
};
const initData = async () => {
const data = await updateData();
if (data.length > 0) {
const chatId = localStorage.getItem('CONVERSATION_ID') || cid;
if (chatId) {
const conversation = data.find((item: any) => item.chatId === +chatId);
if (conversation) {
onSelectConversation(conversation);
} else {
onSelectConversation(data[0]);
}
} else {
onSelectConversation(data[0]);
}
} else {
onAddConversation();
}
};
useEffect(() => {
if (q && cid === undefined) {
onAddConversation(q);
} else {
initData();
}
}, [q]);
const addConversation = async (name?: string) => {
await saveConversation(name || '新问答对话');
return updateData();
};
const onDeleteConversation = async (id: number) => {
await deleteConversation(id);
initData();
};
const onAddConversation = async (name?: string) => {
const data = await addConversation(name);
onSelectConversation(data[0], name);
};
const onOperate = (key: string, conversation: ConversationDetailType) => {
if (key === 'editName') {
setEditConversation(conversation);
setEditModalVisible(true);
} else if (key === 'delete') {
onDeleteConversation(conversation.chatId);
}
};
const onNewChat = () => {
onAddConversation('新问答对话');
};
const onShowHistory = () => {
setHistoryVisible(true);
};
const onShare = () => {
message.info('正在开发中,敬请期待');
};
return (
<div className={styles.conversation}>
<div className={styles.leftSection}>
<div className={styles.conversationList}>
{conversations.map((item) => {
const conversationItemClass = classNames(styles.conversationItem, {
[styles.activeConversationItem]: currentConversation?.chatId === item.chatId,
});
return (
<Dropdown
key={item.chatId}
overlay={
<Menu
items={[
{ label: '修改对话名称', key: 'editName' },
{ label: '删除', key: 'delete' },
]}
onClick={({ key }) => {
onOperate(key, item);
}}
/>
}
trigger={['contextMenu']}
>
<div
key={item.chatId}
className={conversationItemClass}
onClick={() => {
onSelectConversation(item);
}}
>
<div className={styles.conversationItemContent}>
<IconFont type="icon-chat1" className={styles.conversationIcon} />
<div className={styles.conversationContent} title={item.chatName}>
{item.chatName}
</div>
</div>
</div>
</Dropdown>
);
})}
<div className={styles.conversationItem} onClick={onShowHistory}>
<div className={styles.conversationItemContent}>
<IconFont
type="icon-more2"
className={`${styles.conversationIcon} ${styles.historyIcon}`}
/>
<div className={styles.conversationContent}></div>
</div>
</div>
</div>
<div className={styles.operateSection}>
<div className={styles.operateItem} onClick={onNewChat}>
<IconFont type="icon-add" className={`${styles.operateIcon} ${styles.addIcon}`} />
<div className={styles.operateLabel}></div>
</div>
<div className={styles.operateItem} onClick={onShare}>
<IconFont
type="icon-fenxiang2"
className={`${styles.operateIcon} ${styles.shareIcon}`}
/>
<div className={styles.operateLabel}></div>
</div>
</div>
</div>
{historyVisible && (
<ConversationHistory
conversations={originConversations}
onSelectConversation={(conversation) => {
onSelectConversation(conversation);
setHistoryVisible(false);
}}
onClose={() => {
setHistoryVisible(false);
}}
/>
)}
<ConversationModal
visible={editModalVisible}
editConversation={editConversation}
onClose={() => {
setEditModalVisible(false);
}}
onFinish={() => {
setEditModalVisible(false);
updateData();
}}
/>
</div>
);
};
export default forwardRef(Conversation);

View File

@@ -0,0 +1,89 @@
import Text from './components/Text';
import { memo, useCallback, useEffect } from 'react';
import { isEqual } from 'lodash';
import styles from './style.less';
import { connect, Dispatch } from 'umi';
import { ChatItem } from 'supersonic-chat-sdk';
import type { MsgDataType } from 'supersonic-chat-sdk';
import { MessageItem, MessageTypeEnum } from './type';
type Props = {
id: string;
chatId: number;
messageList: MessageItem[];
dispatch: Dispatch;
onClickMessageContainer: () => void;
onMsgDataLoaded: (data: MsgDataType) => void;
onSelectSuggestion: (value: string) => void;
onUpdateMessageScroll: () => void;
};
const MessageContainer: React.FC<Props> = ({
id,
chatId,
messageList,
dispatch,
onClickMessageContainer,
onMsgDataLoaded,
onSelectSuggestion,
onUpdateMessageScroll,
}) => {
const onWindowResize = useCallback(() => {
dispatch({
type: 'windowResize/setTriggerResize',
payload: true,
});
setTimeout(() => {
dispatch({
type: 'windowResize/setTriggerResize',
payload: false,
});
}, 0);
}, []);
useEffect(() => {
window.addEventListener('resize', onWindowResize);
return () => {
window.removeEventListener('resize', onWindowResize);
};
}, []);
return (
<div id={id} className={styles.messageContainer} onClick={onClickMessageContainer}>
<div className={styles.messageList}>
{messageList.map((msgItem: MessageItem, index: number) => {
return (
<div key={`${msgItem.id}`} id={`${msgItem.id}`} className={styles.messageItem}>
{msgItem.type === MessageTypeEnum.TEXT && <Text position="left" data={msgItem.msg} />}
{msgItem.type === MessageTypeEnum.QUESTION && (
<>
<Text position="right" data={msgItem.msg} quote={msgItem.quote} />
<ChatItem
msg={msgItem.msg || ''}
msgData={msgItem.msgData}
conversationId={chatId}
classId={msgItem.domainId}
isLastMessage={index === messageList.length - 1}
onLastMsgDataLoaded={onMsgDataLoaded}
onSelectSuggestion={onSelectSuggestion}
onUpdateMessageScroll={onUpdateMessageScroll}
suggestionEnable
/>
</>
)}
</div>
);
})}
</div>
</div>
);
};
function areEqual(prevProps: Props, nextProps: Props) {
if (prevProps.id === nextProps.id && isEqual(prevProps.messageList, nextProps.messageList)) {
return true;
}
return false;
}
export default connect()(memo(MessageContainer, areEqual));

View File

@@ -0,0 +1,56 @@
import moment from 'moment';
import styles from './style.less';
import type { ChatContextType } from 'supersonic-chat-sdk';
type Props = {
chatContext: ChatContextType;
};
const Context: React.FC<Props> = ({ chatContext }) => {
const { domainName, metrics, dateInfo, filters } = chatContext;
return (
<div className={styles.context}>
<div className={styles.title}></div>
<div className={styles.content}>
<div className={styles.field}>
<span className={styles.fieldName}></span>
<span className={styles.fieldValue}>{domainName}</span>
</div>
{dateInfo && (
<div className={styles.field}>
<span className={styles.fieldName}></span>
<span className={styles.fieldValue}>
{dateInfo.text ||
`${moment(dateInfo.endDate).diff(moment(dateInfo.startDate), 'days') + 1}`}
</span>
</div>
)}
{metrics && metrics.length > 0 && (
<div className={styles.field}>
<span className={styles.fieldName}></span>
<span className={styles.fieldValue}>
{metrics.map((metric) => metric.name).join('、')}
</span>
</div>
)}
{filters && filters.length > 0 && (
<div className={styles.filterSection}>
<div className={styles.fieldName}></div>
<div className={styles.filterValues}>
{filters.map((filter) => {
return (
<div className={styles.filterItem} key={filter.name}>
{filter.name}{filter.value}
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
};
export default Context;

View File

@@ -0,0 +1,70 @@
.context {
display: flex;
flex-direction: column;
.title {
margin-bottom: 22px;
color: var(--text-color);
font-size: 16px;
line-height: 24px;
}
.desc {
max-height: 350px;
margin-top: 10px;
margin-bottom: 10px;
overflow-y: auto;
color: var(--text-color-third);
font-size: 13px;
line-height: 22px;
}
.field {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 10px;
&.columnLayout {
flex-direction: column;
}
.avatar {
margin-right: 8px;
}
}
.filterSection {
margin-bottom: 12px;
}
.fieldName {
margin-right: 6px;
color: var(--text-color);
font-weight: 500;
}
.fieldValue {
color: var(--text-color);
&.switchField {
cursor: pointer;
}
}
.filterValues {
display: flex;
flex-wrap: wrap;
margin-top: 10px;
font-size: 13px;
column-gap: 4px;
row-gap: 4px;
.filterItem {
padding: 2px 12px;
color: var(--text-color-secondary);
background-color: var(--body-background);
border-radius: 13px;
}
}
}

View File

@@ -0,0 +1,41 @@
import { getFormattedValueData } from '@/utils/utils';
import moment from 'moment';
import React from 'react';
import styles from './style.less';
import type { EntityInfoType, MsgDataType } from 'supersonic-chat-sdk';
type Props = {
currentEntity: MsgDataType;
};
const Introduction: React.FC<Props> = ({ currentEntity }) => {
const { entityInfo } = currentEntity;
const { dimensions, metrics } = entityInfo || ({} as EntityInfoType);
return (
<div className={styles.introduction}>
{dimensions
?.filter((dimension) => !dimension.bizName.includes('photo'))
.map((dimension) => {
return (
<div className={styles.field} key={dimension.name}>
<span className={styles.fieldName}>{dimension.name}</span>
<span className={styles.fieldValue}>
{dimension.bizName.includes('publish_time')
? moment(dimension.value).format('YYYY-MM-DD')
: dimension.value}
</span>
</div>
);
})}
{metrics?.map((metric) => (
<div className={styles.field} key={metric.name}>
<span className={styles.fieldName}>{metric.name}</span>
<span className={styles.fieldValue}>{getFormattedValueData(metric.value)}</span>
</div>
))}
</div>
);
};
export default Introduction;

View File

@@ -0,0 +1,63 @@
.introduction {
display: flex;
flex-direction: column;
padding-bottom: 4px;
.title {
margin-bottom: 22px;
color: var(--text-color);
font-size: 16px;
line-height: 24px;
}
.desc {
max-height: 350px;
margin-top: 10px;
margin-bottom: 10px;
overflow-y: auto;
color: var(--text-color-third);
font-size: 13px;
line-height: 22px;
}
.field {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 10px;
&.columnLayout {
flex-direction: column;
}
.avatar {
margin-right: 8px;
}
}
.fieldName {
margin-right: 6px;
color: var(--text-color);
font-weight: 500;
}
.fieldValue {
color: var(--text-color);
&.switchField {
cursor: pointer;
}
&.dimensionFieldValue {
max-width: 90px;
// white-space: nowrap;
}
&.mainNameFieldValue {
max-width: 90px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}

View File

@@ -0,0 +1,28 @@
import classNames from 'classnames';
import Context from './Context';
import Introduction from './Introduction';
import styles from './style.less';
import type { MsgDataType } from 'supersonic-chat-sdk';
type Props = {
currentEntity?: MsgDataType;
};
const RightSection: React.FC<Props> = ({ currentEntity }) => {
const rightSectionClass = classNames(styles.rightSection, {
[styles.external]: true,
});
return (
<div className={rightSectionClass}>
{currentEntity && (
<div className={styles.entityInfo}>
{currentEntity?.chatContext && <Context chatContext={currentEntity.chatContext} />}
<Introduction currentEntity={currentEntity} />
</div>
)}
</div>
);
};
export default RightSection;

View File

@@ -0,0 +1,19 @@
.rightSection {
width: 225px;
height: calc(100vh - 48px);
padding-right: 10px;
padding-bottom: 10px;
padding-left: 20px;
overflow-y: auto;
.entityInfo {
margin-top: 30px;
.topInfo {
margin-bottom: 20px;
color: var(--text-color-third);
font-weight: 500;
font-size: 14px;
}
}
}

View File

@@ -0,0 +1,43 @@
import { CloseOutlined } from '@ant-design/icons';
import moment from 'moment';
import type { ConversationDetailType } from '../../type';
import styles from './style.less';
type Props = {
conversations: ConversationDetailType[];
onSelectConversation: (conversation: ConversationDetailType) => void;
onClose: () => void;
};
const ConversationHistory: React.FC<Props> = ({ conversations, onSelectConversation, onClose }) => {
return (
<div className={styles.conversationHistory}>
<div className={styles.header}>
<div className={styles.headerTitle}></div>
<CloseOutlined className={styles.headerClose} onClick={onClose} />
</div>
<div className={styles.conversationContent}>
{conversations.slice(0, 1000).map((conversation) => {
return (
<div
key={conversation.chatId}
className={styles.conversationItem}
onClick={() => {
onSelectConversation(conversation);
}}
>
<div className={styles.conversationName} title={conversation.chatName}>
{conversation.chatName}
</div>
<div className={styles.conversationTime}>
{moment(conversation.lastTime).format('YYYY-MM-DD')}
</div>
</div>
);
})}
</div>
</div>
);
};
export default ConversationHistory;

View File

@@ -0,0 +1,64 @@
.conversationHistory {
position: absolute;
top: 0;
left: 0;
z-index: 10;
display: flex;
flex-direction: column;
width: 215px;
height: calc(100vh - 48px);
overflow: hidden;
background: #f3f3f7;
border-right: 1px solid var(--border-color-base);
.header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
padding: 0 16px;
border-bottom: 1px solid #e8e8e8;
.headerTitle {
color: var(--text-color);
font-weight: 500;
font-size: 16px;
}
.headerClose {
color: var(--text-color-third);
font-size: 16px;
cursor: pointer;
&:hover {
color: var(--chat-blue);
}
}
}
.conversationContent {
flex: 1;
overflow: auto;
.conversationItem {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 8px 16px;
border-bottom: 1px solid var(--border-color-base-bg-5);
cursor: pointer;
row-gap: 2px;
&:hover {
background: var(--light-blue-background);
}
.conversationName {
width: 170px;
overflow: hidden;
color: var(--text-color);
font-size: 14px;
white-space: nowrap;
text-overflow: ellipsis;
}
.conversationTime {
color: var(--text-color-third);
font-size: 13px;
}
}
}
}

View File

@@ -0,0 +1,65 @@
import { Form, Input, Modal } from 'antd';
import { useEffect, useRef, useState } from 'react';
import { updateConversationName } from '../../service';
import type { ConversationDetailType } from '../../type';
const FormItem = Form.Item;
type Props = {
visible: boolean;
editConversation?: ConversationDetailType;
onClose: () => void;
onFinish: (conversationName: string) => void;
};
const layout = {
labelCol: { span: 6 },
wrapperCol: { span: 18 },
};
const ConversationModal: React.FC<Props> = ({ visible, editConversation, onClose, onFinish }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const conversationNameInputRef = useRef<any>();
useEffect(() => {
if (visible) {
form.setFieldsValue({ conversationName: editConversation!.chatName });
setTimeout(() => {
conversationNameInputRef.current.focus({
cursor: 'all',
});
}, 0);
}
}, [visible]);
const onConfirm = async () => {
const values = await form.validateFields();
setLoading(true);
await updateConversationName(values.conversationName, editConversation!.chatId);
setLoading(false);
onFinish(values.conversationName);
};
return (
<Modal
title="修改问答对话名称"
visible={visible}
onCancel={onClose}
onOk={onConfirm}
confirmLoading={loading}
>
<Form {...layout} form={form}>
<FormItem name="conversationName" label="名称" rules={[{ required: true }]}>
<Input
placeholder="请输入问答对话名称"
ref={conversationNameInputRef}
onPressEnter={onConfirm}
/>
</FormItem>
</Form>
</Modal>
);
};
export default ConversationModal;

View File

@@ -0,0 +1,35 @@
import classNames from 'classnames';
import styles from './style.less';
type Props = {
position: 'left' | 'right';
bubbleClassName?: string;
aggregator?: string;
noTime?: boolean;
};
const Message: React.FC<Props> = ({ position, children, bubbleClassName }) => {
const messageClass = classNames(styles.message, {
[styles.left]: position === 'left',
[styles.right]: position === 'right',
});
return (
<div className={messageClass}>
<div className={styles.messageContent}>
<div className={styles.messageBody}>
<div
className={`${styles.bubble}${bubbleClassName ? ` ${bubbleClassName}` : ''}`}
onClick={(e) => {
e.stopPropagation();
}}
>
{children}
</div>
</div>
</div>
</div>
);
};
export default Message;

View File

@@ -0,0 +1,19 @@
import Message from './Message';
import styles from './style.less';
type Props = {
position: 'left' | 'right';
data: any;
quote?: string;
};
const Text: React.FC<Props> = ({ position, data, quote }) => {
return (
<Message position={position} bubbleClassName={styles.textBubble}>
{position === 'right' && quote && <div className={styles.quote}>{quote}</div>}
<div className={styles.text}>{data}</div>
</Message>
);
};
export default Text;

View File

@@ -0,0 +1,19 @@
import { CHAT_BLUE } from '@/common/constants';
import { Spin } from 'antd';
import BeatLoader from 'react-spinners/BeatLoader';
import Message from './Message';
import styles from './style.less';
const Typing = () => {
return (
<Message position="left" bubbleClassName={styles.typingBubble}>
<Spin
spinning={true}
indicator={<BeatLoader color={CHAT_BLUE} size={10} />}
className={styles.typing}
/>
</Message>
);
};
export default Typing;

View File

@@ -0,0 +1,277 @@
.message {
.messageContent {
display: flex;
align-items: flex-start;
.messageBody {
width: 100%;
}
.avatar {
margin-right: 4px;
}
.bubble {
box-sizing: border-box;
min-width: 1px;
max-width: 100%;
padding: 8px 16px 10px;
background: rgba(255, 255, 255, 0.8);
border: 1px solid transparent;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.14), 0 0 2px rgba(0, 0, 0, 0.12);
.text {
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: break-word;
user-select: text;
}
.textMsg {
padding: 12px 0 5px;
}
.topBar {
display: flex;
align-items: center;
max-width: 100%;
padding: 4px 0 8px;
overflow-x: auto;
color: var(--text-color);
font-weight: 500;
font-size: 14px;
white-space: nowrap;
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
.messageTitleWrapper {
display: flex;
align-items: center;
}
.messageTitle {
display: flex;
align-items: center;
color: var(--text-color);
font-weight: 500;
font-size: 14px;
white-space: nowrap;
}
}
}
}
&.right {
.messageContent {
flex-direction: row-reverse;
.bubble {
float: right;
box-sizing: border-box;
padding: 8px 16px;
color: #fff;
font-size: 16px;
background: linear-gradient(81.62deg, #2870ea 8.72%, var(--chat-blue) 85.01%);
border: 1px solid transparent;
border-radius: 12px 4px 12px 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.14), 0 0 2px rgba(0, 0, 0, 0.12);
.text {
&::selection {
background: #1ba1f7;
}
}
}
}
}
}
.textBubble {
width: fit-content;
}
.listenerSex {
padding-bottom: 24px;
}
.listenerArea {
padding-top: 24px;
padding-bottom: 12px;
}
.typing {
width: 100%;
padding: 0 5px;
:global {
.ant-spin-dot {
width: 100%;
}
}
}
.messageEntityName {
cursor: pointer;
&:hover {
color: var(--primary-color);
}
}
.messageAvatar {
margin-right: 8px;
}
.dataHolder {
position: relative;
}
.subTitle {
margin-left: 20px;
color: var(--text-color-third);
font-weight: normal;
font-size: 12px;
.subTitleValue {
margin-left: 6px;
color: var(--text-color);
font-size: 13px;
}
}
.avatarPopover {
:global {
.ant-popover-inner-content {
padding: 3px 4px !important;
}
}
}
.moreOption {
display: flex;
align-items: center;
margin-top: 10px;
color: var(--text-color-fourth);
font-size: 12px;
.selectOthers {
color: var(--text-color);
cursor: pointer;
&:hover {
color: var(--primary-color);
}
}
.indicators {
display: flex;
align-items: center;
margin-left: 12px;
column-gap: 12px;
.indicator {
cursor: pointer;
&:hover {
color: var(--primary-color);
}
}
}
}
.contentName {
max-width: 350px;
white-space: nowrap;
text-overflow: ellipsis;
}
.aggregatorIndicator {
color: var(--text-color);
font-weight: 500;
font-size: 20px;
}
.entityId {
display: flex;
align-items: center;
margin-left: 12px;
column-gap: 4px;
.idTitle {
color: var(--text-color-fourth);
font-size: 12px;
}
.idValue {
color: var(--text-color-fourth);
font-size: 13px;
cursor: pointer;
&:hover {
color: var(--primary-color);
}
}
}
.typingBubble {
width: fit-content;
}
.quote {
margin-bottom: 4px;
padding: 0 4px 0 6px;
color: var(--border-color-base);
font-size: 13px;
border-left: 4px solid var(--border-color-base);
border-top-left-radius: 2px;
border-bottom-left-radius: 2px;
}
.filterSection {
display: flex;
align-items: center;
color: var(--text-color-secondary);
font-weight: normal;
font-size: 13px;
.filterItem {
padding: 2px 12px;
color: var(--text-color-secondary);
background-color: #edf2f2;
border-radius: 13px;
}
}
.noPermissionTip {
display: flex;
align-items: center;
}
.tip {
margin-left: 6px;
color: var(--text-color-third);
}
.infoBar {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-top: 20px;
column-gap: 20px;
}
.mainEntityInfo {
display: flex;
flex-wrap: wrap;
align-items: center;
font-size: 13px;
column-gap: 20px;
.infoItem {
display: flex;
align-items: center;
.infoName {
color: var(--text-color-fourth);
}
.infoValue {
color: var(--text-color-secondary);
}
}
}

View File

@@ -0,0 +1,28 @@
export const THEME_COLOR_LIST = [
'#3369FF',
'#36D2B8',
'#DB8D76',
'#47B359',
'#8545E6',
'#E0B18B',
'#7258F3',
'#0095FF',
'#52CC8F',
'#6675FF',
'#CC516E',
'#5CA9E6',
];
export enum SemanticTypeEnum {
DOMAIN = 'DOMAIN',
DIMENSION = 'DIMENSION',
METRIC = 'METRIC',
VALUE = 'VALUE',
}
export const SEMANTIC_TYPE_MAP = {
[SemanticTypeEnum.DOMAIN]: '主题域',
[SemanticTypeEnum.DIMENSION]: '维度',
[SemanticTypeEnum.METRIC]: '指标',
[SemanticTypeEnum.VALUE]: '维度值',
};

View File

@@ -0,0 +1,238 @@
import { updateMessageContainerScroll, isMobile, uuid } from '@/utils/utils';
import { useEffect, useRef, useState } from 'react';
import { Helmet } from 'umi';
import MessageContainer from './MessageContainer';
import styles from './style.less';
import { ConversationDetailType, MessageItem, MessageTypeEnum } from './type';
import { updateConversationName } from './service';
import { useThrottleFn } from 'ahooks';
import Conversation from './Conversation';
import RightSection from './RightSection';
import ChatFooter from './ChatFooter';
import classNames from 'classnames';
import { DEFAULT_CONVERSATION_NAME, WEB_TITLE } from '@/common/constants';
import { HistoryMsgItemType, MsgDataType, getHistoryMsg, queryContext } from 'supersonic-chat-sdk';
import { getConversationContext } from './utils';
const Chat = () => {
const [messageList, setMessageList] = useState<MessageItem[]>([]);
const [inputMsg, setInputMsg] = useState('');
const [pageNo, setPageNo] = useState(1);
const [hasNextPage, setHasNextPage] = useState(false);
const [historyInited, setHistoryInited] = useState(false);
const [currentConversation, setCurrentConversation] = useState<
ConversationDetailType | undefined
>(isMobile ? { chatId: 0, chatName: '问答对话' } : undefined);
const [currentEntity, setCurrentEntity] = useState<MsgDataType>();
const conversationRef = useRef<any>();
const chatFooterRef = useRef<any>();
const sendHelloRsp = () => {
setMessageList([
{
id: uuid(),
type: MessageTypeEnum.TEXT,
msg: '您好,请问有什么我能帮您吗?',
},
]);
};
const updateHistoryMsg = async (page: number) => {
const res = await getHistoryMsg(page, currentConversation!.chatId);
const { hasNextPage, list } = res.data.data;
setMessageList([
...list.map((item: HistoryMsgItemType) => ({
id: item.questionId,
type: MessageTypeEnum.QUESTION,
msg: item.queryText,
msgData: item.queryResponse,
})),
...(page === 1 ? [] : messageList),
]);
setHasNextPage(hasNextPage);
if (page === 1) {
if (list.length === 0) {
sendHelloRsp();
} else {
setCurrentEntity(list[list.length - 1].queryResponse);
}
updateMessageContainerScroll();
setHistoryInited(true);
}
if (page > 1) {
const msgEle = document.getElementById(`${messageList[0]?.id}`);
msgEle?.scrollIntoView();
}
};
const { run: handleScroll } = useThrottleFn(
(e) => {
if (e.target.scrollTop === 0 && hasNextPage) {
updateHistoryMsg(pageNo + 1);
setPageNo(pageNo + 1);
}
},
{
leading: true,
trailing: true,
wait: 200,
},
);
useEffect(() => {
if (historyInited) {
const messageContainerEle = document.getElementById('messageContainer');
messageContainerEle?.addEventListener('scroll', handleScroll);
}
return () => {
const messageContainerEle = document.getElementById('messageContainer');
messageContainerEle?.removeEventListener('scroll', handleScroll);
};
}, [historyInited]);
const inputFocus = () => {
if (!isMobile) {
chatFooterRef.current?.inputFocus();
}
};
const inputBlur = () => {
chatFooterRef.current?.inputBlur();
};
useEffect(() => {
if (!currentConversation) {
return;
}
setCurrentEntity(undefined);
const { initMsg, domainId } = currentConversation;
if (initMsg) {
inputFocus();
if (initMsg === DEFAULT_CONVERSATION_NAME) {
sendHelloRsp();
return;
}
onSendMsg(currentConversation.initMsg, [], domainId, true);
return;
}
updateHistoryMsg(1);
setPageNo(1);
}, [currentConversation]);
const modifyConversationName = async (name: string) => {
await updateConversationName(name, currentConversation!.chatId);
conversationRef?.current?.updateData();
window.history.replaceState('', '', `?q=${name}&cid=${currentConversation!.chatId}`);
};
const onSendMsg = async (
msg?: string,
list?: MessageItem[],
domainId?: number,
firstMsg?: boolean,
) => {
const currentMsg = msg || inputMsg;
if (currentMsg.trim() === '') {
setInputMsg('');
return;
}
let quote = '';
if (currentEntity && !firstMsg) {
const { data } = await queryContext(currentMsg, currentConversation!.chatId);
if (data.code === 200 && data.data.domainId === currentEntity.chatContext?.domainId) {
quote = getConversationContext(data.data);
}
}
setMessageList([
...(list || messageList),
{ id: uuid(), msg: currentMsg, domainId, type: MessageTypeEnum.QUESTION, quote },
]);
updateMessageContainerScroll();
setInputMsg('');
modifyConversationName(currentMsg);
};
const onInputMsgChange = (value: string) => {
const inputMsgValue = value || '';
setInputMsg(inputMsgValue);
};
const saveConversationToLocal = (conversation: ConversationDetailType) => {
if (conversation) {
if (conversation.chatId !== -1) {
localStorage.setItem('CONVERSATION_ID', `${conversation.chatId}`);
}
} else {
localStorage.removeItem('CONVERSATION_ID');
}
};
const onSelectConversation = (conversation: ConversationDetailType, name?: string) => {
window.history.replaceState('', '', `?q=${conversation.chatName}&cid=${conversation.chatId}`);
setCurrentConversation({
...conversation,
initMsg: name,
});
saveConversationToLocal(conversation);
};
const onMsgDataLoaded = (data: MsgDataType) => {
setCurrentEntity(data);
updateMessageContainerScroll();
};
const chatClass = classNames(styles.chat, {
[styles.external]: true,
[styles.mobile]: isMobile,
});
return (
<div className={chatClass}>
<Helmet title={WEB_TITLE} />
<div className={styles.topSection} />
<div className={styles.chatSection}>
{!isMobile && (
<Conversation
currentConversation={currentConversation}
onSelectConversation={onSelectConversation}
ref={conversationRef}
/>
)}
<div className={styles.chatApp}>
{currentConversation && (
<div className={styles.chatBody}>
<div className={styles.chatContent}>
<MessageContainer
id="messageContainer"
messageList={messageList}
chatId={currentConversation?.chatId}
onClickMessageContainer={() => {
inputFocus();
}}
onMsgDataLoaded={onMsgDataLoaded}
onSelectSuggestion={onSendMsg}
onUpdateMessageScroll={updateMessageContainerScroll}
/>
<ChatFooter
inputMsg={inputMsg}
chatId={currentConversation?.chatId}
onInputMsgChange={onInputMsgChange}
onSendMsg={(msg: string, domainId?: number) => {
onSendMsg(msg, messageList, domainId);
if (isMobile) {
inputBlur();
}
}}
ref={chatFooterRef}
/>
</div>
</div>
)}
</div>
{!isMobile && <RightSection currentEntity={currentEntity} />}
</div>
</div>
);
};
export default Chat;

View File

@@ -0,0 +1,22 @@
import { request } from 'umi';
const prefix = '/api';
export function saveConversation(chatName: string) {
return request<Result<any>>(`${prefix}/chat/manage/save?chatName=${chatName}`, { method: 'POST' });
}
export function updateConversationName(chatName: string, chatId: number = 0) {
return request<Result<any>>(
`${prefix}/chat/manage/updateChatName?chatName=${chatName}&chatId=${chatId}`,
{ method: 'POST' },
);
}
export function deleteConversation(chatId: number) {
return request<Result<any>>(`${prefix}/chat/manage/delete?chatId=${chatId}`, { method: 'POST' });
}
export function getAllConversations() {
return request<Result<any>>(`${prefix}/chat/manage/getAll`);
}

View File

@@ -0,0 +1,579 @@
.chat {
height: calc(100vh - 48px) !important;
overflow-y: hidden;
background: linear-gradient(180deg, rgba(23, 74, 228, 0) 29.44%, rgba(23, 74, 228, 0.06) 100%),
linear-gradient(90deg, #f3f3f7 0%, #f3f3f7 20%, #ebf0f9 60%, #f3f3f7 80%, #f3f3f7 100%);
&.external {
.chatApp {
width: calc(100vw - 450px) !important;
height: calc(100vh - 58px) !important;
}
}
&.mobile {
height: 100vh !important;
.chatSection {
// height: 100vh !important;
height: 100% !important;
}
.conversation {
// height: 100vh !important;
height: 100% !important;
}
.chatApp {
width: 100vw !important;
// height: 100vh !important;
height: 100% !important;
}
}
}
.chatSection {
display: flex;
height: calc(100vh - 48px) !important;
overflow-y: hidden;
}
.chatBody {
height: 100%;
}
.conversation {
position: relative;
width: 225px;
height: calc(100vh - 48px);
.leftSection {
width: 100%;
height: 100%;
}
}
.chatApp {
display: flex;
flex-direction: column;
width: calc(100vw - 510px);
height: calc(100vh - 58px) !important;
margin-top: 10px;
color: rgba(0, 0, 0, 0.87);
.emptyHolder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.navBar {
position: relative;
z-index: 10;
display: flex;
align-items: center;
height: 40px;
padding: 0 10px;
background: rgb(243 243 243);
border-bottom: 1px solid rgb(228, 228, 228);
.conversationNameWrapper {
display: flex;
align-items: center;
.conversationName {
padding: 4px 12px;
color: var(--text-color-third) !important;
font-size: 14px !important;
border-radius: 4px;
cursor: pointer;
.editIcon {
margin-left: 10px;
color: var(--text-color-fourth);
font-size: 14px;
}
&:hover {
background-color: rgba(0, 0, 0, 0.03);
}
}
.divider {
width: 1px;
height: 16px;
margin-right: 4px;
margin-left: 12px;
background-color: var(--text-color-fourth);
}
}
.conversationInput {
width: 300px;
color: var(--text-color-third) !important;
font-size: 14px !important;
cursor: default !important;
}
}
.chatBody {
display: flex;
flex: 1;
.chatContent {
display: flex;
flex-direction: column;
width: 100%;
.messageContainer {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow-x: hidden;
overflow-y: scroll;
.messageList {
display: flex;
flex-direction: column;
padding: 0 20px 90px 4px;
row-gap: 20px;
.messageItem {
display: flex;
flex-direction: column;
row-gap: 20px;
}
&.reportLoading {
position: absolute;
bottom: 10000px;
width: 100%;
}
}
}
}
}
}
.mobile {
.messageList {
padding: 0 12px 20px !important;
}
}
.keyword {
color: var(--primary-color);
}
.messageItem {
margin-top: 12px;
}
.messageTime {
display: flex;
align-items: center;
justify-content: center;
margin-top: 20px;
color: #999;
}
.modules {
display: flex;
align-items: center;
height: 40px;
padding: 8px 12px;
overflow: hidden;
background: rgb(243, 243, 243);
border-top: 1px solid rgb(228, 228, 228);
.moduleType {
width: 80px;
margin-right: 12px;
:global {
.ant-select-selection-item {
font-size: 13px !important;
}
.ant-select-selection-item,
.ant-select-single:not(.ant-select-customize-input) .ant-select-selector::after {
line-height: 28px !important;
}
}
.moduleSelect {
box-sizing: border-box;
width: 100%;
height: 100%;
color: rgba(0, 0, 0, 0.87);
word-break: break-all;
border: 0;
border-radius: 20px;
:global {
.ant-select-selector {
height: 30px !important;
border: 1px solid var(--primary-color) !important;
border-radius: 20px !important;
}
.ant-select-arrow {
margin-top: -4px !important;
}
}
}
}
.example {
margin-right: 4px;
// margin-left: 16px;
color: var(--text-color-secondary);
font-size: 13px;
}
:global {
button[ant-click-animating-without-extra-node]::after {
border: 0 none;
opacity: 0;
animation: none 0 ease 0 1 normal;
}
.iconBtn {
color: rgba(0, 0, 0, 0.4) !important;
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
.scrollerControlIcon {
font-size: 12px;
}
&:hover {
background: rgba(0, 0, 0, 0.05) !important;
}
}
}
.modulesInner {
display: flex;
flex: 1;
overflow-x: scroll;
overflow-y: hidden;
scroll-behavior: smooth;
&::-webkit-scrollbar {
display: none;
}
.moduleItem {
position: relative;
display: flex;
align-items: center;
margin-left: 8px;
padding: 4px 11px;
color: var(--text-color);
font-weight: 500;
font-size: 14px;
line-height: 1.43;
white-space: nowrap;
background: #fff;
border: 1px solid #fff;
border-radius: 20px;
cursor: pointer;
transition: 0.15s ease-in-out;
&:hover {
background: rgba(0, 0, 0, 0.05);
background-clip: padding-box;
border-color: rgba(0, 0, 0, 0.05);
}
&:first-child {
margin-left: 0 !important;
}
&.activeModuleItem {
color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
}
&.cmdItem {
font-weight: normal;
font-size: 13px;
}
}
}
}
.optGroupBar {
display: flex;
align-items: center;
justify-content: space-between;
// width: 530px;
&.recentSearchBar {
padding-top: 2px;
padding-bottom: 5px;
}
.optGroupTitle {
color: #333;
font-weight: 500;
font-size: 14px;
}
.recentSearch {
color: #999;
font-weight: normal;
}
.clearSearch {
color: #666;
font-size: 14px;
cursor: pointer;
}
}
.recentSearchOption {
padding-left: 12px !important;
.optionItem {
display: flex;
align-items: center;
justify-content: space-between;
width: 483px;
.removeRecentMsg {
display: none;
cursor: pointer;
}
}
&:hover {
.removeRecentMsg {
display: block;
}
}
}
.conversationList {
padding-top: 20px;
.conversationItem {
padding-left: 16px;
cursor: pointer;
.conversationItemContent {
display: flex;
align-items: center;
padding: 12px 0;
color: var(--text-color-third);
.conversationIcon {
margin-right: 10px;
color: var(--text-color-fourth);
font-size: 20px;
}
.conversationContent {
width: 160px;
overflow: hidden;
color: var(--text-color-third);
white-space: nowrap;
text-overflow: ellipsis;
}
}
&.activeConversationItem,
&:hover {
.conversationContent {
color: var(--chat-blue);
}
}
}
}
.addConversation {
display: flex;
align-items: center;
margin-left: 12px;
color: var(--text-color-third);
column-gap: 4px;
cursor: pointer;
&:hover {
color: var(--primary-color);
}
}
.loadingWords {
padding: 40px 1px;
}
.associateWordsOption {
display: flex;
align-items: center;
column-gap: 10px;
.optionContent {
display: flex;
align-items: center;
min-width: 450px;
column-gap: 10px;
}
.indicatorItem {
min-width: 180px;
.indicatorLabel {
color: var(--text-color-fourth);
font-size: 12px;
}
.indicatorValue {
margin-left: 4px;
font-size: 13px;
}
}
}
.collapseBtn {
margin: 0 10px;
color: var(--text-color-third);
font-size: 16px;
cursor: pointer;
&:hover {
color: var(--primary-color);
}
}
.autoCompleteDropdown {
width: 650px !important;
min-width: 650px !important;
border-radius: 10px;
:global {
.ant-select-item {
min-height: 36px !important;
line-height: 26px !important;
&:not(:first-child):hover {
background: #f5f5f5 !important;
}
}
// .ant-select-item-option-active:not(.ant-select-item-option-disabled) {
// background-color: #fff;
// }
}
}
.recommendItemTitle {
margin-right: 14px;
padding: 4px 12px;
background-color: var(--deep-background);
border-radius: 2px;
}
.refeshQuestions {
cursor: pointer;
.reloadIcon {
margin-right: 4px;
}
}
.recommendQuestions {
display: flex;
align-items: center;
justify-content: center;
width: 54px;
height: 100%;
color: var(--text-color-fourth);
font-size: 18px;
background: rgba(255, 255, 255, 0.2);
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
.currentTool {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 0 24px 0 2px;
color: var(--chat-blue);
font-weight: 500;
font-size: 16px;
background: rgba(255, 255, 255, 0.2);
.removeTool {
position: absolute;
top: 14px;
right: 6px;
color: var(--text-color-fifth);
font-size: 14px;
cursor: pointer;
&:hover {
color: var(--chat-blue);
}
}
}
.associateOption {
display: flex;
align-items: center;
}
.associateOptionAvatar {
width: 32px;
margin-right: 10px;
}
.optionContent {
min-width: 330px;
}
.optionIndicator {
min-width: 120px;
margin-left: 4px;
color: var(--text-color-fourth);
font-size: 12px;
}
.operateSection {
margin-top: 20px;
padding-left: 15px;
}
.operateItem {
display: flex;
align-items: center;
padding: 10px 0;
cursor: pointer;
.operateIcon {
margin-right: 10px;
color: var(--text-color-fourth);
font-size: 20px;
}
.operateLabel {
color: var(--text-color-third);
font-size: 14px;
}
&:hover {
.operateLabel {
color: var(--chat-blue);
}
}
}
.messageLoading {
margin-top: 30px;
}

View File

@@ -0,0 +1,34 @@
import { MsgDataType } from 'supersonic-chat-sdk';
export enum MessageTypeEnum {
TEXT = 'text', // 指标文本
QUESTION = 'question',
TAG = 'tag', // 标签
SUGGESTION = 'suggestion', // 建议
NO_PERMISSION = 'no_permission', // 无权限
SEMANTIC_DETAIL = 'semantic_detail', // 语义指标/维度等信息详情
}
export type MessageItem = {
id: string | number;
type?: MessageTypeEnum;
msg?: string;
domainId?: number;
msgData?: MsgDataType;
quote?: string;
};
export type ConversationDetailType = {
chatId: number;
chatName: string;
createTime?: string;
creator?: string;
lastQuestion?: string;
lastTime?: string;
initMsg?: string;
domainId?: number;
};
export enum MessageModeEnum {
INTERPRET = 'interpret',
}

View File

@@ -0,0 +1,16 @@
import { ChatContextType } from 'supersonic-chat-sdk';
import moment from 'moment';
export function getConversationContext(chatContext: ChatContextType) {
if (!chatContext) return '';
const { domainName, metrics, dateInfo } = chatContext;
// const dimensionStr =
// dimensions?.length > 0 ? dimensions.map((dimension) => dimension.name).join('、') : '';
const timeStr =
dateInfo?.text ||
`${moment(dateInfo?.endDate).diff(moment(dateInfo?.startDate), 'days') + 1}`;
return `${domainName}${
metrics?.length > 0 ? `${timeStr}${metrics.map((metric) => metric.name).join('、')}` : ''
}`;
}

View File

@@ -0,0 +1,79 @@
import React, { useState } from 'react';
import { Form, Button, Modal, Input } from 'antd';
import type { RegisterFormDetail } from './types';
export type RegisterFormProps = {
onCancel: () => void;
onSubmit: (values: RegisterFormDetail) => Promise<any>;
createModalVisible: boolean;
};
const formLayout = {
labelCol: { span: 4 },
wrapperCol: { span: 18 },
};
const { Item } = Form;
const ForgetPwdForm: React.FC<RegisterFormProps> = (props) => {
const [formVals, setFormVals] = useState<Partial<RegisterFormDetail>>({
email: '', // 邮箱
});
const [saveLoading, setSaveLoading] = useState(false);
const [form] = Form.useForm();
const { onSubmit: handleUpdate, onCancel, createModalVisible } = props;
const handleSubmit = async () => {
const fieldsValue = await form.validateFields();
setFormVals({ ...formVals, ...fieldsValue });
setSaveLoading(true);
const formValues = {
...formVals,
...fieldsValue,
};
try {
await handleUpdate(formValues);
setSaveLoading(false);
} catch (error) {
setSaveLoading(false);
}
};
const renderFooter = () => {
return (
<>
<Button onClick={onCancel}></Button>
<Button type="primary" loading={saveLoading} onClick={handleSubmit}>
</Button>
</>
);
};
return (
<Modal
width={600}
bodyStyle={{ padding: '32px 40px 48px' }}
destroyOnClose
title="忘记密码"
open={createModalVisible}
footer={renderFooter()}
onCancel={onCancel}
>
<Form
{...formLayout}
form={form}
initialValues={{
...formVals,
}}
>
<Item name="email" rules={[{ required: true }]} label="邮箱地址">
<Input size="large" type="email" placeholder="请输入邮箱地址" />
</Item>
</Form>
</Modal>
);
};
export default ForgetPwdForm;

View File

@@ -0,0 +1,87 @@
import React, { useState } from 'react';
import { Form, Button, Modal, Input } from 'antd';
import type { RegisterFormDetail } from './types';
export type RegisterFormProps = {
onCancel: () => void;
onSubmit: (values: RegisterFormDetail) => Promise<any>;
createModalVisible: boolean;
};
const formLayout = {
labelCol: { span: 4 },
wrapperCol: { span: 18 },
};
const { Item } = Form;
const RegisterForm: React.FC<RegisterFormProps> = (props) => {
const [formVals, setFormVals] = useState<Partial<RegisterFormDetail>>({
name: '', // 名称
password: '', // 密码
});
const [saveLoading, setSaveLoading] = useState(false);
const [form] = Form.useForm();
const { onSubmit: handleUpdate, onCancel, createModalVisible } = props;
const handleSubmit = async () => {
const fieldsValue = await form.validateFields();
setFormVals({ ...formVals, ...fieldsValue });
setSaveLoading(true);
const formValus = {
...formVals,
...fieldsValue,
};
try {
await handleUpdate(formValus);
setSaveLoading(false);
} catch (error) {
setSaveLoading(false);
}
};
const renderFooter = () => {
return (
<>
<Button onClick={onCancel}></Button>
<Button type="primary" loading={saveLoading} onClick={handleSubmit}>
</Button>
</>
);
};
return (
<Modal
width={600}
bodyStyle={{ padding: '32px 40px 48px' }}
destroyOnClose
title="用户注册"
open={createModalVisible}
footer={renderFooter()}
onCancel={onCancel}
>
<Form
{...formLayout}
form={form}
initialValues={{
...formVals,
}}
>
<Item name="name" rules={[{ required: true }]} label="账号">
<Input size="large" placeholder="请输入账号" />
</Item>
<Item name="password" rules={[{ required: true, min: 6, max: 10 }]} label="密码">
<Input size="large" type="password" placeholder="请输入密码" />
</Item>
{/* <Item name="email" rules={[{ required: true, type: 'email' }]} label="邮箱地址">
<Input size="large" type="email" placeholder="请输入邮箱地址" />
</Item> */}
</Form>
</Modal>
);
};
export default RegisterForm;

View File

@@ -0,0 +1,9 @@
export type RegisterFormDetail = {
password: string; // 密码
name: string; // 用户名
};
export type ResetPasswordFormDetail = {
password: string;
passwordConfirm: string;
};

View File

@@ -0,0 +1,137 @@
// import type { FC } from 'react';
import styles from './style.less';
import { Button, Form, Input, message, Space } from 'antd';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import RegisterForm from './components/RegisterForm';
// import ForgetPwdForm from './components/ForgetPwdForm';
import S2Icon, { ICON } from '@/components/S2Icon';
import React, { useState } from 'react';
import { useForm } from 'antd/lib/form/Form';
import type { RegisterFormDetail } from './components/types';
import { postUserLogin, userRegister } from './services';
import { AUTH_TOKEN_KEY } from '@/common/constants';
import { queryCurrentUser } from '@/services/user';
import { history, useModel } from 'umi';
import { setToken as setChatSdkToken } from 'supersonic-chat-sdk';
const { Item } = Form;
const LoginPage: React.FC = () => {
const [createModalVisible, setCreateModalVisible] = useState<boolean>(false);
// const [forgetModalVisible, setForgetModalVisible] = useState<boolean>(false);
const [form] = useForm();
const { initialState = {}, setInitialState } = useModel('@@initialState');
// 通过用户信息进行登录
const loginDone = async (values: RegisterFormDetail) => {
const { code, data, msg } = await postUserLogin(values);
if (code === 200) {
localStorage.setItem(AUTH_TOKEN_KEY, data);
setChatSdkToken(data || '');
const { code: queryUserCode, data: queryUserData } = await queryCurrentUser();
if (queryUserCode === 200) {
const currentUser = {
...queryUserData,
staffName: queryUserData.staffName || queryUserData.name,
};
setInitialState({ ...initialState, currentUser });
}
history.push('/');
return;
}
message.success(msg);
};
// 处理登录按钮响应
const handleLogin = async () => {
const { validateFields } = form;
const content = await validateFields();
await loginDone(content);
};
// 处理注册弹窗确定按钮
const handleRegister = async (values: RegisterFormDetail) => {
const { code } = await userRegister({ ...values });
if (code === 200) {
message.success('注册成功');
setCreateModalVisible(false);
// 注册完自动帮用户登录
await loginDone(values);
}
};
// 相应注册按钮
const handleRegisterBtn = () => {
setCreateModalVisible(true);
};
// // 忘记密码弹窗确定响应
// const handleForgetPwd = async (values: RegisterFormDetail) => {
// await getUserForgetPwd({ ...values });
// message.success('发送邮件成功,请在收到邮件后进入邮件链接进行密码重置');
// setForgetModalVisible(false);
// };
// // 响应忘记密码按钮
// const handleForgetPwdBtn = () => {
// setForgetModalVisible(true);
// };
return (
<div className={styles.loginWarp}>
<div className={styles.content}>
<div className={styles.formContent}>
<div className={styles.formBox}>
<Form form={form} labelCol={{ span: 6 }} colon={false}>
<div className={styles.loginMain}>
<h3 className={styles.title}>
<Space>
<S2Icon
icon={ICON.iconlogobiaoshi}
size={30}
color="#296DF3"
style={{ display: 'inline-block', marginTop: 8 }}
/>
<div>(SuperSonic)</div>
</Space>
</h3>
<Item name="name" rules={[{ required: true }]} label="">
<Input size="large" placeholder="用户名: admin" prefix={<UserOutlined />} />
</Item>
<Item name="password" rules={[{ required: true }]} label="">
<Input
size="large"
type="password"
placeholder="密码: admin"
onPressEnter={handleLogin}
prefix={<LockOutlined />}
/>
</Item>
<Button className={styles.signInBtn} type="primary" onClick={handleLogin}>
</Button>
<div className={styles.tool}>
<Button className={styles.button} onClick={handleRegisterBtn}>
</Button>
{/* <Button className={styles.button} type="link" onClick={handleForgetPwdBtn}>
忘记密码
</Button> */}
</div>
</div>
</Form>
</div>
</div>
</div>
<RegisterForm
onCancel={() => {
setCreateModalVisible(false);
}}
onSubmit={handleRegister}
createModalVisible={createModalVisible}
/>
</div>
);
};
export default LoginPage;

View File

@@ -0,0 +1,29 @@
import request from 'umi-request';
export interface PostUserLoginRes {
code: string; // 返回编码
msg: string; // 返回消息
data: string;
traceId: string;
}
export interface PostUserRegesiterRes {
code: string; // 返回编码
msg: string; // 返回消息
data: never;
traceId: string;
}
export function userRegister(data: any): Promise<any> {
return request(`${process.env.AUTH_API_BASE_URL}user/register`, {
method: 'POST',
data,
});
}
export function postUserLogin(data: any): Promise<any> {
return request(`${process.env.AUTH_API_BASE_URL}user/login`, {
method: 'POST',
data,
});
}

View File

@@ -0,0 +1,61 @@
@import '~@/assets/css/variable.less';
.loginWarp {
display: flex;
flex-direction: column;
height: 100vh;
overflow: auto;
.content {
flex: 1;
padding: 32px 0 24px;
.formContent {
display: flex;
flex: 1 1;
flex-direction: column;
height: 100%;
padding: 132px 0 24px;
overflow: auto;
background: inherit;
.formBox {
min-width: 480px;
max-width: 500px;
margin: 0 auto;
}
}
}
}
.loginMain {
// max-width: 480px;
// min-height: 200px;
margin: 120px auto auto;
padding: 20px;
background: #fff;
border-radius: 5px;
box-shadow: 0 0 10px 2px #eee;
}
.title {
margin-bottom: 20px;
font-size: 24px;
text-align: center;
}
.input {
margin-bottom: 20px;
}
.signInBtn {
width: 100%;
margin: 20px 0;
height: 40px;
}
.tool {
display: flex;
flex-direction: row-reverse;
}
.button {
margin-left: 10px;
}

View File

@@ -0,0 +1,116 @@
import { Tabs } from 'antd';
import React, { useEffect, useState } from 'react';
import { connect, Helmet } from 'umi';
import ProjectListTree from './components/ProjectList';
import EntitySection from './components/Entity/EntitySection';
import styles from './components/style.less';
import type { StateType } from './model';
import { RightOutlined, LeftOutlined } from '@ant-design/icons';
import SplitPane from 'react-split-pane';
import Pane from 'react-split-pane/lib/Pane';
import type { Dispatch } from 'umi';
const { TabPane } = Tabs;
type Props = {
domainManger: StateType;
dispatch: Dispatch;
};
const DEFAULT_LEFT_SIZE = '300px';
const ChatSetting: React.FC<Props> = ({ domainManger, dispatch }) => {
window.RUNNING_ENV = 'chat';
const [collapsed, setCollapsed] = useState(false);
const [leftSize, setLeftSize] = useState('');
const { selectDomainId, selectDomainName } = domainManger;
useEffect(() => {
const semanticLeftCollapsed = localStorage.getItem('semanticLeftCollapsed');
const semanticLeftSize =
semanticLeftCollapsed === 'true' ? '0px' : localStorage.getItem('semanticLeftSize');
setCollapsed(semanticLeftCollapsed === 'true');
setLeftSize(semanticLeftSize || DEFAULT_LEFT_SIZE);
}, []);
useEffect(() => {
if (selectDomainId) {
dispatch({
type: 'domainManger/queryDimensionList',
payload: {
domainId: selectDomainId,
},
});
dispatch({
type: 'domainManger/queryMetricList',
payload: {
domainId: selectDomainId,
},
});
}
}, [selectDomainId]);
const onCollapse = () => {
const collapsedValue = !collapsed;
setCollapsed(collapsedValue);
localStorage.setItem('semanticLeftCollapsed', String(collapsedValue));
const semanticLeftSize = collapsedValue ? '0px' : localStorage.getItem('semanticLeftSize');
const sizeValue = parseInt(semanticLeftSize || '0');
if (!collapsedValue && sizeValue <= 10) {
setLeftSize(DEFAULT_LEFT_SIZE);
localStorage.setItem('semanticLeftSize', DEFAULT_LEFT_SIZE);
} else {
setLeftSize(semanticLeftSize || DEFAULT_LEFT_SIZE);
}
};
useEffect(() => {
const width = document.getElementById('tab');
const switchWarpper: any = document.getElementById('switch');
if (width && switchWarpper) {
switchWarpper.style.width = width.offsetWidth * 0.77 + 'px';
}
});
return (
<div className={styles.projectBody}>
<Helmet title={'问答设置-超音数'} />
<SplitPane
split="vertical"
onChange={(size) => {
localStorage.setItem('semanticLeftSize', size[0]);
setLeftSize(size[0]);
}}
>
<Pane initialSize={leftSize || DEFAULT_LEFT_SIZE}>
<div className={styles.menu}>
<ProjectListTree createDomainBtnVisible={false} queryService="chat" />
</div>
</Pane>
<div className={styles.projectManger}>
<div className={styles.collapseLeftBtn} onClick={onCollapse}>
{collapsed ? <RightOutlined /> : <LeftOutlined />}
</div>
<h2 className={styles.title}>
{selectDomainName ? `选择的主题域:${selectDomainName}` : '主题域信息'}
</h2>
{selectDomainId ? (
<>
<Tabs className={styles.tab} defaultActiveKey="chatSetting" destroyInactiveTabPane>
<TabPane className={styles.tabPane} tab="问答设置" key="chatSetting">
<EntitySection />
</TabPane>
</Tabs>
</>
) : (
<h2 className={styles.mainTip}></h2>
)}
</div>
</SplitPane>
</div>
);
};
export default connect(({ domainManger }: { domainManger: StateType }) => ({
domainManger,
}))(ChatSetting);

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { Form, Input, Spin } from 'antd';
import type { FormInstance } from 'antd/lib/form';
const FormItem = Form.Item;
const { TextArea } = Input;
type Props = {
isEdit?: boolean;
form: FormInstance<any>;
tableLoading?: boolean;
};
const DataSourceBasicForm: React.FC<Props> = ({ isEdit, tableLoading = false }) => {
return (
<Spin spinning={tableLoading}>
<FormItem
name="name"
label="数据源中文名"
rules={[{ required: true, message: '请输入数据源中文名' }]}
>
<Input placeholder="名称不可重复" />
</FormItem>
<FormItem
name="bizName"
label="数据源英文名"
rules={[{ required: true, message: '请输入数据源英文名' }]}
>
<Input placeholder="名称不可重复" disabled={isEdit} />
</FormItem>
<FormItem name="description" label="数据源描述">
<TextArea placeholder="请输入数据源描述" />
</FormItem>
</Spin>
);
};
export default DataSourceBasicForm;

View File

@@ -0,0 +1,291 @@
import React, { useEffect, useRef, useState } from 'react';
import { Form, Button, Modal, Steps, message } from 'antd';
import BasicInfoForm from './DataSourceBasicForm';
import FieldForm from './DataSourceFieldForm';
import { formLayout } from '@/components/FormHelper/utils';
import { EnumDataSourceType } from '../constants';
import type { DataInstanceItem, FieldItem, SaveDataSetForm } from '../data';
import styles from '../style.less';
import { createDatasource, updateDatasource } from '../../service';
export type CreateFormProps = {
createModalVisible: boolean;
sql: string;
domainId: number;
dataSourceItem: DataInstanceItem | any;
onCancel?: () => void;
onSubmit?: (dataSourceInfo: any) => void;
scriptColumns: any[];
};
const { Step } = Steps;
const initFormVal = {
name: '', // 数据源名称
bizName: '', // 数据源英文名
description: '', // 数据源描述
};
const DataSourceCreateForm: React.FC<CreateFormProps> = ({
onCancel,
createModalVisible,
domainId,
scriptColumns,
sql,
onSubmit,
dataSourceItem,
}) => {
const isEdit = !!dataSourceItem?.id;
const [fields, setFields] = useState<FieldItem[]>([]);
const [currentStep, setCurrentStep] = useState(0);
const [saveLoading, setSaveLoading] = useState(false);
const formValRef = useRef(initFormVal as any);
const [form] = Form.useForm();
const updateFormVal = (val: SaveDataSetForm) => {
formValRef.current = val;
};
const forward = () => setCurrentStep(currentStep + 1);
const backward = () => setCurrentStep(currentStep - 1);
const getFieldsClassify = (fieldsList: FieldItem[]) => {
const classify = fieldsList.reduce(
(fieldsClassify, item: FieldItem) => {
const {
type,
bizName,
timeGranularity,
agg,
isCreateDimension,
name,
isCreateMetric,
dateFormat,
} = item;
switch (type) {
case EnumDataSourceType.CATEGORICAL:
fieldsClassify.dimensions.push({
bizName,
type,
isCreateDimension,
name,
});
break;
case EnumDataSourceType.TIME:
fieldsClassify.dimensions.push({
bizName,
type,
isCreateDimension,
name,
dateFormat,
typeParams: {
isPrimary: true,
timeGranularity,
},
});
break;
case EnumDataSourceType.FOREIGN:
case EnumDataSourceType.PRIMARY:
fieldsClassify.identifiers.push({
bizName,
name,
type,
});
break;
case EnumDataSourceType.MEASURES:
fieldsClassify.measures.push({
bizName,
type,
agg,
name,
isCreateMetric,
});
break;
default:
break;
}
return fieldsClassify;
},
{
identifiers: [],
dimensions: [],
measures: [],
} as any,
);
return classify;
};
const handleNext = async () => {
const fieldsValue = await form.validateFields();
const fieldsClassify = getFieldsClassify(fields);
const submitForm = {
...formValRef.current,
...fieldsValue,
...fieldsClassify,
};
updateFormVal(submitForm);
if (currentStep < 1) {
forward();
} else {
setSaveLoading(true);
const queryParams = {
...submitForm,
sqlQuery: sql,
databaseId: dataSourceItem.databaseId,
queryType: 'sql_query',
domainId,
};
const queryDatasource = isEdit ? updateDatasource : createDatasource;
const { code, msg, data } = await queryDatasource(queryParams);
setSaveLoading(false);
if (code === 200) {
message.success('保存数据源成功!');
onSubmit?.({
...queryParams,
...data,
resData: data,
});
return;
}
message.error(msg);
}
};
const initFields = (fieldsClassifyList: any[]) => {
const columnFields: any[] = scriptColumns.map((item: any) => {
const { type, nameEn } = item;
const oldItem = fieldsClassifyList.find((oItem) => oItem.bizName === item.nameEn) || {};
return {
...oldItem,
bizName: nameEn,
// name,
sqlType: type,
};
});
setFields(columnFields || []);
};
const formatterMeasures = (measuresList: any[] = []) => {
return measuresList.map((measures: any) => {
return {
...measures,
type: EnumDataSourceType.MEASURES,
};
});
};
const formatterDimensions = (dimensionsList: any[] = []) => {
return dimensionsList.map((dimension: any) => {
const { typeParams } = dimension;
return {
...dimension,
timeGranularity: typeParams?.timeGranularity || '',
};
});
};
const initData = () => {
const { id, name, bizName, description, datasourceDetail } = dataSourceItem as any;
const initValue = {
id,
name,
bizName,
description,
};
const editInitFormVal = {
...formValRef.current,
...initValue,
};
updateFormVal(editInitFormVal);
form.setFieldsValue(initValue);
const { dimensions, identifiers, measures } = datasourceDetail;
const formatFields = [
...formatterDimensions(dimensions || []),
...(identifiers || []),
...formatterMeasures(measures || []),
];
initFields(formatFields);
};
useEffect(() => {
if (isEdit) {
initData();
} else {
initFields([]);
}
}, [dataSourceItem]);
const handleFieldChange = (fieldName: string, data: any) => {
const result = fields.map((field) => {
if (field.bizName === fieldName) {
return {
...field,
...data,
};
}
return {
...field,
};
});
setFields(result);
};
const renderContent = () => {
if (currentStep === 1) {
return <FieldForm fields={fields} onFieldChange={handleFieldChange} />;
}
return <BasicInfoForm form={form} isEdit={isEdit} />;
};
const renderFooter = () => {
if (currentStep === 1) {
return (
<>
<Button style={{ float: 'left' }} onClick={backward}>
</Button>
<Button onClick={onCancel}></Button>
<Button type="primary" loading={saveLoading} onClick={handleNext}>
</Button>
</>
);
}
return (
<>
<Button onClick={onCancel}></Button>
<Button type="primary" onClick={handleNext}>
</Button>
</>
);
};
return (
<Modal
forceRender
width={1300}
bodyStyle={{ padding: '32px 40px 48px' }}
destroyOnClose
title={`${isEdit ? '编辑' : '新建'}数据源`}
maskClosable={false}
open={createModalVisible}
footer={renderFooter()}
onCancel={onCancel}
>
<Steps style={{ marginBottom: 28 }} size="small" current={currentStep}>
<Step title="基本信息" />
<Step title="字段信息" />
</Steps>
<Form
{...formLayout}
form={form}
initialValues={{
...formValRef.current,
}}
className={styles.form}
>
{renderContent()}
</Form>
</Modal>
);
};
export default DataSourceCreateForm;

View File

@@ -0,0 +1,196 @@
import React from 'react';
import { Table, Select, Checkbox, Input } from 'antd';
import type { FieldItem } from '../data';
import { isUndefined } from 'lodash';
import { TYPE_OPTIONS, DATE_FORMATTER, AGG_OPTIONS, EnumDataSourceType } from '../constants';
type Props = {
fields: FieldItem[];
onFieldChange: (fieldName: string, data: Partial<FieldItem>) => void;
};
const { Option } = Select;
const FieldForm: React.FC<Props> = ({ fields, onFieldChange }) => {
const handleFieldChange = (record: FieldItem, fieldName: string, value: any) => {
onFieldChange(record.bizName, {
...record,
[fieldName]: value,
});
};
const columns = [
{
title: '字段名称',
dataIndex: 'bizName',
width: 100,
},
{
title: '数据类型',
dataIndex: 'sqlType',
width: 80,
},
{
title: '字段类型',
dataIndex: 'type',
width: 100,
render: (_: any, record: FieldItem) => {
const type = fields.find((field) => field.bizName === record.bizName)?.type;
return (
<Select
placeholder="字段类型"
value={type}
onChange={(value) => {
let defaultParams = {};
if (value === EnumDataSourceType.MEASURES) {
defaultParams = {
agg: AGG_OPTIONS[0].value,
};
} else if (value === EnumDataSourceType.TIME) {
defaultParams = {
dateFormat: DATE_FORMATTER[0],
timeGranularity: 'day',
};
} else {
defaultParams = {
agg: undefined,
dateFormat: undefined,
timeGranularity: undefined,
};
}
// handleFieldChange(record, 'type', value);
onFieldChange(record.bizName, {
...record,
type: value,
...defaultParams,
});
}}
style={{ width: '100%' }}
>
{TYPE_OPTIONS.map((item) => (
<Option key={item.label} value={item.value}>
{item.label}
</Option>
))}
</Select>
);
},
},
{
title: '扩展配置',
dataIndex: 'extender',
width: 100,
render: (_: any, record: FieldItem) => {
const { type } = record;
if (type === EnumDataSourceType.MEASURES) {
const agg = fields.find((field) => field.bizName === record.bizName)?.agg;
return (
<Select
placeholder="度量算子"
value={agg}
onChange={(value) => {
handleFieldChange(record, 'agg', value);
}}
defaultValue={AGG_OPTIONS[0].value}
style={{ width: '100%' }}
>
{AGG_OPTIONS.map((item) => (
<Option key={item.value} value={item.value}>
{item.label}
</Option>
))}
</Select>
);
}
if (type === EnumDataSourceType.TIME) {
const dateFormat = fields.find((field) => field.bizName === record.bizName)?.dateFormat;
return (
<Select
placeholder="时间格式"
value={dateFormat}
onChange={(value) => {
handleFieldChange(record, 'dateFormat', value);
}}
defaultValue={DATE_FORMATTER[0]}
style={{ width: '100%' }}
>
{DATE_FORMATTER.map((item) => (
<Option key={item} value={item}>
{item}
</Option>
))}
</Select>
);
}
return <></>;
},
},
{
title: '快速创建',
dataIndex: 'fastCreate',
width: 100,
render: (_: any, record: FieldItem) => {
const { type, name } = record;
if (
[
EnumDataSourceType.PRIMARY,
EnumDataSourceType.FOREIGN,
EnumDataSourceType.CATEGORICAL,
EnumDataSourceType.TIME,
EnumDataSourceType.MEASURES,
].includes(type as EnumDataSourceType)
) {
const isCreateName = [EnumDataSourceType.CATEGORICAL, EnumDataSourceType.TIME].includes(
type as EnumDataSourceType,
)
? 'isCreateDimension'
: 'isCreateMetric';
const editState = !isUndefined(record[isCreateName]) ? !!record[isCreateName] : true;
return (
<Checkbox
checked={editState}
onChange={(e) => {
const value = e.target.checked ? 1 : 0;
if (!value) {
onFieldChange(record.bizName, {
...record,
name: '',
[isCreateName]: value,
});
} else {
handleFieldChange(record, isCreateName, value);
}
}}
>
<Input
value={name}
disabled={!editState}
onChange={(e) => {
const value = e.target.value;
handleFieldChange(record, 'name', value);
}}
placeholder="请输入中文名"
/>
</Checkbox>
);
}
return <></>;
},
},
];
return (
<>
<Table<FieldItem>
dataSource={fields}
columns={columns}
className="fields-table"
rowKey="bizName"
pagination={false}
scroll={{ y: 500 }}
/>
</>
);
};
export default FieldForm;

View File

@@ -0,0 +1,530 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button, Table, message, Tooltip, Space, Dropdown } from 'antd';
import SplitPane from 'react-split-pane';
import Pane from 'react-split-pane/lib/Pane';
import sqlFormatter from 'sql-formatter';
import {
FullscreenOutlined,
WarningOutlined,
EditOutlined,
PlayCircleTwoTone,
SwapOutlined,
PlayCircleOutlined,
CloudServerOutlined,
} from '@ant-design/icons';
import { isFunction } from 'lodash';
import FullScreen from '@/components/FullScreen';
import SqlEditor from '@/components/SqlEditor';
import type { TaskResultParams, TaskResultItem, DataInstanceItem, TaskResultColumn } from '../data';
import { excuteSql } from '../service';
import { getDatabaseByDomainId } from '../../service';
import DataSourceCreateForm from './DataSourceCreateForm';
import styles from '../style.less';
import 'ace-builds/src-min-noconflict/ext-searchbox';
import 'ace-builds/src-min-noconflict/theme-sqlserver';
import 'ace-builds/src-min-noconflict/theme-monokai';
import 'ace-builds/src-min-noconflict/mode-sql';
type IProps = {
oprType: 'add' | 'edit';
dataSourceItem: DataInstanceItem;
domainId: number;
onUpdateSql?: (sql: string) => void;
sql?: string;
onSubmitSuccess?: (dataSourceInfo: any) => void;
onJdbcSourceChange?: (jdbcId: number) => void;
};
type ResultTableItem = Record<string, any>;
type ResultColItem = {
key: string;
title: string;
dataIndex: string;
};
type ScreenSize = 'small' | 'middle' | 'large';
type JdbcSourceItems = {
label: string;
key: number;
};
const SqlDetail: React.FC<IProps> = ({
dataSourceItem,
onSubmitSuccess,
domainId,
sql = '',
onUpdateSql,
onJdbcSourceChange,
}) => {
const [resultTable, setResultTable] = useState<ResultTableItem[]>([]);
const [resultTableLoading, setResultTableLoading] = useState(false);
const [resultCols, setResultCols] = useState<ResultColItem[]>([]);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 20,
total: 0,
});
const [jdbcSourceItems, setJdbcSourceItems] = useState<JdbcSourceItems[]>([]);
const [dataSourceModalVisible, setDataSourceModalVisible] = useState(false);
const [tableScroll, setTableScroll] = useState({
scrollToFirstRowOnChange: true,
x: '100%',
y: 200,
});
// const [dataSourceResult, setDataSourceResult] = useState<any>({});
const [runState, setRunState] = useState<boolean | undefined>();
const [taskLog, setTaskLog] = useState('');
const [isSqlExcLocked, setIsSqlExcLocked] = useState(false);
const [screenSize, setScreenSize] = useState<ScreenSize>('middle');
const [isSqlIdeFullScreen, setIsSqlIdeFullScreen] = useState<boolean>(false);
const [isSqlResFullScreen, setIsSqlResFullScreen] = useState<boolean>(false);
// const [sqlParams, setSqlParams] = useState<SqlParamsItem[]>([]);
const resultInnerWrap = useRef<HTMLDivElement>();
const [editorSize, setEditorSize] = useState<number>(0);
const DEFAULT_FULLSCREEN_TOP = 0;
const [partialSql, setPartialSql] = useState('');
const [isPartial, setIsPartial] = useState(false);
const [isRight, setIsRight] = useState(false);
const [scriptColumns, setScriptColumns] = useState<any[]>([]);
// const [jdbcSourceName, setJdbcSourceName] = useState<string>(() => {
// const sourceId = dataSourceItem.databaseId;
// if (sourceId) {
// const target: any = jdbcSourceItems.filter((item: any) => {
// return item.key === Number(sourceId);
// })[0];
// if (target) {
// return target.label;
// }
// }
// return 'ClickHouse';
// });
const queryDatabaseConfig = async () => {
const { code, data } = await getDatabaseByDomainId(domainId);
if (code === 200) {
setJdbcSourceItems([
{
label: data?.name,
key: data?.id,
},
]);
onJdbcSourceChange?.(data?.id && Number(data?.id));
return;
}
message.error('数据库配置获取错误');
};
function creatCalcItem(key: string, data: string) {
const line = document.createElement('div'); // 需要每条数据一行,这样避免数据换行的时候获得的宽度不准确
const child = document.createElement('span');
child.classList.add(`resultCalcItem_${key}`);
child.innerText = data;
line.appendChild(child);
return line;
}
// 计算每列的宽度,通过容器插入文档中动态得到该列数据(包括表头)的最长宽度,设为列宽度,保证每列的数据都能一行展示完
function getKeyWidthMap(list: TaskResultItem[]): TaskResultItem {
const widthMap = {};
const container = document.createElement('div');
container.id = 'resultCalcWrap';
container.style.position = 'fixed';
container.style.left = '-99999px';
container.style.top = '-99999px';
container.style.width = '19999px';
container.style.fontSize = '12px';
list.forEach((item, index) => {
if (index === 0) {
Object.keys(item).forEach((key, keyIndex) => {
// 因为key可能存在一些特殊字符导致querySelectorAll获取的时候报错所以用keyIndex(而不用key)拼接className
container.appendChild(creatCalcItem(`${keyIndex}`, key));
container.appendChild(creatCalcItem(`${keyIndex}`, `${item[key]}`));
});
} else {
Object.keys(item).forEach((key, keyIndex) => {
container.appendChild(creatCalcItem(`${keyIndex}`, `${item[key]}`));
});
}
});
document.body.appendChild(container);
Object.keys(list[0]).forEach((key, keyIndex) => {
// 因为key可能存在一些特殊字符导致querySelectorAll获取的时候报错所以用keyIndex(而不用key)拼接className
const widthArr = Array.from(container.querySelectorAll(`.resultCalcItem_${keyIndex}`)).map(
(node: any) => node.offsetWidth,
);
widthMap[key] = Math.max(...widthArr);
});
document.body.removeChild(container);
return widthMap;
}
const updateResultCols = (list: TaskResultItem[], columns: TaskResultColumn[]) => {
if (list.length) {
const widthMap = getKeyWidthMap(list);
const cols = columns.map(({ nameEn }) => {
return {
key: nameEn,
title: nameEn,
dataIndex: nameEn,
width: `${(widthMap[nameEn] as number) + 22}px`, // 字宽度 + 20px(比左右padding宽几像素作为一个buffer值)
};
});
setResultCols(cols);
}
};
const fetchTaskResult = (params: TaskResultParams) => {
setResultTable(
params.resultList.map((item, index) => {
return {
...item,
index,
};
}),
);
setPagination({
current: 1,
pageSize: 20,
total: params.resultList.length,
});
setScriptColumns(params.columns);
updateResultCols(params.resultList, params.columns);
};
const changePaging = (paging: Pagination) => {
setPagination({
...pagination,
...paging,
});
};
const onSqlChange = (sqlString: string) => {
if (onUpdateSql && isFunction(onUpdateSql)) {
onUpdateSql(sqlString);
}
};
const formatSQL = () => {
const sqlvalue = sqlFormatter.format(sql);
if (onUpdateSql && isFunction(onUpdateSql)) {
onUpdateSql(sqlvalue);
}
// eslint-disable-next-line no-param-reassign
sql = sqlvalue;
};
const separateSql = async (value: string) => {
setResultTableLoading(true);
const { code, data, msg } = await excuteSql({
sql: value,
domainId,
});
setResultTableLoading(false);
if (code === 200) {
// setDataSourceResult(data);
fetchTaskResult(data);
setRunState(true);
} else {
setRunState(false);
setTaskLog(msg);
}
};
const onSelect = (value: string) => {
if (value) {
setIsPartial(true);
setPartialSql(value);
} else {
setIsPartial(false);
}
};
const excuteScript = () => {
if (!sql) {
return message.error('SQL查询语句不可以为空');
}
if (isSqlExcLocked) {
return message.warning('请间隔5s再重新执行');
}
const waitTime = 5000;
setIsSqlExcLocked(true); // 加锁5s后再解锁
setTimeout(() => {
setIsSqlExcLocked(false);
}, waitTime);
return isPartial ? separateSql(partialSql) : separateSql(sql);
};
const showDataSetModal = () => {
setDataSourceModalVisible(true);
};
const startCreatDataSource = async () => {
showDataSetModal();
};
const updateNormalResScroll = () => {
const node = resultInnerWrap?.current;
if (node) {
setTableScroll({
scrollToFirstRowOnChange: true,
x: '100%',
y: node.clientHeight - 120,
});
}
};
const updateFullScreenResScroll = () => {
const windowHeight = window.innerHeight;
const paginationHeight = 96;
setTableScroll({
scrollToFirstRowOnChange: true,
x: '100%',
y: windowHeight - DEFAULT_FULLSCREEN_TOP - paginationHeight - 30, // 30为退出全屏按钮的高度
});
};
const handleFullScreenSqlIde = () => {
setIsSqlIdeFullScreen(true);
};
const handleNormalScreenSqlIde = () => {
setIsSqlIdeFullScreen(false);
};
const handleFullScreenSqlResult = () => {
setIsSqlResFullScreen(true);
};
const handleNormalScreenSqlResult = () => {
setIsSqlResFullScreen(false);
};
const handleThemeChange = () => {
setIsRight(!isRight);
};
const renderResult = () => {
if (runState === false) {
return (
<>
{
<div className={styles.taskFailed}>
<WarningOutlined className={styles.resultFailIcon} />
</div>
}
<div
className={styles.sqlResultLog}
dangerouslySetInnerHTML={{
__html: taskLog.replace(/\r\n/g, '<br/>').replace(/\t/g, '&nbsp;&nbsp;&nbsp;&nbsp;'),
}}
/>
</>
);
}
if (runState) {
return (
<>
<div className={styles.detail} />
<Table<TaskResultItem>
loading={resultTableLoading}
dataSource={resultTable}
columns={resultCols}
onChange={changePaging}
pagination={pagination}
scroll={tableScroll}
className={styles.resultTable}
rowClassName="resultTableRow"
rowKey="index"
/>
</>
);
}
return <div className={styles.sqlResultContent}></div>;
};
// 更新任务结果列表的高度,使其撑满容器
useEffect(() => {
if (isSqlResFullScreen) {
updateFullScreenResScroll();
} else {
updateNormalResScroll();
}
}, [resultTable, isSqlResFullScreen]);
useEffect(() => {
queryDatabaseConfig();
const windowHeight = window.innerHeight;
let size: ScreenSize = 'small';
if (windowHeight > 1100) {
size = 'large';
} else if (windowHeight > 850) {
size = 'middle';
}
setScreenSize(size);
}, []);
const exploreEditorSize = localStorage.getItem('exploreEditorSize');
return (
<>
<div className={styles.sqlOprBar}>
<div className={styles.sqlOprBarLeftBox}>
<Tooltip title="数据类型">
<Dropdown
menu={{
items: jdbcSourceItems,
onClick: (e) => {
const value = e.key;
const target: any = jdbcSourceItems.filter((item: any) => {
return item.key === Number(value);
})[0];
if (target) {
// setJdbcSourceName(target.label);
onJdbcSourceChange?.(Number(value));
}
},
}}
placement="bottom"
>
<Button style={{ marginRight: '15px', minWidth: '140px' }}>
<Space>
<CloudServerOutlined className={styles.sqlOprIcon} style={{ marginRight: 0 }} />
<span>{jdbcSourceItems[0]?.label}</span>
</Space>
</Button>
</Dropdown>
</Tooltip>
<Tooltip title="全屏">
<FullscreenOutlined className={styles.sqlOprIcon} onClick={handleFullScreenSqlIde} />
</Tooltip>
<Tooltip title="格式化SQL语句">
<EditOutlined className={styles.sqlOprIcon} onClick={formatSQL} />
</Tooltip>
<Tooltip title="改变主题">
<SwapOutlined className={styles.sqlOprIcon} onClick={handleThemeChange} />
</Tooltip>
<Tooltip title="执行脚本">
<Button
style={{
lineHeight: '24px',
top: '3px',
position: 'relative',
}}
type="primary"
shape="round"
icon={
isPartial ? '' : isSqlExcLocked ? <PlayCircleOutlined /> : <PlayCircleTwoTone />
}
size={'small'}
className={
isSqlExcLocked ? `${styles.disableIcon} ${styles.sqlOprIcon}` : styles.sqlOprBtn
}
onClick={excuteScript}
>
{isPartial ? '部分运行' : '运行'}
</Button>
</Tooltip>
</div>
</div>
<SplitPane
split="horizontal"
onChange={(size) => {
setEditorSize(size);
localStorage.setItem('exploreEditorSize', size[0]);
}}
>
<Pane initialSize={exploreEditorSize || '500px'}>
<div className={styles.sqlMain}>
<div className={styles.sqlEditorWrapper}>
<FullScreen
isFullScreen={isSqlIdeFullScreen}
top={`${DEFAULT_FULLSCREEN_TOP}px`}
triggerBackToNormal={handleNormalScreenSqlIde}
>
<SqlEditor
value={sql}
// height={sqlEditorHeight}
// theme="monokai"
isRightTheme={isRight}
sizeChanged={editorSize}
onSqlChange={onSqlChange}
onSelect={onSelect}
/>
</FullScreen>
</div>
</div>
</Pane>
<div className={`${styles.sqlBottmWrap} ${screenSize}`}>
<div className={styles.sqlResultWrap}>
<div className={styles.sqlToolBar}>
{
<Button
className={styles.sqlToolBtn}
type="primary"
onClick={startCreatDataSource}
disabled={!runState}
>
</Button>
}
<Button
className={styles.sqlToolBtn}
type="primary"
onClick={handleFullScreenSqlResult}
disabled={!runState}
>
</Button>
</div>
<div
className={styles.sqlResultPane}
ref={resultInnerWrap as React.MutableRefObject<HTMLDivElement | null>}
>
<FullScreen
isFullScreen={isSqlResFullScreen}
top={`${DEFAULT_FULLSCREEN_TOP}px`}
triggerBackToNormal={handleNormalScreenSqlResult}
>
{renderResult()}
</FullScreen>
</div>
</div>
</div>
</SplitPane>
{dataSourceModalVisible && (
<DataSourceCreateForm
sql={sql}
domainId={domainId}
dataSourceItem={dataSourceItem}
scriptColumns={scriptColumns}
onCancel={() => {
setDataSourceModalVisible(false);
}}
onSubmit={(dataSourceInfo: any) => {
setDataSourceModalVisible(false);
onSubmitSuccess?.(dataSourceInfo);
}}
createModalVisible={dataSourceModalVisible}
/>
)}
</>
);
};
export default SqlDetail;

View File

@@ -0,0 +1,126 @@
import React, { useState, useRef, useEffect } from 'react';
import { Tabs } from 'antd';
import SqlDetail from './SqlDetail';
import type { SqlItem } from '../data';
import styles from '../style.less';
type Panes = {
title: string;
key: string;
type: 'add' | 'edit';
scriptId?: number;
sql?: string;
sqlInfo?: SqlItem;
isSave?: boolean; // 暂存提示保存
};
type TableRef = {
current?: {
fetchSqlList: () => void;
upDateActiveItem: (key: any) => void;
};
};
type Props = {
initialValues: any;
domainId: number;
onSubmitSuccess?: (dataSourceInfo: any) => void;
};
const { TabPane } = Tabs;
const LIST_KEY = 'list';
const SqlSide: React.FC<Props> = ({ initialValues, domainId, onSubmitSuccess }) => {
const defaultPanes: Panes[] = [
{
key: '数据源查询',
title: initialValues?.name || '数据源查询',
type: 'add',
isSave: true,
},
];
const [activeKey, setActiveKey] = useState('数据源查询');
const [panes, setPanes] = useState<Panes[]>(defaultPanes);
const tableRef: TableRef = useRef();
const panesRef = useRef<Panes[]>(defaultPanes);
const [dataSourceItem, setDataSourceItem] = useState<any>(initialValues || {});
const updatePane = (list: Panes[]) => {
setPanes(list);
panesRef.current = list;
};
// 更新脚本内容
const updateTabSql = (sql: string, targetKey: string) => {
const newPanes = panesRef.current.slice();
const index = newPanes.findIndex((item) => item.key === targetKey);
const targetItem = newPanes[index];
newPanes.splice(index, 1, {
...targetItem,
sql,
isSave: false,
});
updatePane(newPanes);
};
useEffect(() => {
if (initialValues) {
updateTabSql(initialValues?.datasourceDetail?.sqlQuery || '', '数据源查询');
}
}, [initialValues]);
const onChange = (key: string) => {
setActiveKey(key);
tableRef?.current?.upDateActiveItem(key);
if (key === LIST_KEY) {
tableRef?.current?.fetchSqlList();
}
};
return (
<>
<div className={styles.outside}>
<Tabs
type="editable-card"
hideAdd={true}
activeKey={activeKey}
onChange={onChange}
className={styles.middleArea}
>
{panes.map((pane) => {
return (
<TabPane
tab={<div className={styles.paneName}>{pane.title}</div>}
closable={false}
key={pane.key}
>
<SqlDetail
onSubmitSuccess={onSubmitSuccess}
dataSourceItem={dataSourceItem}
oprType={pane.type}
domainId={domainId}
onUpdateSql={(sql: string) => {
updateTabSql(sql, pane.key);
}}
onJdbcSourceChange={(databaseId) => {
setDataSourceItem({
...dataSourceItem,
databaseId,
});
}}
sql={pane.sql}
/>
</TabPane>
);
})}
</Tabs>
</div>
{/* </SplitPane> */}
</>
);
};
export default SqlSide;

View File

@@ -0,0 +1,67 @@
export const EDITOR_HEIGHT_MAP = new Map([
['small', '250px'],
['middle', '300px'],
['large', '400px'],
]);
export enum EnumDataSourceType {
CATEGORICAL = 'categorical',
TIME = 'time',
MEASURES = 'measures',
PRIMARY = 'primary',
FOREIGN = 'foreign',
}
export const TYPE_OPTIONS = [
{
label: '维度',
value: EnumDataSourceType.CATEGORICAL,
},
{
label: '日期',
value: EnumDataSourceType.TIME,
},
{
label: '度量',
value: EnumDataSourceType.MEASURES,
},
{
label: '主键',
value: EnumDataSourceType.PRIMARY,
},
{
label: '外键',
value: EnumDataSourceType.FOREIGN,
},
];
export const AGG_OPTIONS = [
{
label: 'sum',
value: 'sum',
},
{
label: 'max',
value: 'max',
},
{
label: 'min',
value: 'min',
},
{
label: 'avg',
value: 'avg',
},
{
label: 'count',
value: 'count',
},
{
label: 'count_distinct',
value: 'count_distinct',
},
];
export const DATE_OPTIONS = ['day', 'week', 'month'];
export const DATE_FORMATTER = ['YYYY-MM-DD', 'YYYYMMDD', 'YYYY-MM', 'YYYYMM'];

View File

@@ -0,0 +1,68 @@
import React, { useState, useEffect } from 'react';
import SplitPane from 'react-split-pane';
import SqlSide from './components/SqlSide';
import Pane from 'react-split-pane/lib/Pane';
import styles from './style.less';
import { RightOutlined, LeftOutlined } from '@ant-design/icons';
type Props = {
initialValues: any;
domainId: number;
onSubmitSuccess?: (dataSourceInfo: any) => void;
};
const DEFAULT_RIGHT_SIZE = '300px';
const DataExploreView: React.FC<Props> = ({ initialValues, domainId, onSubmitSuccess }) => {
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
const exploreRightCollapsed = localStorage.getItem('exploreRightCollapsed');
setCollapsed(exploreRightCollapsed === 'true');
}, []);
const onCollapse = () => {
const collapsedValue = !collapsed;
setCollapsed(collapsedValue);
localStorage.setItem('exploreRightCollapsed', String(collapsedValue));
const exploreRightSize = collapsedValue ? '0px' : localStorage.getItem('exploreRightSize');
const sizeValue = parseInt(exploreRightSize || '0');
if (!collapsedValue && sizeValue <= 10) {
localStorage.setItem('exploreRightSize', DEFAULT_RIGHT_SIZE);
}
};
return (
<div
className={`${styles.pageContainer} ${
window.location.hash.includes('external') ? styles.externalPageContainer : ''
}`}
>
<div className={styles.main}>
<SplitPane
split="vertical"
onChange={(size) => {
localStorage.setItem('exploreRightSize', size[1]);
}}
>
<div className={styles.rightListSide}>
{false && (
<div className={styles.collapseRightBtn} onClick={onCollapse}>
{collapsed ? <LeftOutlined /> : <RightOutlined />}
</div>
)}
<SqlSide
initialValues={initialValues}
domainId={domainId}
onSubmitSuccess={onSubmitSuccess}
/>
</div>
<Pane initialSize={0} />
</SplitPane>
</div>
</div>
);
};
export default DataExploreView;

View File

@@ -0,0 +1,12 @@
import request from 'umi-request';
type ExcuteSqlParams = {
sql: string;
domainId: number;
};
// 执行脚本
export async function excuteSql(params: ExcuteSqlParams) {
const data = { ...params };
return request.post(`${process.env.API_BASE_URL}database/executeSql`, { data });
}

View File

@@ -0,0 +1,759 @@
@borderColor: #eee;
@activeColor: #a0c5e8;
@hoverColor: #dee4e9;
.pageContainer {
position: absolute;
top: 55px;
right: 0;
bottom: 0;
left: 0;
// margin: -24px;
background: #fff;
&.externalPageContainer {
margin: 0 !important;
}
}
.searchBar {
:global {
.ant-form-item-label {
width: 70px;
}
}
}
.main {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
:global {
.ant-tabs {
height: 100% !important;
.ant-tabs-content {
height: 100% !important;
.ant-tabs-tabpane {
height: 100%;
}
}
}
}
}
.rightSide {
position: relative;
z-index: 1;
min-width: 250px;
height: 100%;
margin-left: 4px;
padding: 10px;
overflow: hidden;
:global {
.ant-form-item {
margin-bottom: 6px;
.ant-form-item-label {
width: 70px;
}
.ant-form-item-control {
min-width: 100px;
}
}
}
}
.rightListSide {
position: relative;
z-index: 2;
flex: 1;
height: 100%;
// padding: 10px 10px 0;
background-color: #fff;
// 去掉标签间距
:global {
.ant-tabs-card.ant-tabs-top > .ant-tabs-nav .ant-tabs-tab + .ant-tabs-tab,
.ant-tabs-card.ant-tabs-bottom > .ant-tabs-nav .ant-tabs-tab + .ant-tabs-tab,
.ant-tabs-card.ant-tabs-top > div > .ant-tabs-nav .ant-tabs-tab + .ant-tabs-tab,
.ant-tabs-card.ant-tabs-bottom > div > .ant-tabs-nav .ant-tabs-tab + .ant-tabs-tab {
margin-left: 0;
}
.ant-tabs > .ant-tabs-nav .ant-tabs-nav-add,
.ant-tabs > div > .ant-tabs-nav .ant-tabs-nav-add {
margin-left: 0;
}
}
}
.leftListSide {
position: relative;
z-index: 2;
flex: 1;
height: 100%;
// padding: 10px 10px 0;
background-color: #fff;
}
.tableTotal {
margin: 0 2px;
color: #296df3;
font-weight: bold;
}
.tableDetaildrawer {
:global {
.ant-drawer-header {
padding: 10px 45px 10px 10px;
}
.ant-drawer-close {
padding: 10px;
}
.ant-drawer-body {
padding: 0 10px 10px;
}
.ant-tabs-top > .ant-tabs-nav {
margin-bottom: 8px;
}
}
}
.tableDetailTable {
:global {
.ant-table-cell,
.resultTableRow > td {
padding: 8px;
font-size: 12px;
}
}
}
.sqlEditor {
min-width: 0;
height: 100%;
border: solid 1px @borderColor;
:global {
.ace_editor {
font-family: 'Menlo', 'Monaco', 'Ubuntu Mono', 'Consolas', 'source-code-pro' !important;
}
}
}
.sqlOprBar {
margin-top: -10px;
padding: 5px;
display: flex;
.sqlOprBarLeftBox {
flex: 1 1 200px;
}
.sqlOprBarRightBox {
flex: 0 1 210px;
}
:global {
.ant-btn-round.ant-btn-sm {
font-size: 12px;
}
.ant-btn-primary {
color: #fff;
background: #02a7f0;
border-color: #02a7f0;
}
.ant-segmented-item-selected {
color: #fff;
background: #02a7f0;
border-color: #02a7f0;
}
}
}
.sqlOprIcon {
margin-right: 30px;
color: #02a7f0;
font-size: 22px;
&:hover {
cursor: pointer;
opacity: 0.8;
}
&:active {
opacity: 0.7;
}
}
.sqlOprBtn {
margin-right: 30px;
vertical-align: super !important;
&:hover {
cursor: pointer;
opacity: 0.8;
}
&:active {
opacity: 0.7;
}
}
.sqlOprSwitch {
// vertical-align: super !important;
float: right;
margin-right: 10px !important;
}
:global {
.is-sql-full-select {
background-color: #02a7f0;
}
.cjjWdp:hover {
z-index: 10;
}
}
.sqlMain {
display: flex;
flex-direction: row;
height: 100%;
.sqlEditorWrapper {
flex: 1;
height: 100%;
overflow: hidden;
}
.sqlParams {
width: 20%;
height: 100% !important;
overflow: auto;
}
.hideSqlParams {
width: 0;
height: 100% !important;
overflow: auto;
}
}
.sqlParamsBody {
.header {
display: flex;
padding: 10px;
font-weight: bold;
.title {
flex: 1;
}
.icon {
display: flex;
align-items: center;
margin-right: 10px !important;
cursor: pointer;
}
}
.paramsList {
.paramsItem {
display: flex;
padding: 10px;
:global {
.ant-list-item-action {
margin-left: 5px;
}
}
.name {
flex: 1;
width: 80%;
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
&:hover {
cursor: pointer;
}
}
.icon {
// display: none;
margin-left: 10px;
}
}
}
// .paramsItem:hover {
// .icon {
// display: inline-block;
// margin-left: 8px;
// cursor: pointer;
// }
// }
}
.disableIcon {
vertical-align: super !important;
// color: rgba(0, 10, 36, 0.25);
background: #7d7f80 !important;
border-color: #7d7f80 !important;
:global {
.anticon .anticon-play-circle {
color: #fff;
}
}
&:hover {
cursor: not-allowed;
opacity: 1;
}
}
.sqlTaskListWrap {
position: relative;
width: 262px;
border-top: 0 !important;
border-radius: 0;
:global {
.ant-card-head {
min-height: 20px;
}
.ant-card-head-title {
padding: 8px 0;
}
}
}
.sqlTaskList {
position: absolute !important;
top: 42px;
right: 0;
bottom: 0;
left: 0;
overflow: auto;
}
.sqlBottmWrap {
// position: absolute;
// top: 484px;
// right: 0;
// bottom: 0;
// left: 0;
display: flex;
height: 100%;
// padding: 0 10px;
&:global(.small) {
top: 334px;
}
&:global(.middle) {
top: 384px;
}
}
.sqlResultWrap {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
overflow: auto;
border: solid 1px @borderColor;
border-top: 0;
border-left: 0;
}
.sqlToolBar {
display: flex;
flex-direction: row-reverse;
align-items: center;
height: 41px;
padding: 5px 0;
text-align: right;
}
.sqlResultPane {
flex: 1;
border-top: solid 1px @borderColor;
}
.sqlToolBtn {
margin-right: 15px;
}
.runScriptBtn {
margin-right: 15px;
background-color: #e87954;
border-color: #e87954;
&:hover{
border-color: #f89878;
background: #f89878;
}
&:focus {
border-color: #f89878;
background: #f89878;
}
}
.taskFailed {
padding: 20px 20px 0 20px;
}
.sqlResultContent {
position: absolute;
top: 50%;
width: 100%;
color: rgba(0, 0, 0, 0.25);
font-size: 16px;
text-align: center;
}
.sqlResultLog {
padding: 20px;
word-wrap: break-word;
}
.tableList {
position: absolute !important;
top: 160px;
right: 0;
bottom: 26px;
left: 0;
overflow-x: hidden;
overflow-y: auto;
border-bottom: solid 1px @borderColor;
}
.tablePage {
position: absolute !important;
bottom: 0;
left: 0;
z-index: 1;
width: 100%;
min-width: 250px;
overflow: hidden;
}
.tableListItem {
width: 88%;
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
&:hover {
cursor: pointer;
}
}
.tableItem {
&:global(.ant-list-item) {
padding: 6px 0 6px 6px;
}
:global(.ant-list-item-action) {
margin-left: 12px !important;
}
&:hover {
background: @hoverColor;
border-bottom: 1px solid #f0f0f0;
}
&:global(.active) {
background: @activeColor;
}
}
.taskIcon {
margin-right: 10px;
color: #1890ff;
font-size: 14px;
}
.taskSuccessIcon {
.taskIcon();
color: #67c23a;
}
.taskFailIcon {
.taskIcon();
color: #f56c6c;
}
.resultFailIcon {
margin-right: 8px;
color: #f56c6c;
}
.taskItem {
padding: 10px 8px !important;
font-size: 12px;
cursor: pointer;
&:global(.ant-list-item) {
justify-content: flex-start;
}
&:hover {
background: @hoverColor;
}
}
.activeTask {
background: @activeColor;
}
.resultTable {
width: 100%;
:global {
.ant-table-body {
width: 100%;
// max-height: none !important;
overflow: auto !important;
}
.ant-table-cell,
.resultTableRow > td {
padding: 8px;
font-size: 12px;
}
}
}
.taskLogWrap {
word-wrap: break-word;
}
.siteTagPlus {
background: #fff;
border-style: dashed;
}
.editTag {
margin-bottom: 5px;
user-select: none;
}
.tagInput {
width: 78px;
margin-right: 8px;
vertical-align: top;
}
.outside {
position: relative;
height: 100%;
}
.collapseRightBtn {
position: absolute;
top: calc(50% + 50px);
right: 0;
z-index: 100;
display: flex;
align-items: center;
height: 70px;
color: #fff;
font-size: 12px;
background-color: rgba(40, 46, 54, 0.2);
border-radius: 24px 0 0 24px;
cursor: pointer;
transition: all 0.3s ease;
}
.collapseLeftBtn {
position: absolute;
top: calc(50% + 45px);
left: 0;
z-index: 100;
display: flex;
align-items: center;
height: 70px;
color: #fff;
font-size: 12px;
background-color: rgba(40, 46, 54, 0.2);
border-radius: 0 24px 24px 0;
cursor: pointer;
transition: all 0.3s ease;
}
.detail {
.titleCollapse {
float: right;
padding-right: 18px;
color: #1890ff;
line-height: 35px;
text-align: right;
cursor: pointer;
}
.tableTitle {
display: inline-block;
width: 85%;
margin-left: 15px;
overflow: hidden;
line-height: 35px;
white-space: nowrap;
text-overflow: ellipsis;
cursor: pointer;
}
:global {
.ant-divider-horizontal {
margin: 0;
}
}
}
.search {
margin-left: 10px;
}
.middleArea {
:global {
.ant-tabs-nav .ant-tabs-tab {
border: none;
// background: #d9d9d96e;
border-right: 1px solid #f0f0f0;
border-radius: 0 !important;
}
.ant-tabs-nav-add {
border-radius: 0 !important;
}
.ant-tabs-tab {
.ant-tabs-tab-remove {
.closeTab {
opacity: 0;
}
.dot {
opacity: 1;
}
}
}
.ant-tabs-tab:hover {
.ant-tabs-tab-remove {
.closeTab {
opacity: 1 !important;
}
.dot {
opacity: 0;
}
}
}
}
}
.menu {
position: relative;
z-index: 1;
height: 100%;
padding: 5px;
overflow: hidden;
overflow-x: hidden;
overflow-y: auto;
:global {
.ant-form {
margin: -2px;
}
}
}
.menuList {
position: absolute !important;
top: 95px;
right: 0;
bottom: 26px;
left: 0;
overflow-x: hidden;
overflow-y: auto;
border-bottom: solid 1px @borderColor;
.menuItem {
&:global(.ant-list-item) {
padding: 6px 0 6px 14px;
}
:global(.ant-list-item-action) {
margin-left: 12px !important;
}
&:hover {
background: @hoverColor;
border-bottom: 1px solid #f0f0f0;
.icon {
display: block;
}
}
&:global(.active) {
background: @activeColor;
}
.menuListItem {
width: 90%;
overflow: hidden;
font-size: 12px;
white-space: nowrap;
text-overflow: ellipsis;
&:hover {
cursor: pointer;
}
}
.icon {
display: none;
margin-right: 15px !important;
cursor: pointer;
}
.menuIcon {
display: flex;
}
}
}
.scriptFile {
width: 100%;
margin: 10px;
overflow: hidden;
font-size: 14px;
white-space: nowrap;
text-overflow: ellipsis;
.icon {
margin-right: 10px;
}
}
.sqlScriptName {
width: 93% !important;
margin: 14px 0 0 14px !important;
}
.fileIcon {
width: 20px !important;
height: 20px !important;
padding-top: 2px !important;
padding-right: 5px !important;
vertical-align: middle;
}
.itemName {
vertical-align: middle;
}
.paneName {
width: 100px;
overflow: hidden;
font-size: 12px !important;
white-space: nowrap;
text-overflow: ellipsis;
}
.titleIcon {
width: 16px !important;
height: 16px !important;
margin: 0 3px 4px;
}

View File

@@ -0,0 +1,143 @@
import { Tabs } from 'antd';
import React, { useEffect, useState } from 'react';
import { connect, Helmet } from 'umi';
import ProjectListTree from './components/ProjectList';
import ClassDataSourceTable from './components/ClassDataSourceTable';
import ClassDimensionTable from './components/ClassDimensionTable';
import ClassMetricTable from './components/ClassMetricTable';
import PermissionSection from './components/Permission/PermissionSection';
import DatabaseSection from './components/Database/DatabaseSection';
import styles from './components/style.less';
import type { StateType } from './model';
import { RightOutlined, LeftOutlined } from '@ant-design/icons';
import SemanticFlow from './SemanticFlows';
// import SemanticGraph from './SemanticGraph';
import SplitPane from 'react-split-pane';
import Pane from 'react-split-pane/lib/Pane';
import type { Dispatch } from 'umi';
const { TabPane } = Tabs;
type Props = {
domainManger: StateType;
dispatch: Dispatch;
};
const DEFAULT_LEFT_SIZE = '300px';
const DomainManger: React.FC<Props> = ({ domainManger, dispatch }) => {
const [collapsed, setCollapsed] = useState(false);
const [leftSize, setLeftSize] = useState('');
const { selectDomainId, selectDomainName } = domainManger;
useEffect(() => {
const semanticLeftCollapsed = localStorage.getItem('semanticLeftCollapsed');
const semanticLeftSize =
semanticLeftCollapsed === 'true' ? '0px' : localStorage.getItem('semanticLeftSize');
setCollapsed(semanticLeftCollapsed === 'true');
setLeftSize(semanticLeftSize || DEFAULT_LEFT_SIZE);
}, []);
useEffect(() => {
if (selectDomainId) {
dispatch({
type: 'domainManger/queryDimensionList',
payload: {
domainId: selectDomainId,
},
});
dispatch({
type: 'domainManger/queryMetricList',
payload: {
domainId: selectDomainId,
},
});
}
}, [selectDomainId]);
const onCollapse = () => {
const collapsedValue = !collapsed;
setCollapsed(collapsedValue);
localStorage.setItem('semanticLeftCollapsed', String(collapsedValue));
const semanticLeftSize = collapsedValue ? '0px' : localStorage.getItem('semanticLeftSize');
const sizeValue = parseInt(semanticLeftSize || '0');
if (!collapsedValue && sizeValue <= 10) {
setLeftSize(DEFAULT_LEFT_SIZE);
localStorage.setItem('semanticLeftSize', DEFAULT_LEFT_SIZE);
} else {
setLeftSize(semanticLeftSize || DEFAULT_LEFT_SIZE);
}
};
useEffect(() => {
const width = document.getElementById('tab');
const switchWarpper: any = document.getElementById('switch');
if (width && switchWarpper) {
switchWarpper.style.width = width.offsetWidth * 0.77 + 'px';
}
});
return (
<div className={styles.projectBody}>
<Helmet title={'语义建模-超音数'} />
<SplitPane
split="vertical"
onChange={(size) => {
localStorage.setItem('semanticLeftSize', size[0]);
setLeftSize(size[0]);
}}
>
<Pane initialSize={leftSize || DEFAULT_LEFT_SIZE}>
<div className={styles.menu}>
<ProjectListTree />
</div>
</Pane>
<div className={styles.projectManger}>
<div className={styles.collapseLeftBtn} onClick={onCollapse}>
{collapsed ? <RightOutlined /> : <LeftOutlined />}
</div>
<h2 className={styles.title}>
{selectDomainName ? `选择的主题域:${selectDomainName}` : '主题域信息'}
</h2>
{selectDomainId ? (
<>
<Tabs className={styles.tab} defaultActiveKey="xflow" destroyInactiveTabPane>
{/* <TabPane className={styles.tabPane} tab="关系可视化" key="graph">
<div style={{ width: '100%', height: 'calc(100vh - 200px)' }}>
<SemanticGraph domainId={selectDomainId} />
</div>
</TabPane> */}
<TabPane className={styles.tabPane} tab="可视化建模" key="xflow">
<div style={{ width: '100%', height: 'calc(100vh - 200px)' }}>
<SemanticFlow />
</div>
</TabPane>
<TabPane className={styles.tabPane} tab="数据库" key="dataBase">
<DatabaseSection />
</TabPane>
<TabPane className={styles.tabPane} tab="数据源" key="dataSource">
<ClassDataSourceTable />
</TabPane>
<TabPane className={styles.tabPane} tab="维度" key="dimenstion">
<ClassDimensionTable key={selectDomainId} />
</TabPane>
<TabPane className={styles.tabPane} tab="指标" key="metric">
<ClassMetricTable />
</TabPane>
<TabPane className={styles.tabPane} tab="权限管理" key="permissonSetting">
<PermissionSection />
</TabPane>
</Tabs>
</>
) : (
<h2 className={styles.mainTip}></h2>
)}
</div>
</SplitPane>
</div>
);
};
export default connect(({ domainManger }: { domainManger: StateType }) => ({
domainManger,
}))(DomainManger);

View File

@@ -0,0 +1,209 @@
import type { HookHub, ICmdHooks as IHooks, NsGraph } from '@antv/xflow';
import { Deferred, ManaSyringe } from '@antv/xflow';
import { Modal, ConfigProvider } from 'antd';
import type { IArgsBase, ICommandHandler } from '@antv/xflow';
import { ICommandContextProvider } from '@antv/xflow';
import { DATASOURCE_NODE_RENDER_ID } from '../constant';
import { CustomCommands } from './constants';
import 'antd/es/modal/style/index.css';
export namespace NsConfirmModalCmd {
/** Command: 用于注册named factory */
// eslint-disable-next-line
export const command = CustomCommands.SHOW_CONFIRM_MODAL;
/** hook name */
// eslint-disable-next-line
export const hookKey = 'confirmModal';
/** hook 参数类型 */
export interface IArgs extends IArgsBase {
nodeConfig: NsGraph.INodeConfig;
confirmModalCallBack: IConfirmModalService;
}
export interface IConfirmModalService {
(): Promise<any>;
}
/** hook handler 返回类型 */
export type IResult = any;
/** hooks 类型 */
export interface ICmdHooks extends IHooks {
confirmModal: HookHub<IArgs, IResult>;
}
}
const deleteDataSourceConfirmNode = (name: string) => {
return (
<>
<span style={{ color: '#296DF3', fontWeight: 'bold' }}>{name}</span>
</>
);
};
// prettier-ignore
type ICommand = ICommandHandler<NsConfirmModalCmd.IArgs, NsConfirmModalCmd.IResult, NsConfirmModalCmd.ICmdHooks>;
@ManaSyringe.injectable()
/** 部署画布数据 */
export class ConfirmModalCommand implements ICommand {
/** api */
@ManaSyringe.inject(ICommandContextProvider) contextProvider!: ICommand['contextProvider'];
/** 执行Cmd */
execute = async () => {
const ctx = this.contextProvider();
const { args } = ctx.getArgs();
const hooks = ctx.getHooks();
await hooks.confirmModal.call(args, async (confirmArgs: NsConfirmModalCmd.IArgs) => {
const { nodeConfig, confirmModalCallBack } = confirmArgs;
const { renderKey, label } = nodeConfig;
if (!nodeConfig.modalProps?.modalContent) {
let modalContent = <></>;
if (renderKey === DATASOURCE_NODE_RENDER_ID) {
modalContent = deleteDataSourceConfirmNode(label!);
}
nodeConfig.modalProps = {
...(nodeConfig.modalProps || {}),
modalContent,
};
}
const getAppContext: IGetAppCtx = () => {
return {
confirmModalCallBack,
};
};
const x6Graph = await ctx.getX6Graph();
const cell = x6Graph.getCellById(nodeConfig.id);
if (!cell || !cell.isNode()) {
throw new Error(`${nodeConfig.id} is not a valid node`);
}
/** 通过modal 获取 new name */
await showModal(nodeConfig, getAppContext);
return;
});
// ctx.setResult(result);
return this;
};
/** undo cmd */
undo = async () => {
if (this.isUndoable()) {
const ctx = this.contextProvider();
ctx.undo();
}
return this;
};
/** redo cmd */
redo = async () => {
if (!this.isUndoable()) {
await this.execute();
}
return this;
};
isUndoable(): boolean {
const ctx = this.contextProvider();
return ctx.isUndoable();
}
}
export interface IGetAppCtx {
(): {
confirmModalCallBack: NsConfirmModalCmd.IConfirmModalService;
};
}
export type IModalInstance = ReturnType<typeof Modal.confirm>;
function showModal(node: NsGraph.INodeConfig, getAppContext: IGetAppCtx) {
/** showModal 返回一个Promise */
const defer = new Deferred<string | void>();
const modalTitle = node.modalProps?.title;
const modalContent = node.modalProps?.modalContent;
/** modal确认保存逻辑 */
class ModalCache {
static modal: IModalInstance;
}
/** modal确认保存逻辑 */
const onOk = async () => {
const { modal } = ModalCache;
const appContext = getAppContext();
const { confirmModalCallBack } = appContext;
try {
modal.update({ okButtonProps: { loading: true } });
/** 执行 confirm回调*/
if (confirmModalCallBack) {
await confirmModalCallBack();
}
/** 更新成功后关闭modal */
onHide();
} catch (error) {
console.error(error);
/** 如果resolve空字符串则不更新 */
modal.update({ okButtonProps: { loading: false } });
}
};
/** modal销毁逻辑 */
const onHide = () => {
modal.destroy();
ModalCache.modal = null as any;
container.destroy();
};
/** modal内容 */
const ModalContent = () => {
return (
<div>
<ConfigProvider>{modalContent}</ConfigProvider>
</div>
);
};
/** 创建modal dom容器 */
const container = createContainer();
/** 创建modal */
const modal = Modal.confirm({
title: modalTitle,
content: <ModalContent />,
getContainer: () => {
return container.element;
},
okButtonProps: {
onClick: (e) => {
e.stopPropagation();
onOk();
},
},
onCancel: () => {
onHide();
},
afterClose: () => {
onHide();
},
});
/** 缓存modal实例 */
ModalCache.modal = modal;
/** showModal 返回一个Promise用于await */
return defer.promise;
}
const createContainer = () => {
const div = document.createElement('div');
div.classList.add('xflow-modal-container');
window.document.body.append(div);
return {
element: div,
destroy: () => {
window.document.body.removeChild(div);
},
};
};

View File

@@ -0,0 +1,89 @@
import type {
NsGraphCmd,
ICmdHooks as IHooks,
NsGraph,
IArgsBase,
ICommandHandler,
HookHub,
} from '@antv/xflow';
import { XFlowGraphCommands, ManaSyringe } from '@antv/xflow';
import { ICommandContextProvider } from '@antv/xflow';
import { CustomCommands } from './constants';
// prettier-ignore
type ICommand = ICommandHandler<NsDeployDagCmd.IArgs, NsDeployDagCmd.IResult, NsDeployDagCmd.ICmdHooks>;
export namespace NsDeployDagCmd {
/** Command: 用于注册named factory */
// eslint-disable-next-line
export const command = CustomCommands.DEPLOY_SERVICE;
/** hook name */
// eslint-disable-next-line
export const hookKey = 'deployDag';
/** hook 参数类型 */
export interface IArgs extends IArgsBase {
deployDagService: IDeployDagService;
}
export interface IDeployDagService {
(meta: NsGraph.IGraphMeta, data: NsGraph.IGraphData): Promise<{ success: boolean }>;
}
/** hook handler 返回类型 */
export interface IResult {
success: boolean;
}
/** hooks 类型 */
export interface ICmdHooks extends IHooks {
deployDag: HookHub<IArgs, IResult>;
}
}
@ManaSyringe.injectable()
/** 部署画布数据 */
export class DeployDagCommand implements ICommand {
/** api */
@ManaSyringe.inject(ICommandContextProvider) contextProvider!: ICommand['contextProvider'];
/** 执行Cmd */
execute = async () => {
const ctx = this.contextProvider();
const { args } = ctx.getArgs();
const hooks = ctx.getHooks();
const result = await hooks.deployDag.call(args, async (handlerArgs) => {
const { commandService, deployDagService } = handlerArgs;
/** 执行Command */
await commandService!.executeCommand<NsGraphCmd.SaveGraphData.IArgs>(
XFlowGraphCommands.SAVE_GRAPH_DATA.id,
{
saveGraphDataService: async (meta, graph) => {
await deployDagService(meta, graph);
},
},
);
return { success: true };
});
ctx.setResult(result);
return this;
};
/** undo cmd */
undo = async () => {
if (this.isUndoable()) {
const ctx = this.contextProvider();
ctx.undo();
}
return this;
};
/** redo cmd */
redo = async () => {
if (!this.isUndoable()) {
await this.execute();
}
return this;
};
isUndoable(): boolean {
const ctx = this.contextProvider();
return ctx.isUndoable();
}
}

View File

@@ -0,0 +1,255 @@
import type { HookHub, ICmdHooks as IHooks, NsGraph, IModelService } from '@antv/xflow';
import { Deferred, ManaSyringe } from '@antv/xflow';
import type { FormInstance } from 'antd';
import { Modal, Form, Input, ConfigProvider } from 'antd';
import type { IArgsBase, ICommandHandler, IGraphCommandService } from '@antv/xflow';
import { ICommandContextProvider } from '@antv/xflow';
import { CustomCommands } from './constants';
import 'antd/es/modal/style/index.css';
// prettier-ignore
type ICommand = ICommandHandler<NsRenameNodeCmd.IArgs, NsRenameNodeCmd.IResult, NsRenameNodeCmd.ICmdHooks>;
export namespace NsRenameNodeCmd {
/** Command: 用于注册named factory */
// eslint-disable-next-line
export const command = CustomCommands.SHOW_RENAME_MODAL;
/** hook name */
// eslint-disable-next-line
export const hookKey = 'renameNode';
/** hook 参数类型 */
export interface IArgs extends IArgsBase {
nodeConfig: NsGraph.INodeConfig;
updateNodeNameService: IUpdateNodeNameService;
}
export interface IUpdateNodeNameService {
(newName: string, nodeConfig: NsGraph.INodeConfig, meta: NsGraph.IGraphMeta): Promise<{
err: string | null;
nodeName: string;
}>;
}
/** hook handler 返回类型 */
export interface IResult {
err: string | null;
preNodeName?: string;
currentNodeName?: string;
}
/** hooks 类型 */
export interface ICmdHooks extends IHooks {
renameNode: HookHub<IArgs, IResult>;
}
}
@ManaSyringe.injectable()
/** 部署画布数据 */
// prettier-ignore
export class RenameNodeCommand implements ICommand {
/** api */
@ManaSyringe.inject(ICommandContextProvider) contextProvider!: ICommand['contextProvider'];
/** 执行Cmd */
execute = async () => {
const ctx = this.contextProvider();
// const app = useXFlowApp();
const { args } = ctx.getArgs();
const hooks = ctx.getHooks();
const result = await hooks.renameNode.call(args, async (args) => {
const { nodeConfig, graphMeta, commandService, modelService, updateNodeNameService } = args;
const preNodeName = nodeConfig.label;
const getAppContext: IGetAppCtx = () => {
return {
graphMeta,
commandService,
modelService,
updateNodeNameService,
};
};
const x6Graph = await ctx.getX6Graph();
const cell = x6Graph.getCellById(nodeConfig.id);
const nodes = x6Graph.getNodes();
const edges = x6Graph.getEdges();
nodes.forEach((node) => {
if (node !== cell) {
x6Graph.removeCell(node);
}
});
edges.forEach((edge) => {
x6Graph.removeEdge(edge);
});
if (!cell || !cell.isNode()) {
throw new Error(`${nodeConfig.id} is not a valid node`);
}
/** 通过modal 获取 new name */
const newName = await showModal(nodeConfig, getAppContext);
/** 更新 node name */
if (newName) {
const cellData = cell.getData<NsGraph.INodeConfig>();
cell.setData({ ...cellData, label: newName } as NsGraph.INodeConfig);
return { err: null, preNodeName, currentNodeName: newName };
}
return { err: null, preNodeName, currentNodeName: '' };
});
ctx.setResult(result);
return this;
};
/** undo cmd */
undo = async () => {
if (this.isUndoable()) {
const ctx = this.contextProvider();
ctx.undo();
}
return this;
};
/** redo cmd */
redo = async () => {
if (!this.isUndoable()) {
await this.execute();
}
return this;
};
isUndoable(): boolean {
const ctx = this.contextProvider();
return ctx.isUndoable();
}
}
export interface IGetAppCtx {
(): {
graphMeta: NsGraph.IGraphMeta;
commandService: IGraphCommandService;
modelService: IModelService;
updateNodeNameService: NsRenameNodeCmd.IUpdateNodeNameService;
};
}
export type IModalInstance = ReturnType<typeof Modal.confirm>;
export interface IFormProps {
newNodeName: string;
}
const layout = {
labelCol: { span: 5 },
wrapperCol: { span: 19 },
};
function showModal(node: NsGraph.INodeConfig, getAppContext: IGetAppCtx) {
/** showModal 返回一个Promise */
const defer = new Deferred<string | void>();
/** modal确认保存逻辑 */
class ModalCache {
static modal: IModalInstance;
static form: FormInstance<IFormProps>;
}
/** modal确认保存逻辑 */
const onOk = async () => {
const { form, modal } = ModalCache;
const appContext = getAppContext();
const { updateNodeNameService, graphMeta } = appContext;
try {
modal.update({ okButtonProps: { loading: true } });
await form.validateFields();
const values = await form.getFieldsValue();
const newName: string = values.newNodeName;
/** 执行 backend service */
if (updateNodeNameService) {
const { err, nodeName } = await updateNodeNameService(newName, node, graphMeta);
if (err) {
throw new Error(err);
}
defer.resolve(nodeName);
}
/** 更新成功后关闭modal */
onHide();
} catch (error) {
console.error(error);
/** 如果resolve空字符串则不更新 */
modal.update({ okButtonProps: { loading: false } });
}
};
/** modal销毁逻辑 */
const onHide = () => {
modal.destroy();
ModalCache.form = null as any;
ModalCache.modal = null as any;
container.destroy();
};
/** modal内容 */
const ModalContent = () => {
const [form] = Form.useForm<IFormProps>();
/** 缓存form实例 */
ModalCache.form = form;
return (
<div>
<ConfigProvider>
<Form form={form} {...layout} initialValues={{ newNodeName: node.label }}>
<Form.Item
name="newNodeName"
label="节点名"
rules={[
{ required: true, message: '请输入新节点名' },
{ min: 3, message: '节点名不能少于3个字符' },
]}
>
<Input />
</Form.Item>
</Form>
</ConfigProvider>
</div>
);
};
/** 创建modal dom容器 */
const container = createContainer();
/** 创建modal */
const modal = Modal.confirm({
title: '重命名',
content: <ModalContent />,
getContainer: () => {
return container.element;
},
okButtonProps: {
onClick: (e) => {
e.stopPropagation();
onOk();
},
},
onCancel: () => {
onHide();
},
afterClose: () => {
onHide();
},
});
/** 缓存modal实例 */
ModalCache.modal = modal;
/** showModal 返回一个Promise用于await */
return defer.promise;
}
const createContainer = () => {
const div = document.createElement('div');
div.classList.add('xflow-modal-container');
window.document.body.append(div);
return {
element: div,
destroy: () => {
window.document.body.removeChild(div);
},
};
};

View File

@@ -0,0 +1,88 @@
import type {
ICmdHooks as IHooks,
NsGraph,
IArgsBase,
ICommandHandler,
HookHub,
} from '@antv/xflow';
import { ManaSyringe } from '@antv/xflow';
import { ICommandContextProvider } from '@antv/xflow';
import { CustomCommands } from './constants';
import { getDatasourceRelaList } from '../../service';
// prettier-ignore
type ICommand = ICommandHandler<NsDataSourceRelationCmd.IArgs, NsDataSourceRelationCmd.IResult, NsDataSourceRelationCmd.ICmdHooks>;
export namespace NsDataSourceRelationCmd {
/** Command: 用于注册named factory */
// eslint-disable-next-line
export const command = CustomCommands.DATASOURCE_RELATION;
/** hook name */
// eslint-disable-next-line
export const hookKey = 'dataSourceRelation';
/** hook 参数类型 */
export interface IArgs extends IArgsBase {
dataSourceRelationService: IDataSourceRelationService;
}
export interface IDataSourceRelationService {
(meta: NsGraph.IGraphMeta, data: NsGraph.IGraphData): Promise<{ success: boolean }>;
}
/** hook handler 返回类型 */
export type IResult = any[] | undefined;
/** hooks 类型 */
export interface ICmdHooks extends IHooks {
dataSourceRelation: HookHub<IArgs, IResult>;
}
}
@ManaSyringe.injectable()
/** 部署画布数据 */
export class DataSourceRelationCommand implements ICommand {
/** api */
@ManaSyringe.inject(ICommandContextProvider) contextProvider!: ICommand['contextProvider'];
/** 执行Cmd */
execute = async () => {
const ctx = this.contextProvider();
const { args } = ctx.getArgs();
const hooks = ctx.getHooks();
const graphMeta = await ctx.getGraphMeta();
const domainId = graphMeta?.meta?.domainManger?.selectDomainId;
if (!domainId) {
return this;
}
const result = await hooks.dataSourceRelation.call(args, async () => {
const { code, data } = await getDatasourceRelaList(domainId);
if (code === 200) {
return data;
}
return [];
});
ctx.setResult(result);
ctx.setGlobal('dataSourceRelationList', result);
return this;
};
/** undo cmd */
undo = async () => {
if (this.isUndoable()) {
const ctx = this.contextProvider();
ctx.undo();
}
return this;
};
/** redo cmd */
redo = async () => {
if (!this.isUndoable()) {
await this.execute();
}
return this;
};
isUndoable(): boolean {
const ctx = this.contextProvider();
return ctx.isUndoable();
}
}

View File

@@ -0,0 +1,43 @@
import type { IGraphCommand } from '@antv/xflow';
/** 节点命令 */
export namespace CustomCommands {
const category = '节点操作';
/** 异步请求demo */
export const TEST_ASYNC_CMD: IGraphCommand = {
id: 'xflow:async-cmd',
label: '异步请求',
category,
};
/** 重命名节点弹窗 */
export const SHOW_RENAME_MODAL: IGraphCommand = {
id: 'xflow:rename-node-modal',
label: '打开重命名弹窗',
category,
};
/** 二次确认弹窗 */
export const SHOW_CONFIRM_MODAL: IGraphCommand = {
id: 'xflow:confirm-modal',
label: '打开二次确认弹窗',
category,
};
/** 部署服务 */
export const DEPLOY_SERVICE: IGraphCommand = {
id: 'xflow:deploy-service',
label: '部署服务',
category,
};
export const DATASOURCE_RELATION: IGraphCommand = {
id: 'xflow:datasource-relation',
label: '获取数据源关系数据',
category,
};
/** 查看维度 */
export const VIEW_DIMENSION: IGraphCommand = {
id: 'xflow:view-dimension',
label: '查看维度',
category,
};
}

View File

@@ -0,0 +1,28 @@
import { DeployDagCommand, NsDeployDagCmd } from './CmdDeploy';
import { RenameNodeCommand, NsRenameNodeCmd } from './CmdRenameNodeModal';
import { ConfirmModalCommand, NsConfirmModalCmd } from './CmdConfirmModal';
import {
DataSourceRelationCommand,
NsDataSourceRelationCmd,
} from './CmdUpdateDataSourceRelationList';
import type { ICommandContributionConfig } from '@antv/xflow';
/** 注册成为可以执行的命令 */
export const COMMAND_CONTRIBUTIONS: ICommandContributionConfig[] = [
{
...NsDeployDagCmd,
CommandHandler: DeployDagCommand,
},
{
...NsRenameNodeCmd,
CommandHandler: RenameNodeCommand,
},
{
...NsConfirmModalCmd,
CommandHandler: ConfirmModalCommand,
},
{
...NsDataSourceRelationCmd,
CommandHandler: DataSourceRelationCommand,
},
];

View File

@@ -0,0 +1,266 @@
import type { NsGraphCmd } from '@antv/xflow';
import { createCmdConfig, DisposableCollection, XFlowGraphCommands } from '@antv/xflow';
import type { IApplication } from '@antv/xflow';
import type { IGraphPipelineCommand, IGraphCommandService, NsGraph } from '@antv/xflow';
import { GraphApi } from './service';
import { addDataSourceInfoAsDimensionParents } from './utils';
import { COMMAND_CONTRIBUTIONS } from './CmdExtensions';
import { CustomCommands } from './CmdExtensions/constants';
export const useCmdConfig = createCmdConfig((config) => {
// 注册全局Command扩展
config.setCommandContributions(() => COMMAND_CONTRIBUTIONS);
// 设置hook
config.setRegisterHookFn((hooks) => {
const list = [
hooks.graphMeta.registerHook({
name: 'get graph meta from backend',
handler: async (args) => {
args.graphMetaService = GraphApi.queryGraphMeta;
},
}),
hooks.saveGraphData.registerHook({
name: 'save graph data',
handler: async (args) => {
if (!args.saveGraphDataService) {
args.saveGraphDataService = GraphApi.saveGraphData;
}
},
}),
hooks.addNode.registerHook({
name: 'get node config from backend api',
handler: async (args) => {
args.createNodeService = GraphApi.addNode;
},
}),
hooks.delNode.registerHook({
name: 'get edge config from backend api',
handler: async (args) => {
args.deleteNodeService = GraphApi.delNode;
},
}),
hooks.addEdge.registerHook({
name: '获取起始和结束节点的业务数据,并写入在边上',
handler: async (handlerArgs, handler: any) => {
const { commandService } = handlerArgs;
const main = async (args: any) => {
const res = await handler(args);
if (res && res.edgeCell) {
const sourceNode = res.edgeCell.getSourceNode();
const targetNode = res.edgeCell.getTargetNode();
const sourceNodeData = sourceNode?.getData() || {};
const targetNodeData = targetNode?.getData() || {};
res.edgeCell.setData({
sourceNodeData,
targetNodeData,
source: sourceNodeData.id,
target: targetNodeData.id,
});
// 对边进行tooltips设置
res.edgeCell.addTools([
{
name: 'tooltip',
args: {
tooltip: '左键点击进行关系编辑,右键点击进行删除操作',
},
},
]);
if (commandService) {
const initGraphCmdsState: any = commandService.getGlobal('initGraphCmdsSuccess');
if (initGraphCmdsState) {
// 保存图数据
commandService!.executeCommand<NsGraphCmd.SaveGraphData.IArgs>(
XFlowGraphCommands.SAVE_GRAPH_DATA.id,
{
saveGraphDataService: (meta, graphData) =>
GraphApi.saveGraphData!(meta, graphData),
},
);
}
}
return res;
}
};
return main;
},
}),
hooks.delEdge.registerHook({
name: '边删除,并向后台请求删除数据源间关联关系',
handler: async (args) => {
args.deleteEdgeService = GraphApi.delEdge;
},
}),
];
const toDispose = new DisposableCollection();
toDispose.pushAll(list);
return toDispose;
});
});
/** 查询图的节点和边的数据 */
export const initGraphCmds = async (app: IApplication) => {
const { commandService } = app;
await app.executeCommandPipeline([
/** 1. 从服务端获取数据 */
{
commandId: XFlowGraphCommands.LOAD_DATA.id,
getCommandOption: async () => {
commandService.setGlobal('initGraphCmdsSuccess', false);
return {
args: {
loadDataService: GraphApi.loadDataSourceData,
},
};
},
} as IGraphPipelineCommand<NsGraphCmd.GraphLoadData.IArgs>,
/** 2. 执行布局算法 */
{
commandId: XFlowGraphCommands.GRAPH_LAYOUT.id,
getCommandOption: async (ctx) => {
const { graphData } = ctx.getResult();
return {
args: {
layoutType: 'dagre',
layoutOptions: {
type: 'dagre',
/** 布局方向 */
rankdir: 'LR',
/** 节点间距 */
nodesep: 30,
/** 层间距 */
ranksep: 80,
begin: [0, 0],
},
graphData,
},
};
},
} as IGraphPipelineCommand<NsGraphCmd.GraphLayout.IArgs>,
/** 3. 画布内容渲染 */
{
commandId: XFlowGraphCommands.GRAPH_RENDER.id,
getCommandOption: async (ctx) => {
const { graphData } = ctx.getResult();
const { edges, nodes } = graphData;
const filterClassNodeEdges = edges.filter((item: NsGraph.IEdgeConfig) => {
return !item.source.includes('classNodeId-');
});
const filterClassNodeNodes = nodes.filter((item: NsGraph.INodeConfig) => {
return !item.id.includes('classNodeId-');
});
return {
args: {
graphData: {
edges: filterClassNodeEdges,
nodes: filterClassNodeNodes,
},
},
};
},
} as IGraphPipelineCommand<NsGraphCmd.GraphRender.IArgs>,
/** 4. 缩放画布 */
{
commandId: XFlowGraphCommands.GRAPH_ZOOM.id,
getCommandOption: async () => {
commandService.setGlobal('initGraphCmdsSuccess', true);
return {
args: { factor: 'fit', zoomOptions: { maxScale: 0.9 } },
};
},
} as IGraphPipelineCommand<NsGraphCmd.GraphZoom.IArgs>,
// commandService.executeCommand(CustomCommands.DATASOURCE_RELATION.id, {});
{
commandId: CustomCommands.DATASOURCE_RELATION.id,
getCommandOption: async () => {
return {
args: {},
};
},
},
]);
// const nodes = await app.getAllNodes();
// const classNodes = nodes.filter((item) => {
// return item.id.includes('classNodeId');
// });
// if (classNodes?.[0]) {
// const targetClassId = classNodes[0].id;
// await app.commandService.executeCommand<NsNodeCmd.DelNode.IArgs>(
// XFlowNodeCommands.DEL_NODE.id,
// {
// nodeConfig: { id: targetClassId, type: 'class' },
// },
// );
// }
};
/** 查询当前数据源下的维度节点和边的数据 */
export const initDimensionGraphCmds = async (args: {
commandService: IGraphCommandService;
target: NsGraph.INodeConfig;
}) => {
const { commandService, target } = args;
await commandService.executeCommandPipeline([
{
commandId: XFlowGraphCommands.LOAD_DATA.id,
getCommandOption: async () => {
return {
args: {
loadDataService: GraphApi.loadDimensionData,
},
};
},
} as IGraphPipelineCommand<NsGraphCmd.GraphLoadData.IArgs>,
/** 2. 执行布局算法 */
{
commandId: XFlowGraphCommands.GRAPH_LAYOUT.id,
getCommandOption: async (ctx) => {
const { graphData } = ctx.getResult();
const targetData = {
...target.data,
};
delete targetData.x;
delete targetData.y;
const addGraphData = addDataSourceInfoAsDimensionParents(graphData, targetData);
ctx.setResult(addGraphData);
return {
args: {
layoutType: 'dagre',
layoutOptions: {
type: 'dagre',
/** 布局方向 */
rankdir: 'LR',
/** 节点间距 */
nodesep: 30,
/** 层间距 */
ranksep: 80,
begin: [0, 0],
},
graphData: addGraphData,
},
};
},
} as IGraphPipelineCommand<NsGraphCmd.GraphLayout.IArgs>,
/** 3. 画布内容渲染 */
{
commandId: XFlowGraphCommands.GRAPH_RENDER.id,
getCommandOption: async (ctx) => {
const { graphData } = ctx.getResult();
return {
args: {
graphData,
},
};
},
} as IGraphPipelineCommand<NsGraphCmd.GraphRender.IArgs>,
/** 4. 缩放画布 */
{
commandId: XFlowGraphCommands.GRAPH_ZOOM.id,
getCommandOption: async () => {
return {
args: { factor: 'fit', zoomOptions: { maxScale: 0.9 } },
};
},
} as IGraphPipelineCommand<NsGraphCmd.GraphZoom.IArgs>,
]);
};

View File

@@ -0,0 +1,51 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { uuidv4 } from '@antv/xflow';
import { XFlowNodeCommands } from '@antv/xflow';
import { DATASOURCE_NODE_RENDER_ID } from './constant';
import type { NsNodeCmd } from '@antv/xflow';
import type { NsNodeCollapsePanel } from '@antv/xflow';
import { Card } from 'antd';
export const onNodeDrop: NsNodeCollapsePanel.IOnNodeDrop = async (node, commands, modelService) => {
const args: NsNodeCmd.AddNode.IArgs = {
nodeConfig: { ...node, id: uuidv4() },
};
commands.executeCommand(XFlowNodeCommands.ADD_NODE.id, args);
};
const NodeDescription = (props: { name: string }) => {
return (
<Card size="small" style={{ width: '200px' }} bordered={false}>
</Card>
);
};
export const nodeDataService: NsNodeCollapsePanel.INodeDataService = async (meta, modelService) => {
return [
{
id: '数据源',
header: '数据源',
children: [
{
id: '2',
label: '新增数据源',
parentId: '1',
renderKey: DATASOURCE_NODE_RENDER_ID,
// renderComponent: (props) => (
// <div className="react-dnd-node react-custom-node-1"> {props.data.label} </div>
// ),
popoverContent: <NodeDescription name="数据源" />,
},
],
},
];
};
export const searchService: NsNodeCollapsePanel.ISearchService = async (
nodes: NsNodeCollapsePanel.IPanelNode[] = [],
keyword: string,
) => {
const list = nodes.filter((node) => node.label.includes(keyword));
return list;
};

View File

@@ -0,0 +1,79 @@
import type { IProps } from './index';
import { NsGraph } from '@antv/xflow';
import type { Graph } from '@antv/x6';
import { createHookConfig, DisposableCollection } from '@antv/xflow';
import { DATASOURCE_NODE_RENDER_ID, GROUP_NODE_RENDER_ID } from './constant';
import { AlgoNode } from './ReactNodes/algoNode';
import { GroupNode } from './ReactNodes/group';
export const useGraphHookConfig = createHookConfig<IProps>((config) => {
// 获取 Props
// const props = proxy.getValue();
config.setRegisterHook((hooks) => {
const disposableList = [
// 注册增加 react Node Render
hooks.reactNodeRender.registerHook({
name: 'add react node',
handler: async (renderMap) => {
renderMap.set(DATASOURCE_NODE_RENDER_ID, AlgoNode);
renderMap.set(GROUP_NODE_RENDER_ID, GroupNode);
},
}),
// 注册修改graphOptions配置的钩子
hooks.graphOptions.registerHook({
name: 'custom-x6-options',
after: 'dag-extension-x6-options',
handler: async (options) => {
const graphOptions: Graph.Options = {
connecting: {
allowLoop: false,
// 是否触发交互事件
validateMagnet() {
// return magnet.getAttribute('port-group') !== NsGraph.AnchorGroup.TOP
return true;
},
// 显示可用的链接桩
validateConnection(args: any) {
const { sourceView, targetView, sourceMagnet, targetMagnet } = args;
// 不允许连接到自己
if (sourceView === targetView) {
return false;
}
// 没有起点的返回false
if (!sourceMagnet) {
return false;
}
if (!targetMagnet) {
return false;
}
// 只能从上游节点的输出链接桩创建连接
if (sourceMagnet?.getAttribute('port-group') === NsGraph.AnchorGroup.LEFT) {
return false;
}
// 只能连接到下游节点的输入桩
if (targetMagnet?.getAttribute('port-group') === NsGraph.AnchorGroup.RIGHT) {
return false;
}
const node = targetView!.cell as any;
// 判断目标链接桩是否可连接
const portId = targetMagnet.getAttribute('port')!;
const port = node.getPort(portId);
return !!port;
},
},
};
options.connecting = { ...options.connecting, ...graphOptions.connecting };
},
}),
// hooks.afterGraphInit.registerHook({
// name: '注册toolTips工具',
// handler: async (args) => {},
// }),
];
const toDispose = new DisposableCollection();
toDispose.pushAll(disposableList);
return toDispose;
});
});

View File

@@ -0,0 +1,176 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { NsNodeCmd, NsEdgeCmd, IMenuOptions, NsGraph, NsGraphCmd } from '@antv/xflow';
import type { NsRenameNodeCmd } from './CmdExtensions/CmdRenameNodeModal';
import { createCtxMenuConfig, MenuItemType } from '@antv/xflow';
import { IconStore, XFlowNodeCommands, XFlowEdgeCommands, XFlowGraphCommands } from '@antv/xflow';
import { initDimensionGraphCmds } from './ConfigCmd';
import type { NsConfirmModalCmd } from './CmdExtensions/CmdConfirmModal';
import { NS_DATA_SOURCE_RELATION_MODAL_OPEN_STATE } from './ConfigModelService';
import { DeleteOutlined, EditOutlined, StopOutlined } from '@ant-design/icons';
import { CustomCommands } from './CmdExtensions/constants';
import { GraphApi } from './service';
/** menuitem 配置 */
export namespace NsMenuItemConfig {
/** 注册菜单依赖的icon */
IconStore.set('DeleteOutlined', DeleteOutlined);
IconStore.set('EditOutlined', EditOutlined);
IconStore.set('StopOutlined', StopOutlined);
export const DELETE_EDGE: IMenuOptions = {
id: XFlowEdgeCommands.DEL_EDGE.id,
label: '删除边',
iconName: 'DeleteOutlined',
onClick: async (args) => {
const { target, commandService, modelService } = args;
await commandService.executeCommand<NsEdgeCmd.DelEdge.IArgs>(XFlowEdgeCommands.DEL_EDGE.id, {
edgeConfig: target.data as NsGraph.IEdgeConfig,
});
// 保存数据源关联关系
await commandService.executeCommand(CustomCommands.DATASOURCE_RELATION.id, {});
// 保存图数据
commandService.executeCommand<NsGraphCmd.SaveGraphData.IArgs>(
XFlowGraphCommands.SAVE_GRAPH_DATA.id,
{ saveGraphDataService: (meta, graphData) => GraphApi.saveGraphData!(meta, graphData) },
);
// 关闭设置关联关系弹窗
const modalModel = await modelService!.awaitModel(
NS_DATA_SOURCE_RELATION_MODAL_OPEN_STATE.ID,
);
modalModel.setValue({ open: false });
},
};
export const DELETE_NODE: IMenuOptions = {
id: XFlowNodeCommands.DEL_NODE.id,
label: '删除节点',
iconName: 'DeleteOutlined',
onClick: async ({ target, commandService }) => {
commandService.executeCommand<NsNodeCmd.DelNode.IArgs>(XFlowNodeCommands.DEL_NODE.id, {
nodeConfig: { id: target?.data?.id || '', targetData: target.data },
});
},
};
export const EMPTY_MENU: IMenuOptions = {
id: 'EMPTY_MENU_ITEM',
label: '暂无可用',
isEnabled: false,
iconName: 'DeleteOutlined',
};
export const RENAME_NODE: IMenuOptions = {
id: CustomCommands.SHOW_RENAME_MODAL.id,
label: '重命名',
isVisible: true,
iconName: 'EditOutlined',
onClick: async ({ target, commandService }) => {
const nodeConfig = target.data as NsGraph.INodeConfig;
commandService.executeCommand<NsRenameNodeCmd.IArgs>(CustomCommands.SHOW_RENAME_MODAL.id, {
nodeConfig,
updateNodeNameService: GraphApi.renameNode,
});
},
};
export const DELETE_DATASOURCE_NODE: IMenuOptions = {
id: CustomCommands.SHOW_RENAME_MODAL.id,
label: '删除数据源',
isVisible: true,
iconName: 'EditOutlined',
onClick: async ({ target, commandService }) => {
const nodeConfig = {
...target.data,
modalProps: {
title: '确认删除?',
},
} as NsGraph.INodeConfig;
await commandService.executeCommand<NsConfirmModalCmd.IArgs>(
CustomCommands.SHOW_CONFIRM_MODAL.id,
{
nodeConfig,
confirmModalCallBack: async () => {
await commandService.executeCommand<NsNodeCmd.DelNode.IArgs>(
XFlowNodeCommands.DEL_NODE.id,
{
nodeConfig: {
id: target?.data?.id || '',
type: 'dataSource',
targetData: target.data,
},
},
);
commandService.executeCommand<NsGraphCmd.SaveGraphData.IArgs>(
XFlowGraphCommands.SAVE_GRAPH_DATA.id,
{
saveGraphDataService: (meta, graphData) => GraphApi.saveGraphData!(meta, graphData),
},
);
},
},
);
},
};
export const VIEW_DIMENSION: IMenuOptions = {
id: CustomCommands.VIEW_DIMENSION.id,
label: '查看维度',
isVisible: true,
iconName: 'EditOutlined',
onClick: async (args) => {
const { target, commandService, modelService } = args as any;
initDimensionGraphCmds({ commandService, target });
},
};
export const SEPARATOR: IMenuOptions = {
id: 'separator',
type: MenuItemType.Separator,
};
}
export const useMenuConfig = createCtxMenuConfig((config) => {
config.setMenuModelService(async (target, model, modelService, toDispose) => {
const { type, cell } = target as any;
switch (type) {
/** 节点菜单 */
case 'node':
model.setValue({
id: 'root',
type: MenuItemType.Root,
submenu: [
// NsMenuItemConfig.VIEW_DIMENSION,
// NsMenuItemConfig.SEPARATOR,
// NsMenuItemConfig.DELETE_NODE,
NsMenuItemConfig.DELETE_DATASOURCE_NODE,
// NsMenuItemConfig.RENAME_NODE,
],
});
break;
/** 边菜单 */
case 'edge':
model.setValue({
id: 'root',
type: MenuItemType.Root,
submenu: [NsMenuItemConfig.DELETE_EDGE],
});
break;
/** 画布菜单 */
case 'blank':
model.setValue({
id: 'root',
type: MenuItemType.Root,
submenu: [NsMenuItemConfig.EMPTY_MENU],
});
break;
/** 默认菜单 */
default:
model.setValue({
id: 'root',
type: MenuItemType.Root,
submenu: [NsMenuItemConfig.EMPTY_MENU],
});
break;
}
});
});

View File

@@ -0,0 +1,33 @@
import type { Disposable, IModelService } from '@antv/xflow';
import { createModelServiceConfig, DisposableCollection } from '@antv/xflow';
export namespace NS_DATA_SOURCE_RELATION_MODAL_OPEN_STATE {
export const ID = 'NS_DATA_SOURCE_RELATION_MODAL_OPEN_STATE';
// export const id = 'NS_DATA_SOURCE_RELATION_MODAL_OPEN_STATE';
export interface IState {
open: boolean;
}
}
export const useModelServiceConfig = createModelServiceConfig((config) => {
config.registerModel((registry) => {
const list: Disposable[] = [
registry.registerModel({
id: NS_DATA_SOURCE_RELATION_MODAL_OPEN_STATE.ID,
// getInitialValue: () => {
// open: false;
// },
}),
];
const toDispose = new DisposableCollection();
toDispose.pushAll(list);
return toDispose;
});
});
export const useOpenState = async (contextService: IModelService) => {
const ctx = await contextService.awaitModel<NS_DATA_SOURCE_RELATION_MODAL_OPEN_STATE.IState>(
NS_DATA_SOURCE_RELATION_MODAL_OPEN_STATE.ID,
);
return ctx.getValidValue();
};

View File

@@ -0,0 +1,242 @@
import type { IToolbarItemOptions } from '@antv/xflow';
import { createToolbarConfig } from '@antv/xflow';
import type { IModelService } from '@antv/xflow';
import {
XFlowGraphCommands,
XFlowDagCommands,
NsGraphStatusCommand,
MODELS,
IconStore,
} from '@antv/xflow';
import {
UngroupOutlined,
SaveOutlined,
CloudSyncOutlined,
GroupOutlined,
GatewayOutlined,
PlaySquareOutlined,
StopOutlined,
} from '@ant-design/icons';
import { GraphApi } from './service';
import type { NsGraphCmd } from '@antv/xflow';
import { Radio } from 'antd';
export namespace NSToolbarConfig {
/** 注册icon 类型 */
IconStore.set('SaveOutlined', SaveOutlined);
IconStore.set('CloudSyncOutlined', CloudSyncOutlined);
IconStore.set('GatewayOutlined', GatewayOutlined);
IconStore.set('GroupOutlined', GroupOutlined);
IconStore.set('UngroupOutlined', UngroupOutlined);
IconStore.set('PlaySquareOutlined', PlaySquareOutlined);
IconStore.set('StopOutlined', StopOutlined);
/** toolbar依赖的状态 */
export interface IToolbarState {
isMultiSelectionActive: boolean;
isNodeSelected: boolean;
isGroupSelected: boolean;
isProcessing: boolean;
}
export const getDependencies = async (modelService: IModelService) => {
return [
await MODELS.SELECTED_CELLS.getModel(modelService),
await MODELS.GRAPH_ENABLE_MULTI_SELECT.getModel(modelService),
await NsGraphStatusCommand.MODEL.getModel(modelService),
];
};
/** toolbar依赖的状态 */
export const getToolbarState = async (modelService: IModelService) => {
// isMultiSelectionActive
const { isEnable: isMultiSelectionActive } = await MODELS.GRAPH_ENABLE_MULTI_SELECT.useValue(
modelService,
);
// isGroupSelected
const isGroupSelected = await MODELS.IS_GROUP_SELECTED.useValue(modelService);
// isNormalNodesSelected: node不能是GroupNode
const isNormalNodesSelected = await MODELS.IS_NORMAL_NODES_SELECTED.useValue(modelService);
// statusInfo
const statusInfo = await NsGraphStatusCommand.MODEL.useValue(modelService);
return {
isNodeSelected: isNormalNodesSelected,
isGroupSelected,
isMultiSelectionActive,
isProcessing: statusInfo.graphStatus === NsGraphStatusCommand.StatusEnum.PROCESSING,
} as NSToolbarConfig.IToolbarState;
};
export const getToolbarItems = async () => {
const toolbarGroup1: IToolbarItemOptions[] = [];
const toolbarGroup2: IToolbarItemOptions[] = [];
const toolbarGroup3: IToolbarItemOptions[] = [];
/** 保存数据 */
toolbarGroup1.push({
id: XFlowGraphCommands.SAVE_GRAPH_DATA.id,
iconName: 'SaveOutlined',
tooltip: '保存数据',
onClick: async ({ commandService }) => {
commandService.executeCommand<NsGraphCmd.SaveGraphData.IArgs>(
XFlowGraphCommands.SAVE_GRAPH_DATA.id,
{ saveGraphDataService: (meta, graphData) => GraphApi.saveGraphData!(meta, graphData) },
);
},
});
// /** 部署服务按钮 */
// toolbarGroup1.push({
// iconName: 'CloudSyncOutlined',
// tooltip: '部署服务',
// id: CustomCommands.DEPLOY_SERVICE.id,
// onClick: ({ commandService }) => {
// commandService.executeCommand<NsDeployDagCmd.IArgs>(CustomCommands.DEPLOY_SERVICE.id, {
// deployDagService: (meta, graphData) => GraphApi.deployDagService(meta, graphData),
// });
// },
// });
// /** 开启框选 */
// toolbarGroup2.push({
// id: XFlowGraphCommands.GRAPH_TOGGLE_MULTI_SELECT.id,
// tooltip: '开启框选',
// iconName: 'GatewayOutlined',
// active: state.isMultiSelectionActive,
// onClick: async ({ commandService }) => {
// commandService.executeCommand<NsGraphCmd.GraphToggleMultiSelect.IArgs>(
// XFlowGraphCommands.GRAPH_TOGGLE_MULTI_SELECT.id,
// {},
// );
// },
// });
// /** 新建群组 */
// toolbarGroup2.push({
// id: XFlowGroupCommands.ADD_GROUP.id,
// tooltip: '新建群组',
// iconName: 'GroupOutlined',
// isEnabled: state.isNodeSelected,
// onClick: async ({ commandService, modelService }) => {
// const cells = await MODELS.SELECTED_CELLS.useValue(modelService);
// const groupChildren = cells.map((cell) => cell.id);
// commandService.executeCommand<NsGroupCmd.AddGroup.IArgs>(XFlowGroupCommands.ADD_GROUP.id, {
// nodeConfig: {
// id: uuidv4(),
// renderKey: GROUP_NODE_RENDER_ID,
// groupChildren,
// groupCollapsedSize: { width: 200, height: 40 },
// label: '新建群组',
// },
// });
// },
// });
// /** 解散群组 */
// toolbarGroup2.push({
// id: XFlowGroupCommands.DEL_GROUP.id,
// tooltip: '解散群组',
// iconName: 'UngroupOutlined',
// isEnabled: state.isGroupSelected,
// onClick: async ({ commandService, modelService }) => {
// const cell = await MODELS.SELECTED_NODE.useValue(modelService);
// const nodeConfig = cell.getData();
// commandService.executeCommand<NsGroupCmd.AddGroup.IArgs>(XFlowGroupCommands.DEL_GROUP.id, {
// nodeConfig: nodeConfig,
// });
// },
// });
// toolbarGroup3.push({
// id: XFlowDagCommands.QUERY_GRAPH_STATUS.id + 'play',
// tooltip: '开始执行',
// iconName: 'PlaySquareOutlined',
// isEnabled: !state.isProcessing,
// onClick: async ({ commandService }) => {
// commandService.executeCommand<NsGraphStatusCommand.IArgs>(
// XFlowDagCommands.QUERY_GRAPH_STATUS.id,
// {
// graphStatusService: GraphApi.graphStatusService,
// loopInterval: 3000,
// },
// );
// },
// });
// toolbarGroup3.push({
// id: XFlowDagCommands.QUERY_GRAPH_STATUS.id + 'stop',
// tooltip: '停止执行',
// iconName: 'StopOutlined',
// isEnabled: state.isProcessing,
// onClick: async ({ commandService }) => {
// commandService.executeCommand<NsGraphStatusCommand.IArgs>(
// XFlowDagCommands.QUERY_GRAPH_STATUS.id,
// {
// graphStatusService: GraphApi.stopGraphStatusService,
// loopInterval: 5000,
// },
// );
// },
// render: (props) => {
// return (
// <Popconfirm
// title="确定停止执行?"
// onConfirm={() => {
// props.onClick();
// }}
// >
// {props.children}
// </Popconfirm>
// );
// },
// });
return [
{ name: 'graphData', items: toolbarGroup1 },
{ name: 'groupOperations', items: toolbarGroup2 },
{
name: 'customCmd',
items: toolbarGroup3,
},
];
};
}
export const getExtraToolbarItems = async () => {
const toolbarGroup: IToolbarItemOptions[] = [];
/** 保存数据 */
toolbarGroup.push({
id: XFlowDagCommands.QUERY_GRAPH_STATUS.id + 'switchShowType',
render: () => {
return (
<Radio.Group defaultValue="dataSource" buttonStyle="solid" size="small">
<Radio.Button value="dataSource"></Radio.Button>
<Radio.Button value="dimension"></Radio.Button>
<Radio.Button value="metric"></Radio.Button>
</Radio.Group>
);
},
// text: '添加节点',
// tooltip: '添加节点配置extraGroups',
});
return [{ name: 'extra', items: toolbarGroup }];
};
export const useToolbarConfig = createToolbarConfig((toolbarConfig) => {
/** 生产 toolbar item */
toolbarConfig.setToolbarModelService(async (toolbarModel, modelService, toDispose) => {
const updateToolbarModel = async () => {
const state = await NSToolbarConfig.getToolbarState(modelService);
const toolbarItems = await NSToolbarConfig.getToolbarItems(state);
// const extraToolbarItems = await getExtraToolbarItems();
toolbarModel.setValue((toolbar) => {
toolbar.mainGroups = toolbarItems;
// toolbar.extraGroups = extraToolbarItems;
});
};
const models = await NSToolbarConfig.getDependencies(modelService);
const subscriptions = models.map((model) => {
return model.watch(async () => {
updateToolbarModel();
});
});
toDispose.pushAll(subscriptions);
});
});

View File

@@ -0,0 +1,85 @@
import React from 'react';
import ReactDom from 'react-dom';
import { Tooltip } from 'antd';
import type { EdgeView } from '@antv/x6';
import { Graph, ToolsView } from '@antv/x6';
class TooltipTool extends ToolsView.ToolItem<EdgeView, TooltipToolOptions> {
private knob: HTMLDivElement;
render() {
if (!this.knob) {
this.knob = ToolsView.createElement('div', false) as HTMLDivElement;
this.knob.style.position = 'absolute';
this.container.appendChild(this.knob);
}
return this;
}
private toggleTooltip(visible: boolean) {
if (this.knob) {
ReactDom.unmountComponentAtNode(this.knob);
if (visible) {
ReactDom.render(
<Tooltip title={this.options.tooltip} open={visible} destroyTooltipOnHide>
<div />
</Tooltip>,
this.knob,
);
}
}
}
private onMosueEnter({ e }: { e: MouseEvent }) {
this.updatePosition(e);
this.toggleTooltip(true);
}
private onMouseLeave() {
this.updatePosition();
this.toggleTooltip(false);
}
private onMouseMove() {
this.updatePosition();
this.toggleTooltip(false);
}
delegateEvents() {
this.cellView.on('cell:mouseenter', this.onMosueEnter, this);
this.cellView.on('cell:mouseleave', this.onMouseLeave, this);
this.cellView.on('cell:mousemove', this.onMouseMove, this);
return super.delegateEvents();
}
private updatePosition(e?: MouseEvent) {
const style = this.knob.style;
if (e) {
const p = this.graph.clientToGraph(e.clientX, e.clientY);
style.display = 'block';
style.left = `${p.x}px`;
style.top = `${p.y}px`;
} else {
style.display = 'none';
style.left = '-1000px';
style.top = '-1000px';
}
}
protected onRemove() {
this.toggleTooltip(false);
this.cellView.off('cell:mouseenter', this.onMosueEnter, this);
this.cellView.off('cell:mouseleave', this.onMouseLeave, this);
this.cellView.off('cell:mousemove', this.onMouseMove, this);
}
}
TooltipTool.config({
tagName: 'div',
isSVGElement: false,
});
export interface TooltipToolOptions extends ToolsView.ToolItem.Options {
tooltip?: string;
}
Graph.registerEdgeTool('tooltip', TooltipTool, true);

View File

@@ -0,0 +1,101 @@
@light-border: 1px solid #d9d9d9;
@primaryColor: #c2c8d5;
.xflow-algo-node {
z-index: 10;
display: flex;
width: 180px;
height: 36px;
line-height: 36px;
text-align: center;
background-color: #fff;
border: 1px solid @primaryColor;
border-radius: 2px;
box-shadow: ~'-1px -1px 4px 0 rgba(223,223,223,0.50), -2px 2px 4px 0 rgba(244,244,244,0.50), 2px 3px 8px 2px rgba(151,151,151,0.05)';
transition: all ease-in-out 0.15s;
&:hover {
background-color: #fff;
border: 1px solid #3057e3;
// border: 1px solid @primaryColor;
box-shadow: 0 0 3px 3px rgba(48, 86, 227, 0.15);
cursor: move;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
}
.label {
width: 108px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-word;
}
.status {
width: 36px;
}
&.panel-node {
border: 0;
}
}
.x6-node-selected {
.xflow-algo-node {
background-color: rgba(48, 86, 227, 0.05);
border: 1px solid #3057e3;
box-shadow: 0 0 3px 3px rgba(48, 86, 227, 0.15);
&:hover {
background-color: #fff;
box-shadow: 0 0 5px 5px rgba(48, 86, 227, 0.15);
}
}
}
.dag-solution-layout {
.xflow-canvas-root {
.xflow-algo-node {
height: 72px !important;
line-height: 72px !important;
}
}
}
.dataSourceTooltipWrapper {
max-width: 500px;
.ant-tooltip-inner {
padding:0;
}
.dataSourceTooltip {
width: 300px;
background: #fff;
border-radius: 5px;
color: #4d4d4d;
padding: 15px;
opacity: .9;
font-size: 11px;
box-shadow: 0 0 5px #d8d8d8;
p {
margin-bottom: 0;
font-size: 13px;
padding: 4px;
line-height: 18px;
overflow: hidden;
display: flex;
flex-direction: row;
.dataSourceTooltipLabel {
display: block;
width: 64px;
color: grey;
}
.dataSourceTooltipValue{
flex: 1 1;
display: block;
width: 220px;
padding: 0 4px;
word-break: break-all;
}
}
}
}

View File

@@ -0,0 +1,112 @@
import React from 'react';
import {
DatabaseOutlined,
RedoOutlined,
CloseCircleOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import type { NsGraph } from '@antv/xflow';
import { NsGraphStatusCommand } from '@antv/xflow';
import { Tooltip } from 'antd';
import moment from 'moment';
import './algoNode.less';
const fontStyle = { fontSize: '16px', color: '#3057e3' };
interface IProps {
status: NsGraphStatusCommand.StatusEnum;
hide: boolean;
}
export const AlgoIcon: React.FC<IProps> = (props) => {
if (props.hide) {
return null;
}
switch (props.status) {
case NsGraphStatusCommand.StatusEnum.PROCESSING:
return <RedoOutlined spin style={{ color: '#c1cdf7', fontSize: '16px' }} />;
case NsGraphStatusCommand.StatusEnum.ERROR:
return <CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: '16px' }} />;
case NsGraphStatusCommand.StatusEnum.SUCCESS:
return <CheckCircleOutlined style={{ color: '#39ca74cc', fontSize: '16px' }} />;
case NsGraphStatusCommand.StatusEnum.WARNING:
return <ExclamationCircleOutlined style={{ color: '#faad14', fontSize: '16px' }} />;
case NsGraphStatusCommand.StatusEnum.DEFAULT:
return <InfoCircleOutlined style={{ color: '#d9d9d9', fontSize: '16px' }} />;
default:
return null;
}
};
export const AlgoNode: NsGraph.INodeRender = (props) => {
const { data } = props;
const dataSourceData = data.payload;
const openState = dataSourceData ? undefined : false;
let tooltipNode = <></>;
if (dataSourceData) {
const { name, id, bizName, description, createdBy, updatedAt } = dataSourceData;
const labelList = [
{
label: '数据源ID',
value: id,
},
{
label: '名称',
value: name,
},
{
label: '英文名',
value: bizName,
},
{
label: '创建人',
value: createdBy,
},
{
label: '更新时间',
value: updatedAt ? moment(updatedAt).format('YYYY-MM-DD HH:mm:ss') : '-',
},
{
label: '描述',
value: description,
},
];
tooltipNode = (
<div className="dataSourceTooltip">
{labelList.map(({ label, value }) => {
return (
<p key={value}>
<span className="dataSourceTooltipLabel">{label}:</span>
<span className="dataSourceTooltipValue">{value || '-'}</span>
</p>
);
})}
</div>
);
}
return (
<div className={`xflow-algo-node ${props.isNodeTreePanel ? 'panel-node' : ''}`}>
<span className="icon">
<DatabaseOutlined style={fontStyle} />
</span>
<span className="label">
<Tooltip
open={openState}
title={tooltipNode}
placement="right"
color="#fff"
overlayClassName="dataSourceTooltipWrapper"
>
{props.data.label}
</Tooltip>
</span>
<span className="status">
<AlgoIcon status={props.data && props.data.status} hide={props.isNodeTreePanel} />
</span>
</div>
);
};

View File

@@ -0,0 +1,50 @@
@light-border: 1px solid #d9d9d9;
@primaryColor: #c1cdf7;
.xflow-group-node {
z-index: 9;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.65);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 4px;
box-shadow: ~'rgb(17 49 96 / 12%) 0px 1px 3px 0px, rgb(17 49 96 / 4%) 0px 0px 0px 1px';
cursor: grab;
&:hover {
background-color: rgba(227, 244, 255, 0.45);
border: 1px solid @primaryColor;
box-shadow: 0 0 3px 3px rgba(64, 169, 255, 0.2);
cursor: move;
}
.xflow-group-header {
display: flex;
justify-content: space-between;
padding: 0 12px;
font-size: 14px;
line-height: 38px;
.header-left {
width: 80%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.header-right {
display: inline-flex;
align-items: center;
span.anticon {
margin-left: 8px;
}
}
}
}
.x6-node-selected {
.xflow-group-node {
background-color: rgba(243, 249, 255, 0.92);
border: 1px solid @primaryColor;
box-shadow: 0 0 3px 3px rgb(64 169 255 / 20%);
&:hover {
background-color: rgba(243, 249, 255, 0.6);
}
}
}

View File

@@ -0,0 +1,37 @@
import { PlusSquareOutlined, MinusSquareOutlined } from '@ant-design/icons';
import type { NsGraph } from '@antv/xflow';
import { useXFlowApp, XFlowGroupCommands } from '@antv/xflow';
import './group.less';
export const GroupNode: NsGraph.INodeRender = (props) => {
const { cell } = props;
const app = useXFlowApp();
const isCollapsed = props.data.isCollapsed || false;
const onExpand = () => {
app.executeCommand(XFlowGroupCommands.COLLAPSE_GROUP.id, {
nodeId: cell.id,
isCollapsed: false,
collapsedSize: { width: 200, height: 40 },
});
};
const onCollapse = () => {
app.executeCommand(XFlowGroupCommands.COLLAPSE_GROUP.id, {
nodeId: cell.id,
isCollapsed: true,
collapsedSize: { width: 200, height: 40 },
gap: 3,
});
};
return (
<div className="xflow-group-node">
<div className="xflow-group-header">
<div className="header-left">{props.data.label}</div>
<div className="header-right">
{isCollapsed && <PlusSquareOutlined onClick={onExpand} />}
{!isCollapsed && <MinusSquareOutlined onClick={onCollapse} />}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,166 @@
import React, { useEffect, useState } from 'react';
import { Form, Button, Drawer, Space, Input, Select, message } from 'antd';
import { formLayout } from '@/components/FormHelper/utils';
import { createOrUpdateDatasourceRela } from '../../service';
import { getRelationConfigInfo } from '../utils';
import { useXFlowApp } from '@antv/xflow';
import { CustomCommands } from '../CmdExtensions/constants';
export type DataSourceRelationFormDrawerProps = {
domainId: number;
nodeDataSource: any;
open: boolean;
onClose?: () => void;
};
const FormItem = Form.Item;
const { Option } = Select;
const DataSourceRelationFormDrawer: React.FC<DataSourceRelationFormDrawerProps> = ({
domainId,
open,
nodeDataSource,
onClose,
}) => {
const [form] = Form.useForm();
const [saveLoading, setSaveLoading] = useState(false);
const [dataSourceOptions, setDataSourceOptions] = useState<any[]>([]);
const app = useXFlowApp();
const getRelationListInfo = async () => {
await app.commandService.executeCommand(CustomCommands.DATASOURCE_RELATION.id, {});
};
useEffect(() => {
const { sourceData, targetData } = nodeDataSource;
const dataSourceFromIdentifiers = sourceData?.datasourceDetail?.identifiers || [];
const dataSourceToIdentifiers = targetData?.datasourceDetail?.identifiers || [];
const dataSourceToIdentifiersNames = dataSourceToIdentifiers.map((item) => {
return item.name;
});
const keyOptions = dataSourceFromIdentifiers.reduce((options: any[], item: any) => {
const { name } = item;
if (dataSourceToIdentifiersNames.includes(name)) {
options.push(item);
}
return options;
}, []);
setDataSourceOptions(
keyOptions.map((item: any) => {
const { name } = item;
return {
label: name,
value: name,
};
}),
);
}, [nodeDataSource]);
useEffect(() => {
const { sourceData, targetData } = nodeDataSource;
if (!sourceData || !targetData) {
return;
}
const relationList = app.commandService.getGlobal('dataSourceRelationList') || [];
const config = getRelationConfigInfo(sourceData.id, targetData.id, relationList);
if (config) {
form.setFieldsValue({
joinKey: config.joinKey,
});
} else {
form.setFieldsValue({
joinKey: '',
});
}
}, [nodeDataSource]);
const renderContent = () => {
return (
<>
<FormItem hidden={true} name="id" label="ID">
<Input placeholder="id" />
</FormItem>
<FormItem label="主数据源:">{nodeDataSource?.sourceData?.name}</FormItem>
<FormItem label="关联数据源:">{nodeDataSource?.targetData?.name}</FormItem>
<FormItem
name="joinKey"
label="可关联Key:"
tooltip="主从数据源中必须具有相同的主键或外键才可建立关联关系"
rules={[{ required: true, message: '请选择关联Key' }]}
>
<Select placeholder="请选择关联Key">
{dataSourceOptions.map((item) => (
<Option key={item.value} value={item.value}>
{item.label}
</Option>
))}
</Select>
</FormItem>
</>
);
};
const saveRelation = async () => {
const values = await form.validateFields();
setSaveLoading(true);
const { code, msg } = await createOrUpdateDatasourceRela({
domainId,
datasourceFrom: nodeDataSource?.sourceData?.id,
datasourceTo: nodeDataSource?.targetData?.id,
...values,
});
setSaveLoading(false);
if (code === 200) {
message.success('保存成功');
getRelationListInfo();
onClose?.();
return;
}
message.error(msg);
};
const renderFooter = () => {
return (
<Space>
<Button
onClick={() => {
onClose?.();
}}
>
</Button>
<Button
type="primary"
loading={saveLoading}
onClick={() => {
saveRelation();
}}
>
</Button>
</Space>
);
};
return (
<Drawer
forceRender
width={400}
getContainer={false}
title={'数据源关联信息'}
mask={false}
open={open}
footer={renderFooter()}
onClose={() => {
onClose?.();
}}
>
<Form {...formLayout} form={form}>
{renderContent()}
</Form>
</Drawer>
);
};
export default DataSourceRelationFormDrawer;

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { WorkspacePanel } from '@antv/xflow';
import type { NsJsonSchemaForm } from '@antv/xflow';
import XflowJsonSchemaFormDrawerForm from './XflowJsonSchemaFormDrawerForm';
export type CreateFormProps = {
controlMapService?: any;
formSchemaService?: any;
formValueUpdateService?: any;
};
const XflowJsonSchemaFormDrawer: React.FC<CreateFormProps> = ({
controlMapService,
formSchemaService,
formValueUpdateService,
}) => {
const defaultFormValueUpdateService: NsJsonSchemaForm.IFormValueUpdateService = async () => {};
const defaultFormSchemaService: NsJsonSchemaForm.IFormSchemaService = async () => {
return { tabs: [] };
};
const defaultControlMapService: NsJsonSchemaForm.IControlMapService = (controlMap) => {
return controlMap;
};
return (
<WorkspacePanel position={{}}>
<XflowJsonSchemaFormDrawerForm
controlMapService={controlMapService || defaultControlMapService}
formSchemaService={formSchemaService || defaultFormSchemaService}
formValueUpdateService={formValueUpdateService || defaultFormValueUpdateService}
/>
</WorkspacePanel>
);
};
export default XflowJsonSchemaFormDrawer;

View File

@@ -0,0 +1,125 @@
import React, { useEffect, useState } from 'react';
import { Drawer } from 'antd';
import { WorkspacePanel, useXFlowApp, useModelAsync, XFlowGraphCommands } from '@antv/xflow';
import { useJsonSchemaFormModel } from '@antv/xflow-extension/es/canvas-json-schema-form/service';
import { NS_DATA_SOURCE_RELATION_MODAL_OPEN_STATE } from '../ConfigModelService';
import { connect } from 'umi';
import { DATASOURCE_NODE_RENDER_ID } from '../constant';
import DataSourceRelationFormDrawer from './DataSourceRelationFormDrawer';
import { GraphApi } from '../service';
import type { StateType } from '../../model';
import DataSource from '../../Datasource';
export type CreateFormProps = {
controlMapService: any;
formSchemaService: any;
formValueUpdateService: any;
domainManger: StateType;
};
const XflowJsonSchemaFormDrawerForm: React.FC<CreateFormProps> = (props) => {
const { domainManger } = props;
const [visible, setVisible] = useState(false);
const [createModalVisible, setCreateModalVisible] = useState<boolean>(false);
const [dataSourceItem, setDataSourceItem] = useState<any>();
const [nodeDataSource, setNodeDataSource] = useState<any>({
sourceData: {},
targetData: {},
});
const app = useXFlowApp();
// 借用JsonSchemaForm钩子函数对元素状态进行监听
const { state, commandService, modelService } = useJsonSchemaFormModel({
...props,
targetType: ['node', 'edge', 'canvas', 'group'],
position: {},
});
const [modalOpenState] = useModelAsync({
getModel: async () => {
return await modelService.awaitModel(NS_DATA_SOURCE_RELATION_MODAL_OPEN_STATE.ID);
},
initialState: false,
});
useEffect(() => {
const { open } = modalOpenState as any;
setVisible(open);
}, [modalOpenState]);
useEffect(() => {
const { targetType, targetData } = state;
if (targetType && ['node', 'edge'].includes(targetType)) {
const { renderKey, payload } = targetData as any;
if (renderKey === DATASOURCE_NODE_RENDER_ID) {
setDataSourceItem(payload);
setCreateModalVisible(true);
} else {
const { sourceNodeData, targetNodeData } = targetData as any;
setNodeDataSource({
sourceData: sourceNodeData.payload,
targetData: targetNodeData.payload,
});
setVisible(true);
}
}
}, [state]);
const resetSelectedNode = async () => {
const x6Graph = await app.graphProvider.getGraphInstance();
x6Graph.resetSelection();
};
const handleDataSourceRelationDrawerClose = () => {
resetSelectedNode();
setVisible(false);
};
return (
<WorkspacePanel position={{}}>
<DataSourceRelationFormDrawer
domainId={domainManger.selectDomainId}
nodeDataSource={nodeDataSource}
onClose={() => {
handleDataSourceRelationDrawerClose();
}}
open={visible}
/>
<Drawer
width={'100%'}
destroyOnClose
title="数据源编辑"
open={createModalVisible}
onClose={() => {
resetSelectedNode();
setCreateModalVisible(false);
setDataSourceItem(undefined);
}}
footer={null}
>
<DataSource
initialValues={dataSourceItem}
domainId={Number(domainManger?.selectDomainId)}
onSubmitSuccess={(dataSourceInfo: any) => {
setCreateModalVisible(false);
const { targetCell, targetData } = state;
targetCell?.setData({
...targetData,
label: dataSourceInfo.name,
payload: dataSourceInfo,
id: `dataSource-${dataSourceInfo.id}`,
});
setDataSourceItem(undefined);
commandService.executeCommand(XFlowGraphCommands.SAVE_GRAPH_DATA.id, {
saveGraphDataService: (meta, graphData) => GraphApi.saveGraphData!(meta, graphData),
});
}}
/>
</Drawer>
</WorkspacePanel>
);
};
export default connect(({ domainManger }: { domainManger: StateType }) => ({
domainManger,
}))(XflowJsonSchemaFormDrawerForm);

View File

@@ -0,0 +1,5 @@
export const DND_RENDER_ID = 'DND_NDOE';
export const GROUP_NODE_RENDER_ID = 'GROUP_NODE_RENDER_ID';
export const DATASOURCE_NODE_RENDER_ID = 'DATASOURCE_NODE';
export const NODE_WIDTH = 180;
export const NODE_HEIGHT = 72;

View File

@@ -0,0 +1,29 @@
import type { ISODateString, GraphConfigType, UserName } from '../data';
import type { NsGraph } from '@antv/xflow';
export type GraphConfigListItem = {
id: number;
domainId: number;
config: string;
type: GraphConfigType;
createdAt: ISODateString;
createdBy: UserName;
updatedAt: ISODateString;
updatedBy: UserName;
};
export type GraphConfig = { id: number; config: NsGraph.IGraphData };
export type RelationListItem = {
id: number;
domainId: number;
datasourceFrom: number;
datasourceTo: number;
joinKey: string;
createdAt: string;
createdBy: string;
updatedAt: string;
updatedBy: string;
};
export type RelationList = RelationListItem[];

View File

@@ -0,0 +1,103 @@
@body-bg: #fafafa;
@primaryColor: #3056e3;
@light-border: 1px solid #d9d9d9;
.dag-solution {
.__dumi-default-previewer-actions {
border: 0;
}
}
.dag-solution-layout {
position: relative;
height: 610px;
border: @light-border;
.xflow-x6-canvas {
background: @body-bg;
}
.x6-edge {
&:hover {
path:nth-child(2) {
stroke: @primaryColor;
stroke-width: 2px;
}
}
&.x6-edge-selected {
path:nth-child(2) {
stroke: @primaryColor;
stroke-width: 2px;
}
}
}
.xflow-canvas-dnd-node-tree {
border-right: @light-border;
}
.xflow-workspace-toolbar-top {
background-image: ~'linear-gradient(180deg, #ffffff 0%, #fafafa 100%)';
border-bottom: @light-border;
}
.xflow-workspace-toolbar-bottom {
text-align: center;
background: #fff;
border-top: @light-border;
}
.xflow-modal-container {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1000;
}
.xflow-collapse-panel {
.xflow-collapse-panel-header {
display: flex;
align-items: center;
justify-content: space-evenly;
background: #f7f8fa;
.ant-input-affix-wrapper {
padding: 2px 11px;
}
}
.xflow-collapse-panel-body {
background: #f7f8fa;
.xflow-collapse-header {
padding: 12px 8px;
}
}
.xflow-node-dnd-panel-footer {
display: none;
}
}
.xflow-json-form .tabs .ant-tabs-nav {
box-shadow: unset;
}
// .xflow-json-schema-form {
// .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
// color: #525252;
// font-weight: 300 !important;
// }
// .xflow-json-schema-form-footer {
// display: none;
// }
// .xflow-json-form .tabs.xTab .ant-tabs-nav .ant-tabs-nav-list,
// .xflow-json-form .tabs.xTab .ant-tabs-nav .ant-tabs-nav-list .ant-tabs-tab {
// background: #f7f8fa;
// }
// .xflow-json-schema-form-body {
// position: relative;
// width: 100%;
// height: 100%;
// background: #f7f8fa;
// box-shadow: 0 1px 1px 0 rgb(206 201 201 / 50%);
// }
// }
}

View File

@@ -0,0 +1,151 @@
import React, { useState, useEffect } from 'react';
/** app 核心组件 */
import { XFlow, XFlowCanvas, XFlowGraphCommands } from '@antv/xflow';
import type { IApplication, IAppLoad, NsGraph, NsGraphCmd } from '@antv/xflow';
/** 交互组件 */
import {
/** 触发Command的交互组件 */
CanvasScaleToolbar,
NodeCollapsePanel,
CanvasContextMenu,
CanvasToolbar,
/** Graph的扩展交互组件 */
CanvasSnapline,
CanvasNodePortTooltip,
DagGraphExtension,
} from '@antv/xflow';
/** app 组件配置 */
/** 配置画布 */
import { useGraphHookConfig } from './ConfigGraph';
/** 配置Command */
import { useCmdConfig, initGraphCmds } from './ConfigCmd';
/** 配置Model */
import { useModelServiceConfig } from './ConfigModelService';
/** 配置Menu */
import { useMenuConfig } from './ConfigMenu';
/** 配置Toolbar */
import { useToolbarConfig } from './ConfigToolbar';
/** 配置Dnd组件面板 */
import * as dndPanelConfig from './ConfigDndPanel';
import { connect } from 'umi';
import type { StateType } from '../model';
import './index.less';
import XflowJsonSchemaFormDrawer from './components/XflowJsonSchemaFormDrawer';
import { getViewInfoList } from '../service';
import { getGraphConfigFromList } from './utils';
import type { GraphConfig } from './data';
import '@antv/xflow/dist/index.css';
import './ReactNodes/ToolTipsNode';
export interface IProps {
domainManger: StateType;
}
export const SemanticFlow: React.FC<IProps> = (props) => {
const { domainManger } = props;
const graphHooksConfig = useGraphHookConfig(props);
const toolbarConfig = useToolbarConfig();
const menuConfig = useMenuConfig();
const cmdConfig = useCmdConfig();
const modelServiceConfig = useModelServiceConfig();
const [graphConfig, setGraphConfig] = useState<GraphConfig>();
const [meta, setMeta] = useState<NsGraph.IGraphMeta>({
flowId: 'semanticFlow',
domainManger,
});
const cache =
React.useMemo<{ app: IApplication } | null>(
() => ({
app: null as any,
}),
[],
) || ({} as any);
const queryGraphConfig = async () => {
const { code, data } = await getViewInfoList(domainManger.selectDomainId);
if (code === 200) {
const config = getGraphConfigFromList(data);
setGraphConfig(config || ({} as GraphConfig));
}
};
useEffect(() => {
queryGraphConfig();
}, [domainManger.selectDomainId]);
useEffect(() => {
setMeta({
...meta,
domainManger,
graphConfig,
});
}, [graphConfig]);
/**
* @param app 当前XFlow工作空间
*/
const onLoad: IAppLoad = async (app) => {
cache.app = app;
initGraphCmds(cache.app);
};
const updateGraph = async (app: IApplication) => {
await app.executeCommand(XFlowGraphCommands.LOAD_META.id, {
meta,
} as NsGraphCmd.GraphMeta.IArgs);
initGraphCmds(app);
};
/** 父组件meta属性更新时,执行initGraphCmds */
React.useEffect(() => {
if (cache.app) {
updateGraph(cache.app);
}
}, [cache.app, meta]);
return (
<div id="semanticFlowContainer" style={{ height: '100%' }}>
{meta.graphConfig && (
<XFlow
className="dag-user-custom-clz dag-solution-layout"
hookConfig={graphHooksConfig}
modelServiceConfig={modelServiceConfig}
commandConfig={cmdConfig}
onLoad={onLoad}
meta={meta}
>
<DagGraphExtension layout="LR" />
<NodeCollapsePanel
className="xflow-node-panel"
searchService={dndPanelConfig.searchService}
nodeDataService={dndPanelConfig.nodeDataService}
onNodeDrop={dndPanelConfig.onNodeDrop}
position={{ width: 230, top: 0, bottom: 0, left: 0 }}
footerPosition={{ height: 0 }}
bodyPosition={{ top: 40, bottom: 0, left: 0 }}
/>
<CanvasToolbar
className="xflow-workspace-toolbar-top"
layout="horizontal"
config={toolbarConfig}
position={{ top: 0, left: 230, right: 0, bottom: 0 }}
/>
<XFlowCanvas position={{ top: 40, left: 230, right: 0, bottom: 0 }}>
<CanvasScaleToolbar position={{ top: 60, left: 20 }} />
<CanvasContextMenu config={menuConfig} />
<CanvasSnapline color="#faad14" />
<CanvasNodePortTooltip />
</XFlowCanvas>
<XflowJsonSchemaFormDrawer />
</XFlow>
)}
</div>
);
};
export default connect(({ domainManger }: { domainManger: StateType }) => ({
domainManger,
}))(SemanticFlow);

View File

@@ -0,0 +1,352 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { DATASOURCE_NODE_RENDER_ID, NODE_WIDTH, NODE_HEIGHT } from './constant';
import { uuidv4, NsGraph, NsGraphStatusCommand } from '@antv/xflow';
import type { NsRenameNodeCmd } from './CmdExtensions/CmdRenameNodeModal';
import type { NsNodeCmd, NsEdgeCmd, NsGraphCmd } from '@antv/xflow';
import type { NsDeployDagCmd } from './CmdExtensions/CmdDeploy';
import { getRelationConfigInfo, addClassInfoAsDataSourceParents } from './utils';
import { cloneDeep } from 'lodash';
import type { IDataSource } from '../data';
import {
getDatasourceList,
deleteDatasource,
getDimensionList,
createOrUpdateViewInfo,
getViewInfoList,
deleteDatasourceRela,
} from '../service';
import { message } from 'antd';
/** mock 后端接口调用 */
export namespace GraphApi {
export const NODE_COMMON_PROPS = {
renderKey: DATASOURCE_NODE_RENDER_ID,
width: NODE_WIDTH,
height: NODE_HEIGHT,
} as const;
/** 查图的meta元信息 */
export const queryGraphMeta: NsGraphCmd.GraphMeta.IArgs['graphMetaService'] = async (args) => {
return { ...args, flowId: args.meta.flowId };
};
export const createPorts = (nodeId: string, count = 1, layout = 'LR') => {
const ports = [] as NsGraph.INodeAnchor[];
Array(count)
.fill(1)
.forEach((item, idx) => {
const portIdx = idx + 1;
ports.push(
...[
{
id: `${nodeId}-input-${portIdx}`,
type: NsGraph.AnchorType.INPUT,
group: layout === 'TB' ? NsGraph.AnchorGroup.TOP : NsGraph.AnchorGroup.LEFT,
tooltip: `输入桩-${portIdx}`,
},
{
id: `${nodeId}-output-${portIdx}`,
type: NsGraph.AnchorType.OUTPUT,
group: layout === 'TB' ? NsGraph.AnchorGroup.BOTTOM : NsGraph.AnchorGroup.RIGHT,
tooltip: `输出桩-${portIdx}`,
},
],
);
});
return ports;
};
export const createDataSourceNode = (dataSourceItem: IDataSource.IDataSourceItem) => {
const { id, name } = dataSourceItem;
const nodeId = `dataSource-${id}`;
return {
...NODE_COMMON_PROPS,
id: nodeId,
label: `${name}-${id}`,
ports: createPorts(nodeId),
payload: dataSourceItem,
};
};
/** 删除节点的api */
export const delDataSource = async (nodeConfig: any) => {
const dataSourceId = nodeConfig.targetData?.payload?.id;
if (!dataSourceId) {
// dataSourceId 不存在时为未保存节点直接返回true删除
return true;
}
const { code, msg } = await deleteDatasource(dataSourceId);
if (code === 200) {
return true;
}
message.error(msg);
return false;
};
export const loadDataSourceData = async (args: NsGraph.IGraphMeta) => {
const { domainManger, graphConfig } = args.meta;
const { selectDomainId } = domainManger;
const { code, data = [] } = await getDatasourceList({ domainId: selectDomainId });
const dataSourceMap = data.reduce(
(itemMap: Record<string, IDataSource.IDataSourceItem>, item: IDataSource.IDataSourceItem) => {
const { id, name } = item;
itemMap[`dataSource-${id}`] = item;
itemMap[name] = item;
return itemMap;
},
{},
);
if (code === 200) {
// 如果config存在将数据源信息进行merge
if (graphConfig?.id && graphConfig?.config) {
const { config } = graphConfig;
const { nodes, edges } = config;
const nodesMap = nodes.reduce(
(itemMap: Record<string, NsGraph.INodeConfig>, item: NsGraph.INodeConfig) => {
itemMap[item.id] = item;
return itemMap;
},
{},
);
let mergeNodes = nodes;
let mergeEdges = edges;
if (Array.isArray(nodes)) {
mergeNodes = data.reduce(
(mergeNodeList: NsGraph.INodeConfig[], item: IDataSource.IDataSourceItem) => {
const { id } = item;
const targetDataSourceItem = nodesMap[`dataSource-${id}`];
if (targetDataSourceItem) {
mergeNodeList.push({
...targetDataSourceItem,
payload: item,
});
} else {
mergeNodeList.push(createDataSourceNode(item));
}
return mergeNodeList;
},
[],
);
}
if (Array.isArray(edges)) {
mergeEdges = edges.reduce(
(mergeEdgeList: NsGraph.IEdgeConfig[], item: NsGraph.IEdgeConfig) => {
const { source, target } = item;
const sourceDataSourceItem = dataSourceMap[source];
const targetDataSourceItem = dataSourceMap[target];
if (sourceDataSourceItem && targetDataSourceItem) {
const tempItem = { ...item };
tempItem.sourceNodeData.payload = sourceDataSourceItem;
tempItem.targetNodeData.payload = targetDataSourceItem;
mergeEdgeList.push(tempItem);
}
return mergeEdgeList;
},
[],
);
}
return { nodes: mergeNodes, edges: mergeEdges };
}
// 如果config不存在进行初始化
const nodes: NsGraph.INodeConfig[] = data.map((item: IDataSource.IDataSourceItem) => {
return createDataSourceNode(item);
});
return addClassInfoAsDataSourceParents({ nodes, edges: [] }, domainManger);
}
return {};
};
export const loadDimensionData = async (args: NsGraph.IGraphMeta) => {
const { domainManger } = args.meta;
const { domainId } = domainManger;
const { code, data } = await getDimensionList({ domainId });
if (code === 200) {
const { list } = data;
const nodes: NsGraph.INodeConfig[] = list.map((item: any) => {
const { id, name } = item;
const nodeId = `dimension-${id}`;
return {
...NODE_COMMON_PROPS,
id: nodeId,
label: `${name}-${id}`,
ports: createPorts(nodeId),
payload: item,
};
});
return { nodes, edges: [] };
}
return {};
};
/** 保存图数据的api */
export const saveGraphData: NsGraphCmd.SaveGraphData.IArgs['saveGraphDataService'] = async (
graphMeta: NsGraph.IGraphMeta,
graphData: NsGraph.IGraphData,
) => {
const { commandService } = graphMeta;
const initGraphCmdsState = commandService.getGlobal('initGraphCmdsSuccess');
// 如果graph处于初始化阶段则禁止配置文件保存操作
if (!initGraphCmdsState) {
return;
}
const tempGraphData = cloneDeep(graphData);
const { edges, nodes } = tempGraphData;
if (Array.isArray(nodes)) {
tempGraphData.nodes = nodes.map((item: any) => {
delete item.payload;
return item;
});
}
if (Array.isArray(edges)) {
tempGraphData.edges = edges.map((item: any) => {
delete item.sourceNodeData.payload;
delete item.targetNodeData.payload;
return item;
});
}
const { domainManger, graphConfig } = graphMeta.meta;
const { code, msg } = await createOrUpdateViewInfo({
id: graphConfig?.id,
domainId: domainManger.selectDomainId,
type: 'datasource',
config: JSON.stringify(tempGraphData),
});
if (code !== 200) {
message.error(msg);
}
return {
success: true,
data: graphData,
};
};
/** 部署图数据的api */
export const deployDagService: NsDeployDagCmd.IDeployDagService = async (
meta: NsGraph.IGraphMeta,
graphData: NsGraph.IGraphData,
) => {
return {
success: true,
data: graphData,
};
};
/** 添加节点api */
export const addNode: NsNodeCmd.AddNode.IArgs['createNodeService'] = async (
args: NsNodeCmd.AddNode.IArgs,
) => {
console.info('addNode service running, add node:', args);
const { id, ports = createPorts(id, 1), groupChildren } = args.nodeConfig;
const nodeId = id || uuidv4();
/** 这里添加连线桩 */
const node: NsNodeCmd.AddNode.IArgs['nodeConfig'] = {
...NODE_COMMON_PROPS,
...args.nodeConfig,
id: nodeId,
ports: ports,
};
/** group没有链接桩 */
if (groupChildren && groupChildren.length) {
node.ports = [];
}
return node;
};
/** 更新节点 name可能依赖接口判断是否重名返回空字符串时不更新 */
export const renameNode: NsRenameNodeCmd.IUpdateNodeNameService = async (
name,
node,
graphMeta,
) => {
return { err: null, nodeName: name };
};
/** 删除节点的api */
export const delNode: NsNodeCmd.DelNode.IArgs['deleteNodeService'] = async (args: any) => {
const { type } = args.nodeConfig;
switch (type) {
case 'dataSource':
return await delDataSource(args.nodeConfig);
case 'class':
return true;
default:
return true;
}
};
/** 添加边的api */
export const addEdge: NsEdgeCmd.AddEdge.IArgs['createEdgeService'] = async (args) => {
console.info('addEdge service running, add edge:', args);
const { edgeConfig } = args;
return {
...edgeConfig,
id: uuidv4(),
};
};
/** 删除边的api */
export const delEdge: NsEdgeCmd.DelEdge.IArgs['deleteEdgeService'] = async (args) => {
console.info('delEdge service running, del edge:', args);
const { commandService, edgeConfig } = args;
if (!edgeConfig?.sourceNodeData || !edgeConfig?.targetNodeData) {
return true;
}
const { sourceNodeData, targetNodeData } = edgeConfig as any;
const sourceDataId = sourceNodeData.payload.id;
const targetDataId = targetNodeData.payload.id;
const { getGlobal } = commandService as any;
const dataSourceRelationList = getGlobal('dataSourceRelationList');
const relationConfig = getRelationConfigInfo(
sourceDataId,
targetDataId,
dataSourceRelationList,
);
if (!relationConfig) {
// 如果配置不存在则直接删除
return true;
}
const { code, msg } = await deleteDatasourceRela(relationConfig.id);
if (code === 200) {
return true;
}
message.error(msg);
return false;
};
let runningNodeId = 0;
const statusMap = {} as NsGraphStatusCommand.IStatusInfo['statusMap'];
let graphStatus: NsGraphStatusCommand.StatusEnum = NsGraphStatusCommand.StatusEnum.DEFAULT;
export const graphStatusService: NsGraphStatusCommand.IArgs['graphStatusService'] = async () => {
if (runningNodeId < 4) {
statusMap[`node${runningNodeId}`] = { status: NsGraphStatusCommand.StatusEnum.SUCCESS };
statusMap[`node${runningNodeId + 1}`] = {
status: NsGraphStatusCommand.StatusEnum.PROCESSING,
};
runningNodeId += 1;
graphStatus = NsGraphStatusCommand.StatusEnum.PROCESSING;
} else {
runningNodeId = 0;
statusMap.node4 = { status: NsGraphStatusCommand.StatusEnum.SUCCESS };
graphStatus = NsGraphStatusCommand.StatusEnum.SUCCESS;
}
return {
graphStatus: graphStatus,
statusMap: statusMap,
};
};
export const stopGraphStatusService: NsGraphStatusCommand.IArgs['graphStatusService'] =
async () => {
Object.entries(statusMap).forEach(([, val]) => {
const { status } = val as { status: NsGraphStatusCommand.StatusEnum };
if (status === NsGraphStatusCommand.StatusEnum.PROCESSING) {
val.status = NsGraphStatusCommand.StatusEnum.ERROR;
}
});
return {
graphStatus: NsGraphStatusCommand.StatusEnum.ERROR,
statusMap: statusMap,
};
};
}

View File

@@ -0,0 +1,137 @@
import type { NsGraph } from '@antv/xflow';
import { uuidv4 } from '@antv/xflow';
import type { StateType } from '../model';
import { GraphApi } from './service';
import { NODE_WIDTH, NODE_HEIGHT } from './constant';
import moment from 'moment';
import { jsonParse } from '@/utils/utils';
import type { GraphConfigListItem, RelationListItem } from './data';
export const getEdgesNodesIds = (edges: NsGraph.IEdgeConfig[], type?: 'source' | 'target') => {
const hasEdgesNodesIds = edges.reduce((nodesList: string[], item: NsGraph.IEdgeConfig) => {
const { source, target } = item;
if (!type) {
nodesList.push(source, target);
} else if (type === 'source') {
nodesList.push(source);
} else if (type === 'target') {
nodesList.push(target);
}
return nodesList;
}, []);
const uniqueHasEdgesNodesIds = Array.from(new Set(hasEdgesNodesIds));
return uniqueHasEdgesNodesIds;
};
export const computedSingerNodesEdgesPosition = ({ nodes, edges }: NsGraph.IGraphData) => {
const hasEdgesNodesIds = getEdgesNodesIds(edges);
const defaultXPostion = 100;
const defaultYPostion = 100;
const paddingSize = 50;
let xPosistion = defaultXPostion;
const yPostition = defaultYPostion;
const positionNodes = nodes.reduce(
(nodesList: NsGraph.INodeConfig[], item: NsGraph.INodeConfig, index: number) => {
const { id, width, height = NODE_HEIGHT } = item;
if (!hasEdgesNodesIds.includes(id)) {
xPosistion = xPosistion + (width || NODE_WIDTH + paddingSize) * index;
}
nodesList.push({
...item,
x: xPosistion,
y: height > yPostition ? height + paddingSize : yPostition,
});
return nodesList;
},
[],
);
return { nodes: positionNodes, edges };
};
export const addClassInfoAsDataSourceParents = (
{ nodes = [], edges = [] }: NsGraph.IGraphData,
domainManger: StateType,
) => {
const { selectDomainId, selectDomainName } = domainManger;
const sourceId = `classNodeId-${selectDomainId}`;
const classNode = {
...GraphApi.NODE_COMMON_PROPS,
id: sourceId,
label: selectDomainName,
ports: GraphApi.createPorts(sourceId),
};
const classEdges = nodes.reduce((edgesList: NsGraph.IEdgeConfig[], item: NsGraph.INodeConfig) => {
const { id } = item;
const sourcePortId = `${sourceId}-output-1`;
const edge = {
id: uuidv4(),
source: sourceId,
target: id,
sourcePortId,
targetPortId: `${id}-input-1`,
};
edgesList.push(edge);
return edgesList;
}, []);
const graphData = {
nodes: [classNode, ...nodes],
edges: [...edges, ...classEdges],
};
return graphData;
};
export const addDataSourceInfoAsDimensionParents = (
{ nodes = [], edges = [] }: NsGraph.IGraphData,
targetDataSource: NsGraph.INodeConfig,
) => {
const { id: sourceId } = targetDataSource;
const dimensionEdges = nodes.reduce(
(edgesList: NsGraph.IEdgeConfig[], item: NsGraph.INodeConfig) => {
const { id } = item;
const sourcePortId = `${sourceId}-output-1`;
const edge = {
id: uuidv4(),
source: sourceId,
target: id,
sourcePortId,
targetPortId: `${id}-input-1`,
};
edgesList.push(edge);
return edgesList;
},
[],
);
const graphData = {
nodes: [targetDataSource, ...nodes],
edges: [...edges, ...dimensionEdges],
};
return graphData;
};
export const getGraphConfigFromList = (configList: GraphConfigListItem[]) => {
configList.sort((a, b) => moment(b.updatedAt).valueOf() - moment(a.updatedAt).valueOf());
const targetConfig = configList[0];
if (targetConfig) {
const { config, id } = targetConfig;
return {
config: jsonParse(config),
id,
};
}
return;
};
export const getRelationConfigInfo = (
fromDataSourceId: number,
toDataSourceId: number,
relationList: RelationListItem[],
) => {
const relationConfig = relationList.filter((item: RelationListItem) => {
const { datasourceFrom, datasourceTo } = item;
return fromDataSourceId === datasourceFrom && toDataSourceId === datasourceTo;
})[0];
return relationConfig;
};

View File

@@ -0,0 +1,82 @@
import G6 from '@antv/g6';
// import { modifyCSS, createDom } from '@antv/dom-util';
import { createDom } from '@antv/dom-util';
const initToolBar = () => {
// const defaultConfig = G6.ToolBar
const toolBarInstance = new G6.ToolBar();
const config = toolBarInstance._cfgs;
const defaultContentDomString = config.getContent();
// const regex = /<ul[^>]*>|<\/ul>/g;
// const innerDom = defaultContentDom.replace(regex, '');
const defaultContentDom = createDom(defaultContentDomString);
// @ts-ignore
const elements = defaultContentDom.querySelectorAll('li[code="redo"], li[code="undo"]');
elements.forEach((element) => {
element.remove();
});
const searchBtnDom = `<li code="search">
<svg
viewBox="64 64 896 896"
focusable="false"
data-icon="search"
width="24"
height="24"
fill="currentColor"
aria-hidden="true"
>
<path d="M909.6 854.5L649.9 594.8C690.2 542.7 712 479 712 412c0-80.2-31.3-155.4-87.9-212.1-56.6-56.7-132-87.9-212.1-87.9s-155.5 31.3-212.1 87.9C143.2 256.5 112 331.8 112 412c0 80.1 31.3 155.5 87.9 212.1C256.5 680.8 331.8 712 412 712c67 0 130.6-21.8 182.7-62l259.7 259.6a8.2 8.2 0 0011.6 0l43.6-43.5a8.2 8.2 0 000-11.6zM570.4 570.4C528 612.7 471.8 636 412 636s-116-23.3-158.4-65.6C211.3 528 188 471.8 188 412s23.3-116.1 65.6-158.4C296 211.3 352.2 188 412 188s116.1 23.2 158.4 65.6S636 352.2 636 412s-23.3 116.1-65.6 158.4z" />
</svg>
</li>`;
const toolbar = new G6.ToolBar({
position: { x: 10, y: 10 },
getContent: () => {
return `${searchBtnDom}${defaultContentDom}`;
},
});
// const toolbar = new G6.ToolBar({
// getContent: (graph) => {
// const searchInput = document.createElement('input');
// searchInput.id = 'search-input';
// searchInput.placeholder = '搜索节点';
// const searchBtn = document.createElement('button');
// searchBtn.id = 'search-btn';
// searchBtn.innerHTML = '搜索';
// const container = document.createElement('div');
// container.appendChild(searchInput);
// container.appendChild(searchBtn);
// return container;
// },
// handleClick: (name, graph) => {
// if (name === 'search-btn') {
// const searchText = document.getElementById('search-input').value.trim();
// if (!searchText) {
// return;
// }
// const foundNode = graph.getNodes().find((node) => {
// const model = node.getModel();
// return model.label === searchText;
// });
// if (foundNode) {
// // 如果找到了节点,将其设置为选中状态
// graph.setItemState(foundNode, 'active', true);
// // 将视图移动到找到的节点位置
// graph.focusItem(foundNode, true, {
// duration: 300,
// easing: 'easeCubic',
// });
// } else {
// alert('未找到节点');
// }
// }
// },
// });
return toolbar;
};
export default initToolBar;

View File

@@ -0,0 +1,344 @@
import React, { useEffect, useState, useRef } from 'react';
import { connect } from 'umi';
import type { StateType } from '../model';
import type { Dispatch } from 'umi';
import { typeConfigs } from './utils';
import { message } from 'antd';
import { getDatasourceList, getDomainSchemaRela } from '../service';
import initToolBar from './components/ToolBar';
import G6 from '@antv/g6';
type Props = {
domainId: number;
domainManger: StateType;
dispatch: Dispatch;
};
const DomainManger: React.FC<Props> = ({ domainManger, domainId }) => {
const ref = useRef(null);
const [graphData, setGraphData] = useState<any>({});
const legendDataRef = useRef<any[]>([]);
const graphRef = useRef<any>(null);
const legendDataFilterFunctions = useRef<any>({});
const { dimensionList } = domainManger;
const toggleNodeVisibility = (graph, node, visible) => {
if (visible) {
graph.showItem(node);
} else {
graph.hideItem(node);
}
};
const toggleChildrenVisibility = (graph, node, visible) => {
const model = node.getModel();
if (model.children) {
model.children.forEach((child) => {
const childNode = graph.findById(child.id);
toggleNodeVisibility(graph, childNode, visible);
toggleChildrenVisibility(graph, childNode, visible);
});
}
};
const formatterRelationData = (dataSourceList: any[]) => {
const relationData = dataSourceList.reduce((relationList: any[], item: any) => {
const { id, name } = item;
const dataSourceId = `dataSource-${id}`;
const dimensionChildrenList = dimensionList.reduce(
(dimensionChildren: any[], dimension: any) => {
const { id: dimensionId, name: dimensionName, datasourceId } = dimension;
if (datasourceId === id) {
dimensionChildren.push({
nodeType: 'dimension',
legendType: dataSourceId,
id: `dimension-${dimensionId}`,
name: dimensionName,
style: {
lineWidth: 2,
fill: '#f0f7ff',
stroke: '#a6ccff',
},
});
}
return dimensionChildren;
},
[],
);
relationList.push({
name,
legendType: dataSourceId,
id: dataSourceId,
nodeType: 'datasource',
size: 40,
children: [...dimensionChildrenList],
style: {
lineWidth: 2,
fill: '#BDEFDB',
stroke: '#5AD8A6',
},
});
return relationList;
}, []);
return relationData;
};
const queryDataSourceList = async (params: any) => {
getDomainSchemaRela(params.domainId);
const { code, data, msg } = await getDatasourceList({ ...params });
if (code === 200) {
const relationData = formatterRelationData(data);
const legendList = relationData.map((item: any) => {
const { id, name } = item;
return {
id,
label: name,
order: 4,
...typeConfigs.datasource,
};
});
legendDataRef.current = legendList;
setGraphData({
id: 'root',
name: domainManger.selectDomainName,
children: relationData,
});
} else {
message.error(msg);
}
};
useEffect(() => {
queryDataSourceList({ domainId });
}, []);
const getLegendDataFilterFunctions = () => {
legendDataRef.current.map((item: any) => {
const { id } = item;
legendDataFilterFunctions.current = {
...legendDataFilterFunctions.current,
[id]: (d) => {
if (d.legendType === id) {
return true;
}
return false;
},
};
});
};
const setAllActiveLegend = (legend: any) => {
const legendCanvas = legend._cfgs.legendCanvas;
// 从图例中找出node-group节点;
const group = legendCanvas.find((e: any) => e.get('name') === 'node-group');
// 数据源的图例节点在node-group中的children中
const groups = group.get('children');
groups.forEach((itemGroup: any) => {
const labelText = itemGroup.find((e: any) => e.get('name') === 'circle-node-text');
// legend中activateLegend事件触发在图例节点的Text上方法中存在向上溯源的逻辑const shapeGroup = shape.get('parent');
// 因此复用实例方法时在这里不能直接将图例节点传入需要在节点的children中找任意一个元素作为入参
legend.activateLegend(labelText);
});
};
// const [visible, setVisible] = useState(false);
useEffect(() => {
if (!(Array.isArray(graphData.children) && graphData.children.length > 0)) {
return;
}
const container = document.getElementById('semanticGraph');
const width = container!.scrollWidth;
const height = container!.scrollHeight || 500;
if (!graphRef.current) {
getLegendDataFilterFunctions();
const toolbar = initToolBar();
// const toolbar = new G6.ToolBar({
// getContent: (graph) => {
// const searchIcon = document.createElement('i');
// searchIcon.className = 'g6-toolbar-search-icon';
// searchIcon.style.cssText = `
// display: inline-block;
// width: 16px;
// height: 16px;
// background-image: url(https://gw.alipayobjects.com/zos/rmsportal/wzQIcOMRTkQwFgaaDIFs.svg);
// background-size: 16px 16px;
// margin-right: 8px;
// cursor: pointer;
// `;
// searchIcon.addEventListener('click', () => {
// setVisible((prevVisible) => !prevVisible);
// });
// const ul = document.createElement('ul');
// ul.className = 'g6-component-toolbar';
// ul.appendChild(searchIcon);
// return ul;
// },
// });
const tooltip = new G6.Tooltip({
offsetX: 10,
offsetY: 10,
fixToNode: [1, 0.5],
// the types of items that allow the tooltip show up
// 允许出现 tooltip 的 item 类型
// itemTypes: ['node', 'edge'],
itemTypes: ['node'],
// custom the tooltip's content
// 自定义 tooltip 内容
getContent: (e) => {
const outDiv = document.createElement('div');
outDiv.style.width = 'fit-content';
outDiv.style.height = 'fit-content';
const model = e.item.getModel();
if (e.item.getType() === 'node') {
outDiv.innerHTML = `${model.name}`;
}
// else {
// const source = e.item.getSource();
// const target = e.item.getTarget();
// outDiv.innerHTML = `来源:${source.getModel().name}<br/>去向:${
// target.getModel().name
// }`;
// }
return outDiv;
},
});
const legend = new G6.Legend({
// container: 'legendContainer',
data: {
nodes: legendDataRef.current,
},
align: 'center',
layout: 'horizontal', // vertical
position: 'bottom-right',
vertiSep: 12,
horiSep: 24,
offsetY: -24,
padding: [4, 16, 8, 16],
containerStyle: {
fill: '#ccc',
lineWidth: 1,
},
title: '可见数据源',
titleConfig: {
position: 'center',
offsetX: 0,
offsetY: 12,
style: {
fontSize: 12,
fontWeight: 500,
fill: '#000',
},
},
filter: {
enable: true,
multiple: true,
trigger: 'click',
graphActiveState: 'activeByLegend',
graphInactiveState: 'inactiveByLegend',
filterFunctions: {
...legendDataFilterFunctions.current,
},
},
});
graphRef.current = new G6.TreeGraph({
container: 'semanticGraph',
width,
height,
linkCenter: true,
modes: {
default: [
{
type: 'collapse-expand',
onChange: function onChange(item, collapsed) {
const data = item.get('model');
data.collapsed = collapsed;
return true;
},
},
'drag-node',
'drag-canvas',
// 'activate-relations',
'zoom-canvas',
{
type: 'activate-relations',
trigger: 'mouseenter', // 触发方式,可以是 'mouseenter' 或 'click'
resetSelected: true, // 点击空白处时,是否取消高亮
},
],
},
defaultNode: {
size: 26,
labelCfg: {
position: 'bottom',
style: {
stroke: '#fff',
lineWidth: 4,
},
},
},
layout: {
type: 'dendrogram',
direction: 'LR',
nodeSep: 200,
rankSep: 300,
radial: true,
},
plugins: [legend, tooltip, toolbar],
});
const legendCanvas = legend._cfgs.legendCanvas;
// legend模式事件方法bindEvents会有点击图例空白清空选中的逻辑在注册click事件前先将click事件队列清空
legend._cfgs.legendCanvas._events.click = [];
legendCanvas.on('click', (e) => {
const shape = e.target;
const shapeGroup = shape.get('parent');
const shapeGroupId = shapeGroup?.cfg?.id;
if (shapeGroupId) {
const isActive = shapeGroup.get('active');
const targetNode = graph.findById(shapeGroupId);
// const model = targetNode.getModel();
toggleNodeVisibility(graph, targetNode, isActive);
toggleChildrenVisibility(graph, targetNode, isActive);
}
});
const graph = graphRef.current;
graph.node(function (node) {
return {
label: node.name,
labelCfg: { style: { fill: '#3c3c3c' } },
};
});
graph.data(graphData);
graph.render();
graph.fitView();
setAllActiveLegend(legend);
const rootNode = graph.findById('root');
graph.hideItem(rootNode);
if (typeof window !== 'undefined')
window.onresize = () => {
if (!graph || graph.get('destroyed')) return;
if (!container || !container.scrollWidth || !container.scrollHeight) return;
graph.changeSize(container.scrollWidth, container.scrollHeight);
};
}
}, [domainId, graphData]);
return <div ref={ref} id="semanticGraph" style={{ width: '100%', height: '100%' }} />;
};
export default connect(({ domainManger }: { domainManger: StateType }) => ({
domainManger,
}))(DomainManger);

View File

@@ -0,0 +1,12 @@
import request from 'umi-request';
type ExcuteSqlParams = {
sql: string;
domainId: number;
};
// 执行脚本
export async function excuteSql(params: ExcuteSqlParams) {
const data = { ...params };
return request.post(`${process.env.API_BASE_URL}database/executeSql`, { data });
}

View File

@@ -0,0 +1,759 @@
@borderColor: #eee;
@activeColor: #a0c5e8;
@hoverColor: #dee4e9;
.pageContainer {
position: absolute;
top: 55px;
right: 0;
bottom: 0;
left: 0;
// margin: -24px;
background: #fff;
&.externalPageContainer {
margin: 0 !important;
}
}
.searchBar {
:global {
.ant-form-item-label {
width: 70px;
}
}
}
.main {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
:global {
.ant-tabs {
height: 100% !important;
.ant-tabs-content {
height: 100% !important;
.ant-tabs-tabpane {
height: 100%;
}
}
}
}
}
.rightSide {
position: relative;
z-index: 1;
min-width: 250px;
height: 100%;
margin-left: 4px;
padding: 10px;
overflow: hidden;
:global {
.ant-form-item {
margin-bottom: 6px;
.ant-form-item-label {
width: 70px;
}
.ant-form-item-control {
min-width: 100px;
}
}
}
}
.rightListSide {
position: relative;
z-index: 2;
flex: 1;
height: 100%;
// padding: 10px 10px 0;
background-color: #fff;
// 去掉标签间距
:global {
.ant-tabs-card.ant-tabs-top > .ant-tabs-nav .ant-tabs-tab + .ant-tabs-tab,
.ant-tabs-card.ant-tabs-bottom > .ant-tabs-nav .ant-tabs-tab + .ant-tabs-tab,
.ant-tabs-card.ant-tabs-top > div > .ant-tabs-nav .ant-tabs-tab + .ant-tabs-tab,
.ant-tabs-card.ant-tabs-bottom > div > .ant-tabs-nav .ant-tabs-tab + .ant-tabs-tab {
margin-left: 0;
}
.ant-tabs > .ant-tabs-nav .ant-tabs-nav-add,
.ant-tabs > div > .ant-tabs-nav .ant-tabs-nav-add {
margin-left: 0;
}
}
}
.leftListSide {
position: relative;
z-index: 2;
flex: 1;
height: 100%;
// padding: 10px 10px 0;
background-color: #fff;
}
.tableTotal {
margin: 0 2px;
color: #296df3;
font-weight: bold;
}
.tableDetaildrawer {
:global {
.ant-drawer-header {
padding: 10px 45px 10px 10px;
}
.ant-drawer-close {
padding: 10px;
}
.ant-drawer-body {
padding: 0 10px 10px;
}
.ant-tabs-top > .ant-tabs-nav {
margin-bottom: 8px;
}
}
}
.tableDetailTable {
:global {
.ant-table-cell,
.resultTableRow > td {
padding: 8px;
font-size: 12px;
}
}
}
.sqlEditor {
min-width: 0;
height: 100%;
border: solid 1px @borderColor;
:global {
.ace_editor {
font-family: 'Menlo', 'Monaco', 'Ubuntu Mono', 'Consolas', 'source-code-pro' !important;
}
}
}
.sqlOprBar {
margin-top: -10px;
padding: 5px;
display: flex;
.sqlOprBarLeftBox {
flex: 1 1 200px;
}
.sqlOprBarRightBox {
flex: 0 1 210px;
}
:global {
.ant-btn-round.ant-btn-sm {
font-size: 12px;
}
.ant-btn-primary {
color: #fff;
background: #02a7f0;
border-color: #02a7f0;
}
.ant-segmented-item-selected {
color: #fff;
background: #02a7f0;
border-color: #02a7f0;
}
}
}
.sqlOprIcon {
margin-right: 30px;
color: #02a7f0;
font-size: 22px;
&:hover {
cursor: pointer;
opacity: 0.8;
}
&:active {
opacity: 0.7;
}
}
.sqlOprBtn {
margin-right: 30px;
vertical-align: super !important;
&:hover {
cursor: pointer;
opacity: 0.8;
}
&:active {
opacity: 0.7;
}
}
.sqlOprSwitch {
// vertical-align: super !important;
float: right;
margin-right: 10px !important;
}
:global {
.is-sql-full-select {
background-color: #02a7f0;
}
.cjjWdp:hover {
z-index: 10;
}
}
.sqlMain {
display: flex;
flex-direction: row;
height: 100%;
.sqlEditorWrapper {
flex: 1;
height: 100%;
overflow: hidden;
}
.sqlParams {
width: 20%;
height: 100% !important;
overflow: auto;
}
.hideSqlParams {
width: 0;
height: 100% !important;
overflow: auto;
}
}
.sqlParamsBody {
.header {
display: flex;
padding: 10px;
font-weight: bold;
.title {
flex: 1;
}
.icon {
display: flex;
align-items: center;
margin-right: 10px !important;
cursor: pointer;
}
}
.paramsList {
.paramsItem {
display: flex;
padding: 10px;
:global {
.ant-list-item-action {
margin-left: 5px;
}
}
.name {
flex: 1;
width: 80%;
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
&:hover {
cursor: pointer;
}
}
.icon {
// display: none;
margin-left: 10px;
}
}
}
// .paramsItem:hover {
// .icon {
// display: inline-block;
// margin-left: 8px;
// cursor: pointer;
// }
// }
}
.disableIcon {
vertical-align: super !important;
// color: rgba(0, 10, 36, 0.25);
background: #7d7f80 !important;
border-color: #7d7f80 !important;
:global {
.anticon .anticon-play-circle {
color: #fff;
}
}
&:hover {
cursor: not-allowed;
opacity: 1;
}
}
.sqlTaskListWrap {
position: relative;
width: 262px;
border-top: 0 !important;
border-radius: 0;
:global {
.ant-card-head {
min-height: 20px;
}
.ant-card-head-title {
padding: 8px 0;
}
}
}
.sqlTaskList {
position: absolute !important;
top: 42px;
right: 0;
bottom: 0;
left: 0;
overflow: auto;
}
.sqlBottmWrap {
// position: absolute;
// top: 484px;
// right: 0;
// bottom: 0;
// left: 0;
display: flex;
height: 100%;
// padding: 0 10px;
&:global(.small) {
top: 334px;
}
&:global(.middle) {
top: 384px;
}
}
.sqlResultWrap {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
overflow: auto;
border: solid 1px @borderColor;
border-top: 0;
border-left: 0;
}
.sqlToolBar {
display: flex;
flex-direction: row-reverse;
align-items: center;
height: 41px;
padding: 5px 0;
text-align: right;
}
.sqlResultPane {
flex: 1;
border-top: solid 1px @borderColor;
}
.sqlToolBtn {
margin-right: 15px;
}
.runScriptBtn {
margin-right: 15px;
background-color: #e87954;
border-color: #e87954;
&:hover{
border-color: #f89878;
background: #f89878;
}
&:focus {
border-color: #f89878;
background: #f89878;
}
}
.taskFailed {
padding: 20px 20px 0 20px;
}
.sqlResultContent {
position: absolute;
top: 50%;
width: 100%;
color: rgba(0, 0, 0, 0.25);
font-size: 16px;
text-align: center;
}
.sqlResultLog {
padding: 20px;
word-wrap: break-word;
}
.tableList {
position: absolute !important;
top: 160px;
right: 0;
bottom: 26px;
left: 0;
overflow-x: hidden;
overflow-y: auto;
border-bottom: solid 1px @borderColor;
}
.tablePage {
position: absolute !important;
bottom: 0;
left: 0;
z-index: 1;
width: 100%;
min-width: 250px;
overflow: hidden;
}
.tableListItem {
width: 88%;
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
&:hover {
cursor: pointer;
}
}
.tableItem {
&:global(.ant-list-item) {
padding: 6px 0 6px 6px;
}
:global(.ant-list-item-action) {
margin-left: 12px !important;
}
&:hover {
background: @hoverColor;
border-bottom: 1px solid #f0f0f0;
}
&:global(.active) {
background: @activeColor;
}
}
.taskIcon {
margin-right: 10px;
color: #1890ff;
font-size: 14px;
}
.taskSuccessIcon {
.taskIcon();
color: #67c23a;
}
.taskFailIcon {
.taskIcon();
color: #f56c6c;
}
.resultFailIcon {
margin-right: 8px;
color: #f56c6c;
}
.taskItem {
padding: 10px 8px !important;
font-size: 12px;
cursor: pointer;
&:global(.ant-list-item) {
justify-content: flex-start;
}
&:hover {
background: @hoverColor;
}
}
.activeTask {
background: @activeColor;
}
.resultTable {
width: 100%;
:global {
.ant-table-body {
width: 100%;
// max-height: none !important;
overflow: auto !important;
}
.ant-table-cell,
.resultTableRow > td {
padding: 8px;
font-size: 12px;
}
}
}
.taskLogWrap {
word-wrap: break-word;
}
.siteTagPlus {
background: #fff;
border-style: dashed;
}
.editTag {
margin-bottom: 5px;
user-select: none;
}
.tagInput {
width: 78px;
margin-right: 8px;
vertical-align: top;
}
.outside {
position: relative;
height: 100%;
}
.collapseRightBtn {
position: absolute;
top: calc(50% + 50px);
right: 0;
z-index: 100;
display: flex;
align-items: center;
height: 70px;
color: #fff;
font-size: 12px;
background-color: rgba(40, 46, 54, 0.2);
border-radius: 24px 0 0 24px;
cursor: pointer;
transition: all 0.3s ease;
}
.collapseLeftBtn {
position: absolute;
top: calc(50% + 45px);
left: 0;
z-index: 100;
display: flex;
align-items: center;
height: 70px;
color: #fff;
font-size: 12px;
background-color: rgba(40, 46, 54, 0.2);
border-radius: 0 24px 24px 0;
cursor: pointer;
transition: all 0.3s ease;
}
.detail {
.titleCollapse {
float: right;
padding-right: 18px;
color: #1890ff;
line-height: 35px;
text-align: right;
cursor: pointer;
}
.tableTitle {
display: inline-block;
width: 85%;
margin-left: 15px;
overflow: hidden;
line-height: 35px;
white-space: nowrap;
text-overflow: ellipsis;
cursor: pointer;
}
:global {
.ant-divider-horizontal {
margin: 0;
}
}
}
.search {
margin-left: 10px;
}
.middleArea {
:global {
.ant-tabs-nav .ant-tabs-tab {
border: none;
// background: #d9d9d96e;
border-right: 1px solid #f0f0f0;
border-radius: 0 !important;
}
.ant-tabs-nav-add {
border-radius: 0 !important;
}
.ant-tabs-tab {
.ant-tabs-tab-remove {
.closeTab {
opacity: 0;
}
.dot {
opacity: 1;
}
}
}
.ant-tabs-tab:hover {
.ant-tabs-tab-remove {
.closeTab {
opacity: 1 !important;
}
.dot {
opacity: 0;
}
}
}
}
}
.menu {
position: relative;
z-index: 1;
height: 100%;
padding: 5px;
overflow: hidden;
overflow-x: hidden;
overflow-y: auto;
:global {
.ant-form {
margin: -2px;
}
}
}
.menuList {
position: absolute !important;
top: 95px;
right: 0;
bottom: 26px;
left: 0;
overflow-x: hidden;
overflow-y: auto;
border-bottom: solid 1px @borderColor;
.menuItem {
&:global(.ant-list-item) {
padding: 6px 0 6px 14px;
}
:global(.ant-list-item-action) {
margin-left: 12px !important;
}
&:hover {
background: @hoverColor;
border-bottom: 1px solid #f0f0f0;
.icon {
display: block;
}
}
&:global(.active) {
background: @activeColor;
}
.menuListItem {
width: 90%;
overflow: hidden;
font-size: 12px;
white-space: nowrap;
text-overflow: ellipsis;
&:hover {
cursor: pointer;
}
}
.icon {
display: none;
margin-right: 15px !important;
cursor: pointer;
}
.menuIcon {
display: flex;
}
}
}
.scriptFile {
width: 100%;
margin: 10px;
overflow: hidden;
font-size: 14px;
white-space: nowrap;
text-overflow: ellipsis;
.icon {
margin-right: 10px;
}
}
.sqlScriptName {
width: 93% !important;
margin: 14px 0 0 14px !important;
}
.fileIcon {
width: 20px !important;
height: 20px !important;
padding-top: 2px !important;
padding-right: 5px !important;
vertical-align: middle;
}
.itemName {
vertical-align: middle;
}
.paneName {
width: 100px;
overflow: hidden;
font-size: 12px !important;
white-space: nowrap;
text-overflow: ellipsis;
}
.titleIcon {
width: 16px !important;
height: 16px !important;
margin: 0 3px 4px;
}

View File

@@ -0,0 +1,80 @@
export const typeConfigs = {
datasource: {
type: 'circle',
size: 5,
style: {
fill: '#5B8FF9',
},
},
dimension: {
type: 'circle',
size: 20,
style: {
fill: '#5AD8A6',
},
},
metric: {
type: 'rect',
size: [10, 10],
style: {
fill: '#5D7092',
},
},
// eType1: {
// type: 'line',
// style: {
// width: 20,
// stroke: '#F6BD16',
// },
// },
// eType2: {
// type: 'cubic',
// },
// eType3: {
// type: 'quadratic',
// style: {
// width: 25,
// stroke: '#6F5EF9',
// },
// },
};
export const legendData = {
nodes: [
{
id: 'type1',
label: 'node-type1',
order: 4,
...typeConfigs.datasource,
},
{
id: 'type2',
label: 'node-type2',
order: 0,
...typeConfigs.dimension,
},
{
id: 'type3',
label: 'node-type3',
order: 2,
...typeConfigs.metric,
},
],
// edges: [
// {
// id: 'eType1',
// label: 'edge-type1',
// order: 2,
// ...typeConfigs.eType1,
// },
// {
// id: 'eType2',
// label: 'edge-type2',
// ...typeConfigs.eType2,
// },
// {
// id: 'eType3',
// label: 'edge-type3',
// ...typeConfigs.eType3,
// },
// ],
};

View File

@@ -0,0 +1,123 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Modal } from 'antd';
import type { IDataSource } from '../data';
import ProTable from '@ant-design/pro-table';
import type { ActionType, ProColumns } from '@ant-design/pro-table';
import { connect } from 'umi';
import type { Dispatch } from 'umi';
import type { StateType } from '../model';
export type CreateFormProps = {
measuresList: any[];
selectedMeasuresList: any[];
onCancel: () => void;
onSubmit: (selectMeasuresList: any[]) => void;
createModalVisible: boolean;
projectManger: StateType;
dispatch: Dispatch;
};
const BindMeasuresTable: React.FC<CreateFormProps> = ({
measuresList,
selectedMeasuresList = [],
onSubmit,
onCancel,
createModalVisible,
projectManger,
}) => {
const { searchParams = {} } = projectManger || {};
const actionRef = useRef<ActionType>();
const [selectedMeasuresKeys, setSelectedMeasuresKeys] = useState<string[]>(() => {
return selectedMeasuresList.map((item: any) => {
return item.bizName;
});
});
const [selectMeasuresList, setSelectMeasuresList] = useState<IDataSource.IMeasuresItem[]>([]);
const handleSubmit = async () => {
onSubmit?.(selectMeasuresList);
};
const findMeasureItemByName = (bizName: string) => {
return measuresList.find((item) => {
return item.bizName === bizName;
});
};
useEffect(() => {
const selectedMeasures: IDataSource.IMeasuresItem[] = selectedMeasuresKeys.map((bizName) => {
const item = findMeasureItemByName(bizName);
return item;
});
setSelectMeasuresList([...selectedMeasures]);
}, [selectedMeasuresKeys]);
useEffect(() => {}, []);
const columns: ProColumns[] = [
{
dataIndex: 'bizName',
title: '度量名称',
},
{
dataIndex: 'alias',
title: '别名',
},
{
dataIndex: 'agg',
title: '算子类型',
},
{
dataIndex: 'datasourceName',
title: '所属数据源',
},
];
const renderFooter = () => {
return (
<>
<Button onClick={onCancel}></Button>
<Button type="primary" onClick={handleSubmit}>
</Button>
</>
);
};
const rowSelection = {
selectedRowKeys: selectedMeasuresKeys,
onChange: (_selectedRowKeys: any[]) => {
setSelectedMeasuresKeys([..._selectedRowKeys]);
},
};
return (
<Modal
width={800}
destroyOnClose
title="度量添加"
open={createModalVisible}
footer={renderFooter()}
onCancel={() => {
onCancel();
}}
>
<ProTable
actionRef={actionRef}
rowKey="bizName"
rowSelection={rowSelection}
columns={columns}
params={{ ...searchParams }}
pagination={false}
dataSource={measuresList}
size="small"
search={false}
options={false}
/>
</Modal>
);
};
export default connect(({ projectManger }: { projectManger: StateType }) => ({
projectManger,
}))(BindMeasuresTable);

View File

@@ -0,0 +1,178 @@
import type { ActionType, ProColumns } from '@ant-design/pro-table';
import ProTable from '@ant-design/pro-table';
import { message, Button, Drawer, Space, Popconfirm } from 'antd';
import React, { useRef, useState } from 'react';
import type { Dispatch } from 'umi';
import { connect } from 'umi';
import type { StateType } from '../model';
import { getDatasourceList, deleteDatasource } from '../service';
import DataSource from '../Datasource';
import moment from 'moment';
type Props = {
dispatch: Dispatch;
domainManger: StateType;
};
const ClassDataSourceTable: React.FC<Props> = ({ dispatch, domainManger }) => {
const { selectDomainId } = domainManger;
const [createModalVisible, setCreateModalVisible] = useState<boolean>(false);
const [dataSourceItem, setDataSourceItem] = useState<any>();
const actionRef = useRef<ActionType>();
const columns: ProColumns[] = [
{
dataIndex: 'id',
title: 'ID',
},
{
dataIndex: 'name',
title: '数据源名称',
},
{
dataIndex: 'bizName',
title: '英文名称',
},
{
dataIndex: 'createdBy',
title: '创建人',
},
{
dataIndex: 'description',
title: '描述',
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',
render: (_, record) => {
return (
<Space>
<a
key="classEditBtn"
onClick={() => {
setDataSourceItem(record);
setCreateModalVisible(true);
}}
>
</a>
<Popconfirm
title="确认删除?"
okText="是"
cancelText="否"
onConfirm={async () => {
const { code } = await deleteDatasource(record.id);
if (code === 200) {
setDataSourceItem(undefined);
actionRef.current?.reload();
} else {
message.error('删除失败');
}
}}
>
<a
key="classEditBtn"
onClick={() => {
setDataSourceItem(record);
}}
>
</a>
</Popconfirm>
</Space>
);
},
},
];
const queryDataSourceList = async (params: any) => {
dispatch({
type: 'domainManger/setPagination',
payload: {
...params,
},
});
const { code, data, msg } = await getDatasourceList({ ...params });
let resData: any = {};
if (code === 200) {
resData = {
data: data || [],
success: true,
};
} else {
message.error(msg);
resData = {
data: [],
total: 0,
success: false,
};
}
return resData;
};
return (
<>
<ProTable
actionRef={actionRef}
headerTitle="数据源列表"
rowKey="id"
columns={columns}
params={{ domainId: selectDomainId }}
request={queryDataSourceList}
pagination={false}
search={false}
size="small"
options={{ reload: false, density: false, fullScreen: false }}
toolBarRender={() => [
<Button
key="create"
type="primary"
onClick={() => {
setDataSourceItem(undefined);
setCreateModalVisible(true);
}}
>
</Button>,
]}
/>
{createModalVisible && (
<Drawer
width={'100%'}
destroyOnClose
title="数据源编辑"
open={true}
onClose={() => {
setCreateModalVisible(false);
setDataSourceItem(undefined);
}}
footer={null}
>
<DataSource
initialValues={dataSourceItem}
domainId={Number(selectDomainId)}
onSubmitSuccess={() => {
setCreateModalVisible(false);
setDataSourceItem(undefined);
actionRef.current?.reload();
}}
/>
</Drawer>
)}
</>
);
};
export default connect(({ domainManger }: { domainManger: StateType }) => ({
domainManger,
}))(ClassDataSourceTable);

View File

@@ -0,0 +1,263 @@
import type { ActionType, ProColumns } from '@ant-design/pro-table';
import ProTable from '@ant-design/pro-table';
import { message, Button, Space, Popconfirm } from 'antd';
import React, { useRef, useState, useEffect } from 'react';
import type { Dispatch } from 'umi';
import { connect } from 'umi';
import type { StateType } from '../model';
import { SENSITIVE_LEVEL_ENUM } from '../constant';
import {
getDatasourceList,
getDimensionList,
createDimension,
updateDimension,
deleteDimension,
} from '../service';
import DimensionInfoModal from './DimensionInfoModal';
import moment from 'moment';
import styles from './style.less';
type Props = {
dispatch: Dispatch;
domainManger: StateType;
};
const ClassDimensionTable: React.FC<Props> = ({ domainManger, dispatch }) => {
const { selectDomainId } = domainManger;
const [createModalVisible, setCreateModalVisible] = useState<boolean>(false);
const [dimensionItem, setDimensionItem] = useState<any>();
const [dataSourceList, setDataSourceList] = useState<any[]>([]);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 20,
total: 0,
});
const actionRef = useRef<ActionType>();
const queryDimensionList = async (params: any) => {
const { code, data, msg } = await getDimensionList({
...params,
...pagination,
domainId: selectDomainId,
});
const { list, pageSize, current, total } = data;
let resData: any = {};
if (code === 200) {
setPagination({
pageSize,
current,
total,
});
resData = {
data: list || [],
success: true,
};
} else {
message.error(msg);
resData = {
data: [],
total: 0,
success: false,
};
}
return resData;
};
const queryDataSourceList = async () => {
const { code, data, msg } = await getDatasourceList({ domainId: selectDomainId });
if (code === 200) {
setDataSourceList(data);
} else {
message.error(msg);
}
};
useEffect(() => {
queryDataSourceList();
}, [selectDomainId]);
const columns: ProColumns[] = [
{
dataIndex: 'id',
title: 'ID',
order: 100,
},
{
dataIndex: 'name',
title: '维度名称',
},
{
dataIndex: 'bizName',
title: '字段名称',
order: 9,
},
{
dataIndex: 'sensitiveLevel',
title: '敏感度',
valueEnum: SENSITIVE_LEVEL_ENUM,
},
{
dataIndex: 'datasourceName',
title: '数据源名称',
search: false,
},
{
dataIndex: 'createdBy',
title: '创建人',
search: false,
},
{
dataIndex: 'description',
title: '描述',
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',
render: (_, record) => {
return (
<Space>
<a
key="classEditBtn"
onClick={() => {
setDimensionItem(record);
setCreateModalVisible(true);
}}
>
</a>
<Popconfirm
title="确认删除?"
okText="是"
cancelText="否"
onConfirm={async () => {
const { code } = await deleteDimension(record.id);
if (code === 200) {
setDimensionItem(undefined);
actionRef.current?.reload();
} else {
message.error('删除失败');
}
}}
>
<a
key="classEditBtn"
onClick={() => {
setDimensionItem(record);
}}
>
</a>
</Popconfirm>
</Space>
);
},
},
];
const saveDimension = async (fieldsValue: any, reloadState: boolean = true) => {
const queryParams = {
domainId: selectDomainId,
type: 'categorical',
...fieldsValue,
};
let saveDimensionQuery = createDimension;
if (queryParams.id) {
saveDimensionQuery = updateDimension;
}
const { code, msg } = await saveDimensionQuery(queryParams);
if (code === 200) {
setCreateModalVisible(false);
if (reloadState) {
message.success('编辑维度成功');
actionRef?.current?.reload();
}
dispatch({
type: 'domainManger/queryDimensionList',
payload: {
domainId: selectDomainId,
},
});
return;
}
message.error(msg);
};
return (
<>
<ProTable
className={`${styles.classTable} ${styles.classTableSelectColumnAlignLeft}`}
actionRef={actionRef}
headerTitle="维度列表"
rowKey="id"
columns={columns}
request={queryDimensionList}
pagination={pagination}
search={{
span: 4,
defaultCollapsed: false,
collapseRender: () => {
return <></>;
},
}}
onChange={(data: any) => {
const { current, pageSize, total } = data;
setPagination({
current,
pageSize,
total,
});
}}
tableAlertRender={() => {
return false;
}}
size="small"
options={{ reload: false, density: false, fullScreen: false }}
toolBarRender={() => [
<Button
key="create"
type="primary"
onClick={() => {
setDimensionItem(undefined);
setCreateModalVisible(true);
}}
>
</Button>,
]}
/>
{createModalVisible && (
<DimensionInfoModal
bindModalVisible={createModalVisible}
dimensionItem={dimensionItem}
dataSourceList={dataSourceList}
onSubmit={saveDimension}
onCancel={() => {
setCreateModalVisible(false);
}}
/>
)}
</>
);
};
export default connect(({ domainManger }: { domainManger: StateType }) => ({
domainManger,
}))(ClassDimensionTable);

View File

@@ -0,0 +1,238 @@
import type { ActionType, ProColumns } from '@ant-design/pro-table';
import ProTable from '@ant-design/pro-table';
import { message, Button, Space, Popconfirm } from 'antd';
import React, { useRef, useState } from 'react';
import type { Dispatch } from 'umi';
import { connect } from 'umi';
import type { StateType } from '../model';
import { SENSITIVE_LEVEL_ENUM } from '../constant';
import { creatExprMetric, updateExprMetric, queryMetric, deleteMetric } from '../service';
import MetricInfoCreateForm from './MetricInfoCreateForm';
import moment from 'moment';
import styles from './style.less';
type Props = {
dispatch: Dispatch;
domainManger: StateType;
};
const ClassMetricTable: React.FC<Props> = ({ domainManger, dispatch }) => {
const { selectDomainId } = domainManger;
const [createModalVisible, setCreateModalVisible] = useState<boolean>(false);
const [metricItem, setMetricItem] = useState<any>();
const [pagination, setPagination] = useState({
current: 1,
pageSize: 20,
total: 0,
});
const actionRef = useRef<ActionType>();
const queryMetricList = async (params: any) => {
const { code, data, msg } = await queryMetric({
...params,
...pagination,
domainId: selectDomainId,
});
const { list, pageSize, current, total } = data;
let resData: any = {};
if (code === 200) {
setPagination({
pageSize,
current,
total,
});
resData = {
data: list || [],
success: true,
};
} else {
message.error(msg);
resData = {
data: [],
total: 0,
success: false,
};
}
return resData;
};
const columns: ProColumns[] = [
{
dataIndex: 'id',
title: 'ID',
},
{
dataIndex: 'name',
title: '指标名称',
},
{
dataIndex: 'bizName',
title: '字段名称',
},
{
dataIndex: 'sensitiveLevel',
title: '敏感度',
valueEnum: SENSITIVE_LEVEL_ENUM,
},
{
dataIndex: 'createdBy',
title: '创建人',
search: false,
},
{
dataIndex: 'description',
title: '描述',
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',
render: (_, record) => {
return (
<Space>
<a
key="classEditBtn"
onClick={() => {
setMetricItem(record);
setCreateModalVisible(true);
}}
>
</a>
<Popconfirm
title="确认删除?"
okText="是"
cancelText="否"
onConfirm={async () => {
const { code } = await deleteMetric(record.id);
if (code === 200) {
setMetricItem(undefined);
actionRef.current?.reload();
} else {
message.error('删除失败');
}
}}
>
<a
key="classEditBtn"
onClick={() => {
setMetricItem(record);
}}
>
</a>
</Popconfirm>
</Space>
);
},
},
];
const saveMetric = async (fieldsValue: any, reloadState: boolean = true) => {
const queryParams = {
domainId: selectDomainId,
...fieldsValue,
};
if (queryParams.typeParams && !queryParams.typeParams.expr) {
message.error('度量表达式不能为空');
return;
}
let saveMetricQuery = creatExprMetric;
if (queryParams.id) {
saveMetricQuery = updateExprMetric;
}
const { code, msg } = await saveMetricQuery(queryParams);
if (code === 200) {
message.success('编辑指标成功');
setCreateModalVisible(false);
if (reloadState) {
actionRef?.current?.reload();
}
dispatch({
type: 'domainManger/queryMetricList',
payload: {
domainId: selectDomainId,
},
});
return;
}
message.error(msg);
};
return (
<>
<ProTable
className={`${styles.classTable} ${styles.classTableSelectColumnAlignLeft}`}
actionRef={actionRef}
headerTitle="指标列表"
rowKey="id"
search={{
span: 4,
defaultCollapsed: false,
collapseRender: () => {
return <></>;
},
}}
columns={columns}
params={{ domainId: selectDomainId }}
request={queryMetricList}
pagination={pagination}
tableAlertRender={() => {
return false;
}}
onChange={(data: any) => {
const { current, pageSize, total } = data;
setPagination({
current,
pageSize,
total,
});
}}
size="small"
options={{ reload: false, density: false, fullScreen: false }}
toolBarRender={() => [
<Button
key="create"
type="primary"
onClick={() => {
setMetricItem(undefined);
setCreateModalVisible(true);
}}
>
</Button>,
]}
/>
{createModalVisible && (
<MetricInfoCreateForm
domainId={Number(selectDomainId)}
createModalVisible={createModalVisible}
metricItem={metricItem}
onSubmit={(values) => {
saveMetric(values);
}}
onCancel={() => {
setCreateModalVisible(false);
}}
/>
)}
</>
);
};
export default connect(({ domainManger }: { domainManger: StateType }) => ({
domainManger,
}))(ClassMetricTable);

View File

@@ -0,0 +1,159 @@
import type { ActionType } from '@ant-design/pro-table';
import type { Ref, ReactNode } from 'react';
import { Space, message } from 'antd';
import React, { useRef, forwardRef, useImperativeHandle, useState, useEffect } from 'react';
import { EditableProTable } from '@ant-design/pro-table';
type Props = {
title?: string;
tableDataSource: any[];
columnList: any[];
rowKey: string;
editableProTableProps?: any;
onDataSourceChange?: (dataSource: any) => void;
extenderCtrlColumn?: (text, record, _, action) => ReactNode[];
editableActionRender?: (row, config, defaultDom, actionRef) => ReactNode[];
ref?: any;
};
export type CommonEditTableRef = {
getCommonEditTableDataSource: () => void;
editTableActionRef: ActionType;
};
const CommonEditTable: React.FC<Props> = forwardRef(
(
{
title,
columnList,
rowKey,
tableDataSource,
editableProTableProps = {},
onDataSourceChange,
extenderCtrlColumn,
editableActionRender,
}: Props,
ref: Ref<any>,
) => {
const [dataSource, setDataSource] = useState<any[]>(tableDataSource);
const actionRef = useRef<ActionType>();
useImperativeHandle(ref, () => ({
getCommonEditTableDataSource: () => {
return [...dataSource];
},
editTableActionRef: actionRef,
}));
useEffect(() => {
setDataSource(
tableDataSource.map((item: any) => {
return {
...item,
editRowId: item[rowKey] || (Math.random() * 1000000).toFixed(0),
};
}),
);
}, [tableDataSource]);
const handleDataSourceChange = (data: any) => {
setTimeout(() => {
onDataSourceChange?.(data);
}, 0);
};
const columns = [
...columnList,
{
title: '操作',
dataIndex: 'x',
valueType: 'option',
render: (text, record, _, action) => {
return (
<Space>
<a
key="editable"
onClick={() => {
action?.startEditable?.(record.editRowId);
}}
>
</a>
<a
key="deleteBtn"
onClick={() => {
const data = [...dataSource].filter((item) => item[rowKey] !== record[rowKey]);
setDataSource(data);
handleDataSourceChange(data);
}}
>
</a>
{extenderCtrlColumn?.(text, record, _, action)}
</Space>
);
},
},
{
dataIndex: 'editRowId',
hideInTable: true,
},
];
const defaultActionRender = (row, config, defaultDom) => {
return editableActionRender?.(row, config, defaultDom, actionRef);
};
const actionRender = editableActionRender ? defaultActionRender : undefined;
return (
<>
<EditableProTable
key={title}
actionRef={actionRef}
headerTitle={title}
rowKey={'editRowId'}
columns={columns}
value={dataSource}
tableAlertRender={() => {
return false;
}}
onChange={(data) => {
let tableData = data;
if (rowKey) {
// 如果rowKey存在将rowId复写为rowKey值
tableData = data.map((item: any) => {
return {
...item,
editRowId: item[rowKey],
};
});
}
setDataSource(tableData);
handleDataSourceChange(data);
}}
editable={{
onSave: (_, row) => {
const rowKeyValue = row[rowKey];
const isSame = dataSource.filter((item: any, index: number) => {
return index !== row.index && item[rowKey] === rowKeyValue;
});
if (isSame[0]) {
message.error('存在重复值');
return Promise.reject();
}
return true;
},
actionRender: actionRender,
}}
pagination={false}
size="small"
recordCreatorProps={{
record: () => ({ editRowId: (Math.random() * 1000000).toFixed(0) }),
}}
{...editableProTableProps}
/>
</>
);
},
);
export default CommonEditTable;

View File

@@ -0,0 +1,160 @@
import { useEffect, forwardRef, useImperativeHandle, useState } from 'react';
import type { ForwardRefRenderFunction } from 'react';
import { message, Form, Input, Select, Button, Space } from 'antd';
import { saveDatabase, getDatabaseByDomainId, testDatabaseConnect } from '../../service';
import { formLayout } from '@/components/FormHelper/utils';
import styles from '../style.less';
type Props = {
domainId: number;
onSubmit: (params?: any) => void;
};
const FormItem = Form.Item;
const TextArea = Input.TextArea;
const DatabaseCreateForm: ForwardRefRenderFunction<any, Props> = ({ domainId }, ref) => {
const [form] = Form.useForm();
const [selectedDbType, setSelectedDbType] = useState<string>('h2');
const queryDatabaseConfig = async () => {
const { code, data } = await getDatabaseByDomainId(domainId);
if (code === 200) {
form.setFieldsValue({ ...data });
setSelectedDbType(data?.type);
return;
}
message.error('数据库配置获取错误');
};
useEffect(() => {
form.resetFields();
queryDatabaseConfig();
}, [domainId]);
const getFormValidateFields = async () => {
return await form.validateFields();
};
useImperativeHandle(ref, () => ({
getFormValidateFields,
}));
const saveDatabaseConfig = async () => {
const values = await form.validateFields();
const { code, msg } = await saveDatabase({
...values,
domainId,
});
if (code === 200) {
message.success('保存成功');
return;
}
message.error(msg);
};
const testDatabaseConnection = async () => {
const values = await form.validateFields();
const { code, data } = await testDatabaseConnect({
...values,
domainId,
});
if (code === 200 && data) {
message.success('连接测试通过');
return;
}
message.error('连接测试失败');
};
return (
<>
<Form
{...formLayout}
form={form}
layout="vertical"
className={styles.form}
onValuesChange={(value) => {
const { type } = value;
if (type) {
setSelectedDbType(type);
}
}}
>
<FormItem name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="请输入数据库名称" />
</FormItem>
<FormItem
name="type"
label="数据库类型"
rules={[{ required: true, message: '请选择数据库类型' }]}
>
<Select
style={{ width: '100%' }}
placeholder="请选择数据库类型"
options={[
{ value: 'h2', label: 'h2' },
{ value: 'mysql', label: 'mysql' },
{ value: 'clickhouse', label: 'clickhouse' },
]}
/>
</FormItem>
{selectedDbType === 'h2' ? (
<FormItem name="url" label="链接" rules={[{ required: true, message: '请输入链接' }]}>
<Input placeholder="请输入链接" />
</FormItem>
) : (
<>
<FormItem name="host" label="host" rules={[{ required: true, message: '请输入IP' }]}>
<Input placeholder="请输入IP" />
</FormItem>
<FormItem
name="port"
label="port"
rules={[{ required: true, message: '请输入端口号' }]}
>
<Input placeholder="请输入端口号" />
</FormItem>
</>
)}
<FormItem
name="username"
label="用户名"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input placeholder="请输入用户名" />
</FormItem>
<FormItem name="password" label="密码">
<Input.Password placeholder="请输入密码" />
</FormItem>
<FormItem name="database" label="数据库名称">
<Input placeholder="请输入数据库名称" />
</FormItem>
<FormItem name="description" label="描述">
<TextArea placeholder="请输入数据库描述" style={{ height: 100 }} />
</FormItem>
<FormItem>
<Space>
<Button
type="primary"
onClick={() => {
testDatabaseConnection();
}}
>
</Button>
<Button
type="primary"
onClick={() => {
saveDatabaseConfig();
}}
>
</Button>
</Space>
</FormItem>
</Form>
</>
);
};
export default forwardRef(DatabaseCreateForm);

View File

@@ -0,0 +1,35 @@
import { Space } from 'antd';
import React, { useRef } from 'react';
import type { Dispatch } from 'umi';
import { connect } from 'umi';
import type { StateType } from '../../model';
import ProCard from '@ant-design/pro-card';
import DatabaseCreateForm from './DatabaseCreateForm';
type Props = {
dispatch: Dispatch;
domainManger: StateType;
};
const DatabaseSection: React.FC<Props> = ({ domainManger }) => {
const { selectDomainId } = domainManger;
const entityCreateRef = useRef<any>({});
return (
<div style={{ width: 800, margin: '0 auto' }}>
<Space direction="vertical" style={{ width: '100%' }} size={20}>
<ProCard title="数据库设置" bordered>
<DatabaseCreateForm
ref={entityCreateRef}
domainId={Number(selectDomainId)}
onSubmit={() => {}}
/>
</ProCard>
</Space>
</div>
);
};
export default connect(({ domainManger }: { domainManger: StateType }) => ({
domainManger,
}))(DatabaseSection);

View File

@@ -0,0 +1,165 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Input, Modal, Select } from 'antd';
import { SENSITIVE_LEVEL_OPTIONS } from '../constant';
import { formLayout } from '@/components/FormHelper/utils';
import SqlEditor from '@/components/SqlEditor';
import { message } from 'antd';
export type CreateFormProps = {
dimensionItem: any;
onCancel: () => void;
bindModalVisible: boolean;
dataSourceList: any[];
onSubmit: (values: any) => Promise<any>;
};
const FormItem = Form.Item;
const { Option } = Select;
const { TextArea } = Input;
const DimensionInfoModal: React.FC<CreateFormProps> = ({
onCancel,
bindModalVisible,
dimensionItem,
dataSourceList,
onSubmit: handleUpdate,
}) => {
const isEdit = dimensionItem?.id;
const [formVals, setFormVals] = useState<any>({
roleCode: '',
users: [],
effectiveTime: 1,
});
const [form] = Form.useForm();
const { setFieldsValue } = form;
const handleSubmit = async () => {
const fieldsValue = await form.validateFields();
setFormVals({ ...fieldsValue });
try {
await handleUpdate(fieldsValue);
} catch (error) {
message.error('保存失败,接口调用出错');
}
};
const setFormVal = () => {
setFieldsValue(dimensionItem);
};
useEffect(() => {
if (dimensionItem) {
setFormVal();
}
}, [dimensionItem]);
const renderFooter = () => {
return (
<>
<Button onClick={onCancel}></Button>
<Button type="primary" onClick={handleSubmit}>
</Button>
</>
);
};
const renderContent = () => {
return (
<>
<FormItem hidden={true} name="id" label="ID">
<Input placeholder="id" />
</FormItem>
<FormItem
name="name"
label="维度中文名"
rules={[{ required: true, message: '请输入维度中文名' }]}
>
<Input placeholder="名称不可重复" />
</FormItem>
<FormItem
name="bizName"
label="维度英文名"
rules={[{ required: true, message: '请输入维度英文名' }]}
>
<Input placeholder="名称不可重复" disabled={isEdit} />
</FormItem>
<FormItem
name="datasourceId"
label="所属数据源"
rules={[{ required: true, message: '请选择所属数据源' }]}
>
<Select placeholder="请选择数据源" disabled={isEdit}>
{dataSourceList.map((item) => (
<Option key={item.id} value={item.id}>
{item.name}
</Option>
))}
</Select>
</FormItem>
<FormItem
name="semanticType"
label="类型"
rules={[{ required: true, message: '请选择维度类型' }]}
>
<Select placeholder="请选择维度类型">
{['CATEGORY', 'ID', 'DATE'].map((item) => (
<Option key={item} value={item}>
{item}
</Option>
))}
</Select>
</FormItem>
<FormItem
name="sensitiveLevel"
label="敏感度"
rules={[{ required: true, message: '请选择敏感度' }]}
>
<Select placeholder="请选择敏感度">
{SENSITIVE_LEVEL_OPTIONS.map((item) => (
<Option key={item.value} value={item.value}>
{item.label}
</Option>
))}
</Select>
</FormItem>
<FormItem
name="description"
label="维度描述"
rules={[{ required: true, message: '请输入维度描述' }]}
>
<TextArea placeholder="请输入维度描述" />
</FormItem>
<FormItem name="expr" label="表达式" rules={[{ required: true, message: '请输入表达式' }]}>
<SqlEditor height={'150px'} />
</FormItem>
</>
);
};
return (
<Modal
width={800}
destroyOnClose
title="维度信息"
style={{ top: 48 }}
maskClosable={false}
open={bindModalVisible}
footer={renderFooter()}
onCancel={onCancel}
>
<Form
{...formLayout}
form={form}
initialValues={{
...formVals,
}}
>
{renderContent()}
</Form>
</Modal>
);
};
export default DimensionInfoModal;

View File

@@ -0,0 +1,135 @@
import { useState, forwardRef } from 'react';
import type { ForwardRefRenderFunction } from 'react';
import { Form, Button } from 'antd';
import FormItemTitle from '@/components/FormHelper/FormItemTitle';
import { formLayout } from '@/components/FormHelper/utils';
import DimensionMetricVisibleModal from './DimensionMetricVisibleModal';
import DimensionSearchVisibleModal from './DimensionSearchVisibleModal';
type Props = {
themeData: any;
metricList: any[];
dimensionList: any[];
domainId: number;
onSubmit: (params?: any) => void;
};
const FormItem = Form.Item;
const DimensionMetricVisibleForm: ForwardRefRenderFunction<any, Props> = ({
domainId,
metricList,
dimensionList,
themeData,
onSubmit,
}) => {
const [dimensionModalVisible, setDimensionModalVisible] = useState(false);
const [dimensionSearchModalVisible, setDimensionSearchModalVisible] = useState(false);
const [metricModalVisible, setMetricModalVisible] = useState<boolean>(false);
return (
<>
<Form {...formLayout}>
<FormItem
label={
<FormItemTitle title={'可见维度'} subTitle={'设置可见后,维度将允许在问答中被使用'} />
}
>
<Button
type="primary"
onClick={() => {
setDimensionModalVisible(true);
}}
>
</Button>
</FormItem>
<FormItem
label={
<FormItemTitle title={'可见指标'} subTitle={'设置可见后,指标将允许在问答中被使用'} />
}
>
<Button
type="primary"
onClick={() => {
setMetricModalVisible(true);
}}
>
</Button>
</FormItem>
<FormItem
label={
<FormItemTitle
title={'可见维度值'}
subTitle={'设置可见后,在可见维度设置的基础上,维度值将在搜索时可以被联想出来'}
/>
}
>
<Button
type="primary"
onClick={() => {
setDimensionSearchModalVisible(true);
}}
>
</Button>
</FormItem>
</Form>
{dimensionModalVisible && (
<DimensionMetricVisibleModal
domainId={domainId}
themeData={themeData}
settingSourceList={dimensionList}
settingType="dimension"
visible={dimensionModalVisible}
onCancel={() => {
setDimensionModalVisible(false);
}}
onSubmit={() => {
onSubmit?.();
setDimensionModalVisible(false);
}}
/>
)}
{dimensionSearchModalVisible && (
<DimensionSearchVisibleModal
domainId={domainId}
settingSourceList={dimensionList.filter((item) => {
const blackDimensionList = themeData.visibility?.blackDimIdList;
if (Array.isArray(blackDimensionList)) {
return !blackDimensionList.includes(item.id);
}
return false;
})}
themeData={themeData}
visible={dimensionSearchModalVisible}
onCancel={() => {
setDimensionSearchModalVisible(false);
}}
onSubmit={() => {
onSubmit?.({ from: 'dimensionSearchVisible' });
setDimensionSearchModalVisible(false);
}}
/>
)}
{metricModalVisible && (
<DimensionMetricVisibleModal
domainId={domainId}
themeData={themeData}
settingSourceList={metricList}
settingType="metric"
visible={metricModalVisible}
onCancel={() => {
setMetricModalVisible(false);
}}
onSubmit={() => {
onSubmit?.();
setMetricModalVisible(false);
}}
/>
)}
</>
);
};
export default forwardRef(DimensionMetricVisibleForm);

View File

@@ -0,0 +1,151 @@
import React, { useEffect, useState } from 'react';
import { Button, Modal, message } from 'antd';
import { addDomainExtend, editDomainExtend, getDomainExtendDetailConfig } from '../../service';
import DimensionMetricVisibleTransfer from './DimensionMetricVisibleTransfer';
type Props = {
domainId: number;
themeData: any;
settingType: 'dimension' | 'metric';
settingSourceList: any[];
onCancel: () => void;
visible: boolean;
onSubmit: (params?: any) => void;
};
const dimensionConfig = {
blackIdListKey: 'blackDimIdList',
visibleIdListKey: 'whiteDimIdList',
modalTitle: '问答可见维度信息',
titles: ['不可见维度', '可见维度'],
};
const metricConfig = {
blackIdListKey: 'blackMetricIdList',
visibleIdListKey: 'whiteMetricIdList',
modalTitle: '问答可见指标信息',
titles: ['不可见指标', '可见指标'],
};
const DimensionMetricVisibleModal: React.FC<Props> = ({
domainId,
visible,
themeData = {},
settingType,
settingSourceList,
onCancel,
onSubmit,
}) => {
const [sourceList, setSourceList] = useState<any[]>([]);
const [visibilityData, setVisibilityData] = useState<any>({});
const [selectedKeyList, setSelectedKeyList] = useState<string[]>([]);
const settingTypeConfig = settingType === 'dimension' ? dimensionConfig : metricConfig;
useEffect(() => {
const list = settingSourceList.map((item: any) => {
const { id, name } = item;
return { id, name, type: settingType };
});
setSourceList(list);
}, [settingSourceList]);
const queryThemeListData: any = async () => {
const { code, data } = await getDomainExtendDetailConfig({
domainId,
});
if (code === 200) {
setVisibilityData(data.visibility);
return;
}
message.error('获取可见信息失败');
};
useEffect(() => {
queryThemeListData();
}, []);
useEffect(() => {
setSelectedKeyList(visibilityData?.[settingTypeConfig.visibleIdListKey] || []);
}, [visibilityData]);
const saveEntity = async () => {
const { id } = themeData;
let saveDomainExtendQuery = addDomainExtend;
if (id) {
saveDomainExtendQuery = editDomainExtend;
}
const blackIdList = settingSourceList.reduce((list, item: any) => {
const { id: targetId } = item;
if (!selectedKeyList.includes(targetId)) {
list.push(targetId);
}
return list;
}, []);
const params = {
...themeData,
visibility: themeData.visibility || {},
};
params.visibility[settingTypeConfig.blackIdListKey] = blackIdList;
if (!params.visibility.blackDimIdList) {
params.visibility.blackDimIdList = [];
}
if (!params.visibility.blackMetricIdList) {
params.visibility.blackMetricIdList = [];
}
const { code, msg } = await saveDomainExtendQuery({
...params,
id,
domainId,
});
if (code === 200) {
onSubmit?.();
message.success('保存成功');
return;
}
message.error(msg);
};
const handleTransferChange = (newTargetKeys: string[]) => {
setSelectedKeyList(newTargetKeys);
};
const renderFooter = () => {
return (
<>
<Button onClick={onCancel}></Button>
<Button
type="primary"
onClick={() => {
saveEntity();
}}
>
</Button>
</>
);
};
return (
<>
<Modal
width={1200}
destroyOnClose
title={settingTypeConfig.modalTitle}
maskClosable={false}
open={visible}
footer={renderFooter()}
onCancel={onCancel}
>
<DimensionMetricVisibleTransfer
titles={settingTypeConfig.titles}
sourceList={sourceList}
targetList={selectedKeyList}
onChange={(newTargetKeys) => {
handleTransferChange(newTargetKeys);
}}
/>
</Modal>
</>
);
};
export default DimensionMetricVisibleModal;

View File

@@ -0,0 +1,88 @@
import { Transfer, Tag } from 'antd';
import React, { useEffect, useState } from 'react';
interface RecordType {
key: string;
name: string;
type: 'dimension' | 'metric';
}
type Props = {
sourceList: any[];
targetList: string[];
titles?: string[];
onChange?: (params?: any) => void;
transferProps?: Record<string, any>;
};
const DimensionMetricVisibleTransfer: React.FC<Props> = ({
sourceList = [],
targetList = [],
titles,
transferProps = {},
onChange,
}) => {
const [transferData, setTransferData] = useState<RecordType[]>([]);
const [targetKeys, setTargetKeys] = useState<string[]>(targetList);
useEffect(() => {
setTransferData(
sourceList.map(({ id, name, type }: any) => {
return {
key: id,
name,
type,
};
}),
);
}, [sourceList]);
useEffect(() => {
setTargetKeys(targetList);
}, [targetList]);
const handleChange = (newTargetKeys: string[]) => {
setTargetKeys(newTargetKeys);
onChange?.(newTargetKeys);
};
return (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Transfer
dataSource={transferData}
showSearch
titles={titles || ['不可见维度', '可见维度']}
listStyle={{
width: 430,
height: 500,
}}
filterOption={(inputValue: string, item: any) => {
const { name } = item;
if (name.includes(inputValue)) {
return true;
}
return false;
}}
targetKeys={targetKeys}
onChange={handleChange}
render={(item) => (
<div style={{ display: 'flex' }}>
<span style={{ flex: '1' }}>{item.name}</span>
<span style={{ flex: '0 1 40px' }}>
{item.type === 'dimension' ? (
<Tag color="blue">{'维度'}</Tag>
) : item.type === 'metric' ? (
<Tag color="orange">{'指标'}</Tag>
) : (
<></>
)}
</span>
</div>
)}
{...transferProps}
/>
</div>
);
};
export default DimensionMetricVisibleTransfer;

View File

@@ -0,0 +1,138 @@
import React, { useEffect, useState } from 'react';
import { Button, Modal, message, Space } from 'antd';
import ProCard from '@ant-design/pro-card';
import { addDomainExtend, editDomainExtend } from '../../service';
import DimensionMetricVisibleTransfer from './DimensionMetricVisibleTransfer';
import SqlEditor from '@/components/SqlEditor';
type Props = {
domainId: number;
themeData: any;
settingSourceList: any[];
onCancel: () => void;
visible: boolean;
onSubmit: (params?: any) => void;
};
const DimensionSearchVisibleModal: React.FC<Props> = ({
domainId,
themeData,
visible,
settingSourceList,
onCancel,
onSubmit,
}) => {
const [sourceList, setSourceList] = useState<any[]>([]);
const [selectedKeyList, setSelectedKeyList] = useState<string[]>([]);
const [dictRules, setDictRules] = useState<string>('');
useEffect(() => {
const dictionaryInfos = themeData?.dictionaryInfos;
if (Array.isArray(dictionaryInfos)) {
const target = dictionaryInfos[0];
if (Array.isArray(target?.ruleList)) {
setDictRules(target.ruleList[0]);
}
const selectKeys = dictionaryInfos.map((item: any) => {
return item.itemId;
});
setSelectedKeyList(selectKeys);
}
}, [themeData]);
useEffect(() => {
const list = settingSourceList.map((item: any) => {
const { id, name } = item;
return { id, name, type: 'dimension' };
});
setSourceList(list);
}, [settingSourceList]);
const saveDictBatch = async () => {
const dictionaryInfos = selectedKeyList.map((key: string) => {
return {
itemId: key,
type: 'DIMENSION',
isDictInfo: true,
ruleList: dictRules ? [dictRules] : [],
};
});
const id = themeData?.id;
let saveDomainExtendQuery = addDomainExtend;
if (id) {
saveDomainExtendQuery = editDomainExtend;
}
const { code, msg } = await saveDomainExtendQuery({
dictionaryInfos,
domainId,
id,
});
if (code === 200) {
message.success('保存可见维度值成功');
onSubmit?.();
return;
}
message.error(msg);
};
const saveDictSetting = async () => {
await saveDictBatch();
};
const handleTransferChange = (newTargetKeys: string[]) => {
setSelectedKeyList(newTargetKeys);
};
const renderFooter = () => {
return (
<>
<Button onClick={onCancel}></Button>
<Button
type="primary"
onClick={() => {
saveDictSetting();
}}
>
</Button>
</>
);
};
return (
<>
<Modal
width={1200}
destroyOnClose
title={'可见维度值设置'}
maskClosable={false}
open={visible}
footer={renderFooter()}
onCancel={onCancel}
>
<Space direction="vertical" style={{ width: '100%' }} size={20}>
<ProCard bordered title="可见设置">
<DimensionMetricVisibleTransfer
titles={['不可见维度值', '可见维度值']}
sourceList={sourceList}
targetList={selectedKeyList}
onChange={(newTargetKeys) => {
handleTransferChange(newTargetKeys);
}}
/>
</ProCard>
<ProCard bordered title="维度值过滤">
<SqlEditor
height={'150px'}
value={dictRules}
onChange={(sql: string) => {
setDictRules(sql);
}}
/>
</ProCard>
</Space>
</Modal>
</>
);
};
export default DimensionSearchVisibleModal;

View File

@@ -0,0 +1,168 @@
import { useEffect, useState, forwardRef, useImperativeHandle } from 'react';
import type { ForwardRefRenderFunction } from 'react';
import { message, Form, Input, Select, Button } from 'antd';
import { addDomainExtend, editDomainExtend } from '../../service';
import { formLayout } from '@/components/FormHelper/utils';
import styles from '../style.less';
type Props = {
entityData: any;
metricList: any[];
dimensionList: any[];
domainId: number;
onSubmit: (params?: any) => void;
};
const FormItem = Form.Item;
const TextArea = Input.TextArea;
const EntityCreateForm: ForwardRefRenderFunction<any, Props> = (
{ entityData, metricList, dimensionList, domainId, onSubmit },
ref,
) => {
const [form] = Form.useForm();
const [metricListOptions, setMetricListOptions] = useState<any>([]);
const [dimensionListOptions, setDimensionListOptions] = useState<any>([]);
const getFormValidateFields = async () => {
return await form.validateFields();
};
useEffect(() => {
form.resetFields();
if (Object.keys(entityData).length === 0) {
return;
}
const { detailData = {}, names = [] } = entityData;
if (!detailData.dimensionIds) {
entityData = {
...entityData,
detailData: {
...detailData,
dimensionIds: [],
},
};
}
if (!detailData.metricIds) {
entityData = {
...entityData,
detailData: {
...detailData,
metricIds: [],
},
};
}
form.setFieldsValue({ ...entityData, name: names.join(',') });
}, [entityData]);
useImperativeHandle(ref, () => ({
getFormValidateFields,
}));
useEffect(() => {
const metricOption = metricList.map((item: any) => {
return {
label: item.name,
value: item.id,
};
});
setMetricListOptions(metricOption);
}, [metricList]);
useEffect(() => {
const dimensionEnum = dimensionList.map((item: any) => {
return {
label: item.name,
value: item.id,
};
});
setDimensionListOptions(dimensionEnum);
}, [dimensionList]);
const saveEntity = async () => {
const values = await form.validateFields();
const { id, name } = values;
let saveDomainExtendQuery = addDomainExtend;
if (id) {
saveDomainExtendQuery = editDomainExtend;
}
const { code, msg, data } = await saveDomainExtendQuery({
entity: {
...values,
names: name.split(','),
},
domainId,
});
if (code === 200) {
form.setFieldValue('id', data);
onSubmit?.();
message.success('保存成功');
return;
}
message.error(msg);
};
return (
<>
<Form {...formLayout} form={form} layout="vertical" className={styles.form}>
<FormItem hidden={true} name="id" label="ID">
<Input placeholder="id" />
</FormItem>
<FormItem
name="name"
label="实体名称"
rules={[{ required: true, message: '请输入实体名称' }]}
>
<TextArea
placeholder="请输入实体名称,多个实体名称以英文逗号分隔"
style={{ height: 100 }}
/>
</FormItem>
<FormItem
name="entityIds"
label="唯一标识"
rules={[{ required: true, message: '请选择实体标识' }]}
>
<Select
mode="multiple"
allowClear
style={{ width: '100%' }}
placeholder="请选择主体标识"
options={dimensionListOptions}
/>
</FormItem>
<FormItem name={['detailData', 'dimensionIds']} label="维度信息">
<Select
mode="multiple"
allowClear
style={{ width: '100%' }}
placeholder="请选择展示维度信息"
options={dimensionListOptions}
/>
</FormItem>
<FormItem name={['detailData', 'metricIds']} label="指标信息">
<Select
mode="multiple"
allowClear
style={{ width: '100%' }}
placeholder="请选择展示指标信息"
options={metricListOptions}
/>
</FormItem>
<FormItem>
<Button
type="primary"
onClick={() => {
saveEntity();
}}
>
</Button>
</FormItem>
</Form>
</>
);
};
export default forwardRef(EntityCreateForm);

View File

@@ -0,0 +1,102 @@
import { message, Space } from 'antd';
import React, { useState, useEffect, useRef } from 'react';
import type { Dispatch } from 'umi';
import { connect } from 'umi';
import type { StateType } from '../../model';
import { getDomainExtendConfig } from '../../service';
import ProCard from '@ant-design/pro-card';
import EntityCreateForm from './EntityCreateForm';
import MetricSettingForm from './MetricSettingForm';
import DimensionMetricVisibleForm from './DimensionMetricVisibleForm';
type Props = {
dispatch: Dispatch;
domainManger: StateType;
};
const EntitySection: React.FC<Props> = ({ domainManger, dispatch }) => {
const { selectDomainId, dimensionList, metricList } = domainManger;
const [entityData, setEntityData] = useState<any>({});
const [themeData, setThemeData] = useState<any>({});
const entityCreateRef = useRef<any>({});
const queryThemeListData: any = async () => {
const { code, data } = await getDomainExtendConfig({
domainId: selectDomainId,
});
if (code === 200) {
const target = data?.[0] || {};
if (target) {
setThemeData(target);
setEntityData({
id: target.id,
...target.entity,
});
}
return;
}
message.error('获取主题域解析词失败');
};
const initPage = async () => {
queryThemeListData();
};
useEffect(() => {
initPage();
}, [selectDomainId]);
return (
<div style={{ width: 800, margin: '0 auto' }}>
<Space direction="vertical" style={{ width: '100%' }} size={20}>
<ProCard bordered title="问答可见">
<DimensionMetricVisibleForm
themeData={themeData}
domainId={Number(selectDomainId)}
metricList={metricList}
dimensionList={dimensionList}
onSubmit={(params: any = {}) => {
if (params.from === 'dimensionSearchVisible') {
dispatch({
type: 'domainManger/queryDimensionList',
payload: {
domainId: selectDomainId,
},
});
}
queryThemeListData();
}}
/>
</ProCard>
<ProCard bordered title="默认指标">
<MetricSettingForm
domainId={Number(selectDomainId)}
themeData={themeData}
metricList={metricList}
onSubmit={() => {
queryThemeListData();
}}
/>
</ProCard>
<ProCard title="实体" bordered>
<EntityCreateForm
ref={entityCreateRef}
domainId={Number(selectDomainId)}
entityData={entityData}
metricList={metricList}
dimensionList={dimensionList}
onSubmit={() => {
queryThemeListData();
}}
/>
</ProCard>
</Space>
</div>
);
};
export default connect(({ domainManger }: { domainManger: StateType }) => ({
domainManger,
}))(EntitySection);

View File

@@ -0,0 +1,187 @@
import { useEffect, useState, forwardRef, useImperativeHandle } from 'react';
import type { ForwardRefRenderFunction } from 'react';
import FormItemTitle from '@/components/FormHelper/FormItemTitle';
import { formLayout } from '@/components/FormHelper/utils';
import { message, Form, Input, Select, Button, InputNumber } from 'antd';
import { addDomainExtend, editDomainExtend } from '../../service';
import styles from '../style.less';
type Props = {
themeData: any;
metricList: any[];
domainId: number;
onSubmit: (params?: any) => void;
};
const FormItem = Form.Item;
const Option = Select.Option;
const MetricSettingForm: ForwardRefRenderFunction<any, Props> = (
{ metricList, domainId, themeData: uniqueMetricData },
ref,
) => {
const [form] = Form.useForm();
const [metricListOptions, setMetricListOptions] = useState<any>([]);
const [unitState, setUnit] = useState<number | null>();
const [periodState, setPeriod] = useState<string>();
const getFormValidateFields = async () => {
return await form.validateFields();
};
useImperativeHandle(ref, () => ({
getFormValidateFields,
}));
useEffect(() => {
form.resetFields();
setUnit(null);
setPeriod('');
if (Object.keys(uniqueMetricData).length === 0) {
return;
}
const { defaultMetrics = [], id } = uniqueMetricData;
const defaultMetric = defaultMetrics[0];
const recordId = id === -1 ? undefined : id;
if (defaultMetric) {
const { period, unit } = defaultMetric;
setUnit(unit);
setPeriod(period);
form.setFieldsValue({
...defaultMetric,
id: recordId,
});
} else {
form.setFieldsValue({
id: recordId,
});
}
}, [uniqueMetricData]);
useEffect(() => {
const metricOption = metricList.map((item: any) => {
return {
label: item.name,
value: item.id,
};
});
setMetricListOptions(metricOption);
}, [metricList]);
const saveEntity = async () => {
const values = await form.validateFields();
const { id } = values;
let saveDomainExtendQuery = addDomainExtend;
if (id) {
saveDomainExtendQuery = editDomainExtend;
}
const { code, msg, data } = await saveDomainExtendQuery({
defaultMetrics: [{ ...values }],
domainId,
id,
});
if (code === 200) {
form.setFieldValue('id', data);
message.success('保存成功');
return;
}
message.error(msg);
};
return (
<>
<Form
{...formLayout}
form={form}
layout="vertical"
className={styles.form}
initialValues={{
unit: 7,
period: 'DAY',
}}
>
<FormItem hidden={true} name="id" label="ID">
<Input placeholder="id" />
</FormItem>
<FormItem
name={'metricId'}
label={
<FormItemTitle
title={'指标'}
subTitle={'问答搜索结果选择中,如果没有指定指标,将会采用默认指标进行展示'}
/>
}
>
<Select
allowClear
showSearch
style={{ width: '100%' }}
placeholder="请选择展示指标信息"
options={metricListOptions}
/>
</FormItem>
<FormItem
label={
<FormItemTitle
title={'时间范围'}
subTitle={'问答搜索结果选择中,如果没有指定时间范围,将会采用默认时间范围'}
/>
}
>
<Input.Group compact>
<span
style={{
display: 'inline-block',
lineHeight: '32px',
marginRight: '8px',
}}
>
</span>
<InputNumber
value={unitState}
style={{ width: '120px' }}
onChange={(value) => {
setUnit(value);
form.setFieldValue('unit', value);
}}
/>
<Select
value={periodState}
style={{ width: '100px' }}
onChange={(value) => {
form.setFieldValue('period', value);
setPeriod(value);
}}
>
<Option value="DAY"></Option>
<Option value="WEEK"></Option>
<Option value="MONTH"></Option>
<Option value="YEAR"></Option>
</Select>
</Input.Group>
</FormItem>
<FormItem name="unit" hidden={true}>
<InputNumber />
</FormItem>
<FormItem name="period" hidden={true}>
<Input />
</FormItem>
<FormItem>
<Button
type="primary"
onClick={() => {
saveEntity();
}}
>
</Button>
</FormItem>
</Form>
</>
);
};
export default forwardRef(MetricSettingForm);

View File

@@ -0,0 +1,288 @@
import React, { useEffect, useRef, useState } from 'react';
import { Form, Button, Modal, Steps, Input, Select, Switch, InputNumber } from 'antd';
import MetricMeasuresFormTable from './MetricMeasuresFormTable';
import { SENSITIVE_LEVEL_OPTIONS } from '../constant';
import { formLayout } from '@/components/FormHelper/utils';
import FormItemTitle from '@/components/FormHelper/FormItemTitle';
import styles from './style.less';
import { getMeasureListByDomainId } from '../service';
export type CreateFormProps = {
domainId: number;
createModalVisible: boolean;
metricItem: any;
onCancel?: () => void;
onSubmit: (values: any) => void;
};
const { Step } = Steps;
const FormItem = Form.Item;
const { TextArea } = Input;
const { Option } = Select;
const MetricInfoCreateForm: React.FC<CreateFormProps> = ({
domainId,
onCancel,
createModalVisible,
metricItem,
onSubmit,
}) => {
const isEdit = !!metricItem?.id;
const [currentStep, setCurrentStep] = useState(0);
const formValRef = useRef({} as any);
const [form] = Form.useForm();
const updateFormVal = (val: SaveDataSetForm) => {
formValRef.current = val;
};
const [classMeasureList, setClassMeasureList] = useState<any[]>([]);
const [exprTypeParamsState, setExprTypeParamsState] = useState<any>([]);
const [exprSql, setExprSql] = useState<string>('');
const [isPercentState, setIsPercentState] = useState<boolean>(false);
const forward = () => setCurrentStep(currentStep + 1);
const backward = () => setCurrentStep(currentStep - 1);
const queryClassMeasureList = async () => {
const { code, data } = await getMeasureListByDomainId(domainId);
if (code === 200) {
setClassMeasureList(data);
return;
}
setClassMeasureList([]);
};
useEffect(() => {
queryClassMeasureList();
}, []);
const handleNext = async () => {
const fieldsValue = await form.validateFields();
const submitForm = {
...formValRef.current,
...fieldsValue,
typeParams: {
expr: exprSql,
measures: exprTypeParamsState,
},
dataFormatType: isPercentState ? 'percent' : '',
};
updateFormVal(submitForm);
if (currentStep < 1) {
forward();
} else {
onSubmit?.(submitForm);
}
};
const initData = () => {
const {
id,
name,
bizName,
description,
sensitiveLevel,
typeParams: typeParams,
dataFormat,
dataFormatType,
} = metricItem as any;
const isPercent = dataFormatType === 'percent';
const initValue = {
id,
name,
bizName,
sensitiveLevel,
description,
isPercent,
dataFormat: dataFormat || {
decimalPlaces: 2,
needMultiply100: false,
},
};
const editInitFormVal = {
...formValRef.current,
...initValue,
};
updateFormVal(editInitFormVal);
form.setFieldsValue(initValue);
setExprTypeParamsState(typeParams.measures);
setExprSql(typeParams.expr);
setIsPercentState(isPercent);
};
useEffect(() => {
if (isEdit) {
initData();
} else {
// initFields([]);
}
}, [metricItem]);
const renderContent = () => {
if (currentStep === 1) {
return (
<MetricMeasuresFormTable
typeParams={{
measures: exprTypeParamsState,
expr: exprSql,
}}
measuresList={classMeasureList}
onFieldChange={(typeParams: any) => {
setExprTypeParamsState([...typeParams]);
}}
onSqlChange={(sql: string) => {
setExprSql(sql);
}}
/>
);
}
return (
<>
<FormItem hidden={true} name="id" label="ID">
<Input placeholder="id" />
</FormItem>
<FormItem
name="name"
label="指标中文名"
rules={[{ required: true, message: '请输入指标中文名' }]}
>
<Input placeholder="名称不可重复" />
</FormItem>
<FormItem
name="bizName"
label="指标英文名"
rules={[{ required: true, message: '请输入指标英文名' }]}
>
<Input placeholder="名称不可重复" disabled={isEdit} />
</FormItem>
<FormItem
name="sensitiveLevel"
label="敏感度"
rules={[{ required: true, message: '请选择敏感度' }]}
>
<Select placeholder="请选择敏感度">
{SENSITIVE_LEVEL_OPTIONS.map((item) => (
<Option key={item.value} value={item.value}>
{item.label}
</Option>
))}
</Select>
</FormItem>
<FormItem
name="description"
label="指标描述"
rules={[{ required: true, message: '请输入指标描述' }]}
>
<TextArea placeholder="请输入指标描述" />
</FormItem>
<FormItem
label={
<FormItemTitle
title={'是否展示为百分比'}
subTitle={'开启后指标数据展示时会根据配置进行格式化如0.02 -> 2%'}
/>
}
name="isPercent"
valuePropName="checked"
>
<Switch />
</FormItem>
{isPercentState && (
<>
<FormItem
label={
<FormItemTitle
title={'小数位数'}
subTitle={'对小数位数进行设置如保留两位0.021252 -> 2.12%'}
/>
}
name={['dataFormat', 'decimalPlaces']}
>
<InputNumber placeholder="请输入需要保留小数位数" style={{ width: '300px' }} />
</FormItem>
<FormItem
label={
<FormItemTitle
title={'原始值是否乘以100'}
subTitle={'如 原始值0.001 ->展示值0.1% '}
/>
// <FormItemTitle
// title={'仅添加百分号'}
// subTitle={'开启后,会对原始数值直接加%如0.02 -> 0.02%'}
// />
}
name={['dataFormat', 'needMultiply100']}
valuePropName="checked"
>
<Switch />
</FormItem>
</>
)}
</>
);
};
const renderFooter = () => {
if (currentStep === 1) {
return (
<>
<Button style={{ float: 'left' }} onClick={backward}>
</Button>
<Button onClick={onCancel}></Button>
<Button type="primary" onClick={handleNext}>
</Button>
</>
);
}
return (
<>
<Button onClick={onCancel}></Button>
<Button type="primary" onClick={handleNext}>
</Button>
</>
);
};
return (
<Modal
forceRender
width={1300}
style={{ top: 48 }}
bodyStyle={{ padding: '32px 40px 48px' }}
destroyOnClose
title={`${isEdit ? '编辑' : '新建'}指标`}
maskClosable={false}
open={createModalVisible}
footer={renderFooter()}
onCancel={onCancel}
>
<Steps style={{ marginBottom: 28 }} size="small" current={currentStep}>
<Step title="基本信息" />
<Step title="度量信息" />
</Steps>
<Form
{...formLayout}
form={form}
initialValues={{
...formValRef.current,
}}
onValuesChange={(value) => {
const { isPercent } = value;
if (isPercent !== undefined) {
setIsPercentState(isPercent);
}
}}
className={styles.form}
>
{renderContent()}
</Form>
</Modal>
);
};
export default MetricInfoCreateForm;

View File

@@ -0,0 +1,192 @@
import React, { useState, useRef, useEffect } from 'react';
import { Button, Input, Space } from 'antd';
import ProTable from '@ant-design/pro-table';
import ProCard from '@ant-design/pro-card';
import SqlEditor from '@/components/SqlEditor';
import BindMeasuresTable from './BindMeasuresTable';
type Props = {
typeParams: any;
measuresList: any[];
onFieldChange: (measures: any[]) => void;
onSqlChange: (sql: string) => void;
};
const { TextArea } = Input;
const MetricMeasuresFormTable: React.FC<Props> = ({
typeParams,
measuresList,
onFieldChange,
onSqlChange,
}) => {
const actionRef = useRef<ActionType>();
const [measuresModalVisible, setMeasuresModalVisible] = useState<boolean>(false);
const [measuresParams, setMeasuresParams] = useState(
typeParams || {
expr: '',
measures: [],
},
);
useEffect(() => {
setMeasuresParams({ ...typeParams });
}, [typeParams]);
const [exprString, setExprString] = useState(typeParams?.expr || '');
const columns = [
{
dataIndex: 'bizName',
title: '度量名称',
},
// {
// dataIndex: 'alias',
// title: '别名',
// render: (_: any, record: any) => {
// const { alias, name } = record;
// const { measures } = measuresParams;
// return (
// <Input
// placeholder="请输入别名"
// value={alias}
// onChange={(event) => {
// const { value } = event.target;
// const list = measures.map((item: any) => {
// if (item.name === name) {
// return {
// ...item,
// alias: value,
// };
// }
// return item;
// });
// onFieldChange?.(list);
// }}
// />
// );
// },
// },
{
dataIndex: 'constraint',
title: '限定条件',
tooltip:
'所用于过滤的维度需要存在于"维度"列表不需要加where关键字。比如维度A="值1" and 维度B="值2"',
render: (_: any, record: any) => {
const { constraint, name } = record;
const { measures } = measuresParams;
return (
<TextArea
placeholder="请输入限定条件"
value={constraint}
onChange={(event) => {
const { value } = event.target;
const list = measures.map((item: any) => {
if (item.name === name) {
return {
...item,
constraint: value,
};
}
return item;
});
onFieldChange?.(list);
}}
/>
);
},
},
{
title: '操作',
dataIndex: 'x',
valueType: 'option',
render: (_: any, record: any) => {
const { name } = record;
return (
<Space>
<a
key="deleteBtn"
onClick={() => {
const { measures } = measuresParams;
const list = measures.filter((item: any) => {
return item.name !== name;
});
onFieldChange?.(list);
}}
>
</a>
</Space>
);
},
},
];
return (
<>
<Space direction="vertical" style={{ width: '100%' }}>
<ProTable
actionRef={actionRef}
headerTitle="度量列表"
tooltip="一般用于在“指标”列表已有指标的基础上加工新指标比如指标NEW1=指标A/100指标NEW2=指标B/指标C。若需用到多个已有指标可以点击右上角“增加度量”"
rowKey="name"
columns={columns}
dataSource={measuresParams?.measures || []}
pagination={false}
search={false}
size="small"
options={false}
toolBarRender={() => [
<Button
key="create"
type="primary"
onClick={() => {
setMeasuresModalVisible(true);
}}
>
</Button>,
]}
/>
<ProCard
title={'度量表达式'}
tooltip="若为指标NEW1则填写指标A/100。若为指标NEW2则填写指标B/指标C"
>
<SqlEditor
value={exprString}
onChange={(sql: string) => {
const expr = sql;
setExprString(expr);
onSqlChange?.(expr);
}}
height={'150px'}
/>
</ProCard>
</Space>
{measuresModalVisible && (
<BindMeasuresTable
measuresList={measuresList}
selectedMeasuresList={measuresParams?.measures || []}
onSubmit={async (values: any[]) => {
const measures = values.map(({ bizName, name, expr, datasourceId }) => {
return {
bizName,
name,
expr,
datasourceId,
};
});
onFieldChange?.(measures);
setMeasuresModalVisible(false);
}}
onCancel={() => {
setMeasuresModalVisible(false);
}}
createModalVisible={measuresModalVisible}
/>
)}
</>
);
};
export default MetricMeasuresFormTable;

View File

@@ -0,0 +1,143 @@
import React, { useState, useEffect } from 'react';
import { Form, Input, Switch, message } from 'antd';
import SelectPartenr from '@/components/SelectPartner';
import SelectTMEPerson from '@/components/SelectTMEPerson';
import { connect } from 'umi';
import type { Dispatch } from 'umi';
import type { StateType } from '../../model';
import FormItemTitle from '@/components/FormHelper/FormItemTitle';
import { updateDomain, getDomainDetail } from '../../service';
import styles from '../style.less';
type Props = {
dispatch: Dispatch;
domainManger: StateType;
onSubmit?: (data?: any) => void;
onValuesChange?: (value, values) => void;
};
const FormItem = Form.Item;
const PermissionAdminForm: React.FC<Props> = ({ domainManger, onValuesChange }) => {
const [form] = Form.useForm();
const [isOpenState, setIsOpenState] = useState<boolean>(true);
const [classDetail, setClassDetail] = useState<any>({});
const { selectDomainId } = domainManger;
const { APP_TARGET } = process.env;
const queryClassDetail = async (domainId: number) => {
const { code, msg, data } = await getDomainDetail({ domainId });
if (code === 200) {
setClassDetail(data);
const fieldsValue = {
...data,
};
fieldsValue.admins = fieldsValue.admins || [];
fieldsValue.viewers = fieldsValue.viewers || [];
fieldsValue.viewOrgs = fieldsValue.viewOrgs || [];
fieldsValue.isOpen = !!fieldsValue.isOpen;
setIsOpenState(fieldsValue.isOpen);
form.setFieldsValue(fieldsValue);
return;
}
message.error(msg);
};
useEffect(() => {
queryClassDetail(selectDomainId);
}, [selectDomainId]);
const saveAuth = async () => {
const values = await form.validateFields();
const { admins, isOpen, viewOrgs = [], viewers = [] } = values;
const queryClassData = {
...classDetail,
admins,
viewOrgs,
viewers,
isOpen: isOpen ? 1 : 0,
};
const { code, msg } = await updateDomain(queryClassData);
if (code === 200) {
// message.success('保存成功');
return;
}
message.error(msg);
};
return (
<>
<Form
form={form}
layout="vertical"
onValuesChange={(value, values) => {
const { isOpen } = value;
if (isOpen !== undefined) {
setIsOpenState(isOpen);
}
saveAuth();
onValuesChange?.(value, values);
}}
className={styles.form}
>
<FormItem hidden={true} name="groupId" label="ID">
<Input placeholder="groupId" />
</FormItem>
<FormItem
name="admins"
label={
<FormItemTitle title={'管理员'} subTitle={'管理员将拥有主题域下所有编辑及访问权限'} />
}
>
<SelectTMEPerson placeholder="请邀请团队成员" />
</FormItem>
<Form.Item
label={
<FormItemTitle
title={'设为公开'}
subTitle={
'公开后,所有用户将可使用主题域下低/中敏感度资源,高敏感度资源需通过资源列表进行授权'
}
/>
}
name="isOpen"
valuePropName="checked"
>
<Switch />
</Form.Item>
{!isOpenState && (
<>
{APP_TARGET === 'inner' && (
<FormItem name="viewOrgs" label="按组织">
<SelectPartenr
type="selectedDepartment"
treeSelectProps={{
placeholder: '请选择需要授权的部门',
}}
/>
</FormItem>
)}
<FormItem name="viewers" label="按个人">
<SelectTMEPerson placeholder="请选择需要授权的个人" />
</FormItem>
</>
)}
{/* <FormItem>
<Button
type="primary"
onClick={() => {
saveAuth();
}}
>
保 存
</Button>
</FormItem> */}
</Form>
</>
);
};
export default connect(({ domainManger }: { domainManger: StateType }) => ({
domainManger,
}))(PermissionAdminForm);

View File

@@ -0,0 +1,202 @@
import React, { useEffect, useState, useRef } from 'react';
import { Button, message, Form, Space, Drawer, Input } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { connect } from 'umi';
import { createGroupAuth, updateGroupAuth } from '../../service';
import PermissionCreateForm from './PermissionCreateForm';
import type { StateType } from '../../model';
import SqlEditor from '@/components/SqlEditor';
import DimensionMetricVisibleTransfer from '../Entity/DimensionMetricVisibleTransfer';
import styles from '../style.less';
type Props = {
domainManger: StateType;
permissonData: any;
domainId: number;
onCancel: () => void;
visible: boolean;
onSubmit: (params?: any) => void;
};
const FormItem = Form.Item;
const TextArea = Input.TextArea;
const PermissionCreateDrawer: React.FC<Props> = ({
domainManger,
visible,
permissonData,
domainId,
onCancel,
onSubmit,
}) => {
const { dimensionList, metricList } = domainManger;
const [form] = Form.useForm();
const basicInfoFormRef = useRef<any>(null);
const [sourceDimensionList, setSourceDimensionList] = useState<any[]>([]);
const [sourceMetricList, setSourceMetricList] = useState<any[]>([]);
const [selectedDimensionKeyList, setSelectedDimensionKeyList] = useState<string[]>([]);
const [selectedMetricKeyList, setSelectedMetricKeyList] = useState<string[]>([]);
useEffect(() => {
const list = dimensionList.reduce((highList: any[], item: any) => {
const { name, bizName, sensitiveLevel } = item;
if (sensitiveLevel === 2) {
highList.push({ id: bizName, name, type: 'dimension' });
}
return highList;
}, []);
setSourceDimensionList(list);
}, [dimensionList]);
useEffect(() => {
const list = metricList.reduce((highList: any[], item: any) => {
const { name, bizName, sensitiveLevel } = item;
if (sensitiveLevel === 2) {
highList.push({ id: bizName, name, type: 'metric' });
}
return highList;
}, []);
setSourceMetricList(list);
}, [metricList]);
const saveAuth = async () => {
const basicInfoFormValues = await basicInfoFormRef.current.formRef.validateFields();
const values = await form.validateFields();
const { dimensionFilters, dimensionFilterDescription } = values;
const { authRules = [] } = permissonData;
let target = authRules?.[0];
if (!target) {
target = { dimensions: dimensionList };
} else {
target.dimensions = dimensionList;
}
permissonData.authRules = [target];
let saveAuthQuery = createGroupAuth;
if (basicInfoFormValues.groupId) {
saveAuthQuery = updateGroupAuth;
}
const { code, msg } = await saveAuthQuery({
...basicInfoFormValues,
dimensionFilters: [dimensionFilters],
dimensionFilterDescription,
authRules: [
{
dimensions: selectedDimensionKeyList,
metrics: selectedMetricKeyList,
},
],
domainId,
});
if (code === 200) {
onSubmit?.();
message.success('保存成功');
return;
}
message.error(msg);
};
useEffect(() => {
form.resetFields();
const { dimensionFilters, dimensionFilterDescription } = permissonData;
form.setFieldsValue({
dimensionFilterDescription,
dimensionFilters: Array.isArray(dimensionFilters) ? dimensionFilters[0] || '' : '',
});
setSelectedDimensionKeyList(permissonData?.authRules?.[0]?.dimensions || []);
setSelectedMetricKeyList(permissonData?.authRules?.[0]?.metrics || []);
}, [permissonData]);
const renderFooter = () => {
return (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Space>
<Button onClick={onCancel}></Button>
<Button
type="primary"
onClick={() => {
saveAuth();
}}
>
</Button>
</Space>
</div>
);
};
return (
<>
<Drawer
width={'100%'}
className={styles.permissionDrawer}
destroyOnClose
title={'权限组信息'}
maskClosable={false}
open={visible}
footer={renderFooter()}
onClose={onCancel}
>
<div style={{ overflow: 'auto', margin: '0 auto', width: '1000px' }}>
<Space direction="vertical" style={{ width: '100%' }} size={20}>
<ProCard title="基本信息" bordered>
<PermissionCreateForm
ref={basicInfoFormRef}
permissonData={permissonData}
domainId={domainId}
/>
</ProCard>
<ProCard title="列权限" bordered>
<DimensionMetricVisibleTransfer
titles={['未授权维度/指标', '已授权维度/指标']}
sourceList={[...sourceDimensionList, ...sourceMetricList]}
targetList={[...selectedDimensionKeyList, ...selectedMetricKeyList]}
onChange={(bizNameList: string[]) => {
const dimensionKeyChangeList = dimensionList.reduce(
(dimensionChangeList: string[], item: any) => {
if (bizNameList.includes(item.bizName)) {
dimensionChangeList.push(item.bizName);
}
return dimensionChangeList;
},
[],
);
const metricKeyChangeList = metricList.reduce(
(metricChangeList: string[], item: any) => {
if (bizNameList.includes(item.bizName)) {
metricChangeList.push(item.bizName);
}
return metricChangeList;
},
[],
);
setSelectedDimensionKeyList(dimensionKeyChangeList);
setSelectedMetricKeyList(metricKeyChangeList);
}}
/>
</ProCard>
<ProCard bordered title="行权限">
<div>
<Form form={form} layout="vertical">
<FormItem name="dimensionFilters" label="表达式">
<SqlEditor height={'150px'} />
</FormItem>
<FormItem name="dimensionFilterDescription" label="描述">
<TextArea placeholder="行权限描述" />
</FormItem>
</Form>
</div>
</ProCard>
</Space>
</div>
</Drawer>
</>
);
};
export default connect(({ domainManger }: { domainManger: StateType }) => ({
domainManger,
}))(PermissionCreateDrawer);

View File

@@ -0,0 +1,74 @@
import { useEffect, useImperativeHandle, forwardRef } from 'react';
import { Form, Input } from 'antd';
import type { ForwardRefRenderFunction } from 'react';
import SelectPartenr from '@/components/SelectPartner';
import SelectTMEPerson from '@/components/SelectTMEPerson';
import { formLayout } from '@/components/FormHelper/utils';
import styles from '../style.less';
type Props = {
domainId: number;
permissonData: any;
onSubmit?: (data?: any) => void;
onValuesChange?: (value, values) => void;
};
const FormItem = Form.Item;
const PermissionCreateForm: ForwardRefRenderFunction<any, Props> = (
{ permissonData, onValuesChange },
ref,
) => {
const { APP_TARGET } = process.env;
const [form] = Form.useForm();
useImperativeHandle(ref, () => ({
formRef: form,
}));
useEffect(() => {
const fieldsValue = {
...permissonData,
};
fieldsValue.authorizedDepartmentIds = permissonData.authorizedDepartmentIds || [];
fieldsValue.authorizedUsers = permissonData.authorizedUsers || [];
form.setFieldsValue(fieldsValue);
}, [permissonData]);
return (
<>
<Form
{...formLayout}
key={permissonData.groupId}
form={form}
layout="vertical"
onValuesChange={(value, values) => {
onValuesChange?.(value, values);
}}
className={styles.form}
>
<FormItem hidden={true} name="groupId" label="ID">
<Input placeholder="groupId" />
</FormItem>
<FormItem name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="请输入名称" />
</FormItem>
{APP_TARGET === 'inner' && (
<FormItem name="authorizedDepartmentIds" label="按组织">
<SelectPartenr
type="selectedDepartment"
treeSelectProps={{
placeholder: '请选择需要授权的部门',
}}
/>
</FormItem>
)}
<FormItem name="authorizedUsers" label="按个人">
<SelectTMEPerson placeholder="请选择需要授权的个人" />
</FormItem>
</Form>
</>
);
};
export default forwardRef(PermissionCreateForm);

View File

@@ -0,0 +1,32 @@
import { Space } from 'antd';
import React from 'react';
import type { Dispatch } from 'umi';
import { connect } from 'umi';
import type { StateType } from '../../model';
import { ProCard } from '@ant-design/pro-card';
import PermissionTable from './PermissionTable';
import PermissionAdminForm from './PermissionAdminForm';
type Props = {
dispatch: Dispatch;
domainManger: StateType;
};
const PermissionSection: React.FC<Props> = () => {
return (
<>
<div>
<Space direction="vertical" style={{ width: '100%' }} size={20}>
<ProCard title="邀请成员" bordered>
<PermissionAdminForm />
</ProCard>
<PermissionTable />
</Space>
</div>
</>
);
};
export default connect(({ domainManger }: { domainManger: StateType }) => ({
domainManger,
}))(PermissionSection);

Some files were not shown because too many files have changed in this diff Show More