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,11 @@
import { ROUTE_AUTH_CODES } from '../config/routes';
export default function access({ authCodes }: { authCodes: string[] }) {
return Object.keys(ROUTE_AUTH_CODES).reduce((result, key) => {
const data = { ...result };
const code = ROUTE_AUTH_CODES[key];
const codes = authCodes || [];
data[code] = codes.includes(code);
return data;
}, {});
}

View File

@@ -0,0 +1,160 @@
import type { Settings as LayoutSettings } from '@ant-design/pro-layout';
import { Spin, Space } from 'antd';
import ScaleLoader from 'react-spinners/ScaleLoader';
import { history } from 'umi';
import type { RunTimeLayoutConfig } from 'umi';
import RightContent from '@/components/RightContent';
import S2Icon, { ICON } from '@/components/S2Icon';
import qs from 'qs';
import { queryCurrentUser } from './services/user';
import { queryToken } from './services/login';
import defaultSettings from '../config/defaultSettings';
import settings from '../config/themeSettings';
import { deleteUrlQuery } from './utils/utils';
import { AUTH_TOKEN_KEY, FROM_URL_KEY } from '@/common/constants';
import 'supersonic-chat-sdk/dist/index.css';
import { setToken as setChatSdkToken } from 'supersonic-chat-sdk';
export { request } from './services/request';
import { ROUTE_AUTH_CODES } from '../config/routes';
const TOKEN_KEY = AUTH_TOKEN_KEY;
const replaceRoute = '/';
const getRuningEnv = async () => {
try {
// const response = await fetch(`supersonic.config.json`);
// const config = await response.json();
} catch (error) {
console.warn('无法获取配置文件: 运行时环境将以semantic启动');
}
};
Spin.setDefaultIndicator(
<ScaleLoader color={settings['primary-color']} height={25} width={2} radius={2} margin={2} />,
);
export const initialStateConfig = {
loading: (
<Spin wrapperClassName="initialLoading">
<div className="loadingPlaceholder" />
</Spin>
),
};
const getToken = async () => {
let { search } = window.location;
if (search.length > 0) {
search = search.slice(1);
}
const data = qs.parse(search);
if (data.code) {
try {
const fromUrl = localStorage.getItem(FROM_URL_KEY);
const res = await queryToken(data.code as string);
localStorage.setItem(TOKEN_KEY, res.data.authToken);
const newUrl = deleteUrlQuery(window.location.href, 'code');
window.location.href = fromUrl || newUrl;
} catch (err) {
console.log(err);
}
}
};
const getAuthCodes = () => {
const { RUN_TYPE, APP_TARGET } = process.env;
if (RUN_TYPE === 'local') {
return location.host.includes('9080')
? [ROUTE_AUTH_CODES.CHAT, ROUTE_AUTH_CODES.CHAT_SETTING]
: [ROUTE_AUTH_CODES.SEMANTIC];
}
if (APP_TARGET === 'inner') {
return [ROUTE_AUTH_CODES.CHAT_SETTING, ROUTE_AUTH_CODES.SEMANTIC];
}
return [ROUTE_AUTH_CODES.CHAT, ROUTE_AUTH_CODES.CHAT_SETTING, ROUTE_AUTH_CODES.SEMANTIC];
};
export async function getInitialState(): Promise<{
settings?: LayoutSettings;
currentUser?: API.CurrentUser;
fetchUserInfo?: () => Promise<API.CurrentUser | undefined>;
codeList?: string[];
authCodes?: string[];
}> {
await getRuningEnv();
const fetchUserInfo = async () => {
try {
const { code, data } = await queryCurrentUser();
if (code === 200) {
return { ...data, staffName: data.staffName || data.name };
}
} catch (error) {}
return undefined;
};
const { query } = history.location as any;
const currentToken = query[TOKEN_KEY] || localStorage.getItem(TOKEN_KEY);
if (window.location.host.includes('tmeoa') && !currentToken) {
await getToken();
}
setChatSdkToken(localStorage.getItem(AUTH_TOKEN_KEY) || '');
const currentUser = await fetchUserInfo();
if (currentUser) {
localStorage.setItem('user', currentUser.staffName);
if (currentUser.orgName) {
localStorage.setItem('organization', currentUser.orgName);
}
}
const authCodes = getAuthCodes();
return {
fetchUserInfo,
currentUser,
settings: defaultSettings,
authCodes,
};
}
export const layout: RunTimeLayoutConfig = (params) => {
const { initialState } = params as any;
return {
onMenuHeaderClick: (e) => {
e.preventDefault();
history.push(replaceRoute);
},
logo: (
<Space>
<S2Icon
icon={ICON.iconlogobiaoshi}
size={30}
color="#fff"
style={{ display: 'inline-block', marginTop: 8 }}
/>
<div className="logo">(SuperSonic)</div>
</Space>
),
contentStyle: { ...(initialState?.contentStyle || {}) },
rightContentRender: () => <RightContent />,
disableContentMargin: true,
onPageChange: (location: any) => {
const { pathname } = location;
const { RUN_TYPE, APP_TARGET } = process.env;
if (
(RUN_TYPE === 'local' && !window.location.host.includes('9080') && pathname === '/chat') ||
(APP_TARGET === 'inner' && pathname === '/chat')
) {
history.push('/semanticModel');
}
},
menuHeaderRender: undefined,
childrenRender: (dom) => {
return dom;
},
openKeys: false,
...initialState?.settings,
};
};

View File

@@ -0,0 +1,6 @@
@import '~antd/lib/style/themes/default';
// main
@primary: #225ace;
@body-bg: #f0f2f5;
@border-radius-base: '4px';

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 18" class="design-iconfont" width="128" height="128"><path d="M24.7272727,4.26325641e-14 L33.5127273,17.4545455 L26.5345455,17.4545455 L21.1236364,6.70181818 L24.7272727,4.26325641e-14 Z M17.52,4.26325641e-14 L21.1236364,6.70181818 L15.7127273,17.4545455 L8.73090909,17.4545455 L17.52,4.26325641e-14 Z M41.5890909,12.6945455 L43.9818182,17.4545455 L35.0909091,17.4545455 L32.6981818,12.6945455 L41.5890909,12.6945455 Z M12.68,6.32 L7.08,17.4545455 L0.498181818,17.4545455 L6.09818182,6.32 L12.68,6.32 Z M38.4145455,6.32 L40.9090909,11.2727273 L32.0181818,11.2727273 L29.5272727,6.32 L38.4145455,6.32 Z M15.7890909,0.141818182 L13.3963636,4.89818182 L-3.55271368e-14,4.89818182 L2.39272727,0.141818182 L15.7890909,0.141818182 Z M35.2690909,0.141818182 L37.6654545,4.89818182 L28.7745455,4.89818182 L26.3818182,0.141818182 L35.2690909,0.141818182 Z" fill-rule="evenodd" fill="#ffffff"></path></svg>

After

Width:  |  Height:  |  Size: 953 B

View File

@@ -0,0 +1,28 @@
// 登陆 token key
export const AUTH_TOKEN_KEY = 'SUPERSONIC_TOKEN';
// 记录上次访问页面
export const FROM_URL_KEY = 'FROM_URL';
export const PRIMARY_COLOR = '#f87653';
export const CHART_BLUE_COLOR = '#446dff';
export const CHAT_BLUE = '#1b4aef';
export const CHART_SECONDARY_COLOR = 'rgba(153, 153, 153, 0.3)';
export enum NumericUnit {
None = '无',
TenThousand = '万',
EnTenThousand = 'w',
OneHundredMillion = '亿',
Thousand = 'k',
Million = 'M',
Giga = 'G',
}
export const DEFAULT_CONVERSATION_NAME = '新问答对话';
export const PAGE_TITLE = '问答对话';
export const WEB_TITLE = '问答对话 - 超音数';
export const PLACE_HOLDER = '请输入您的问题';

View File

@@ -0,0 +1,16 @@
import { Space } from 'antd';
export interface IProps {
title: string;
subTitle?: string;
}
const FormItemTitle: React.FC<IProps> = ({ title, subTitle }) => {
return (
<Space direction="vertical" size={2}>
<span>{title}</span>
{subTitle && <span style={{ fontSize: '12px', color: '#6a6a6a' }}>{subTitle}</span>}
</Space>
);
};
export default FormItemTitle;

View File

@@ -0,0 +1,39 @@
.normalState {
position: static;
height: 100%;
.backNormal {
display: none;
}
}
.maxState {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 999;
.innerWrap {
position: absolute;
right: 0;
bottom: 0;
left: 0;
background: #fff;
}
.backNormal {
display: block;
height: 30px;
padding-right: 20px;
color: #02a7f0;
font-size: 22px;
line-height: 30px;
text-align: right;
.fullscreenExitIcon {
cursor: pointer;
}
}
}

View File

@@ -0,0 +1,5 @@
export const formLayout: any = {
// labelCol: { span: 13 },
// wrapperCol: { span: 13 },
layout: 'vertical',
};

View File

@@ -0,0 +1,39 @@
.normalState {
position: static;
height: 100%;
.backNormal {
display: none;
}
}
.maxState {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 999;
.innerWrap {
position: absolute;
right: 0;
bottom: 0;
left: 0;
background: #fff;
}
.backNormal {
display: block;
height: 30px;
padding-right: 20px;
color: #02a7f0;
font-size: 22px;
line-height: 30px;
text-align: right;
.fullscreenExitIcon {
cursor: pointer;
}
}
}

View File

@@ -0,0 +1,69 @@
import type { ReactNode, FC } from 'react';
import { useEffect } from 'react';
import { useImperativeHandle, useState } from 'react';
import { FullscreenExitOutlined } from '@ant-design/icons';
import styles from './index.less';
export interface IProps {
children: ReactNode;
maxRef?: any;
top?: string;
isFullScreen: boolean;
triggerBackToNormal: () => void;
}
const FullScreen: FC<IProps> = ({
children,
maxRef,
top = '50px',
isFullScreen,
triggerBackToNormal,
}) => {
const [wrapCls, setWrapCls] = useState(styles.normalState);
const changeToMax = () => {
setWrapCls(styles.maxState);
};
const changeToNormal = () => {
setWrapCls(styles.normalState);
};
const handleBackToNormal = () => {
if (typeof triggerBackToNormal === 'function') {
triggerBackToNormal();
}
};
useEffect(() => {
if (isFullScreen) {
changeToMax();
} else {
changeToNormal();
}
}, [isFullScreen]);
useImperativeHandle(maxRef, () => ({
changeToMax,
changeToNormal,
}));
return (
<div className={wrapCls} style={wrapCls === styles.maxState ? { paddingTop: top } : {}}>
<div
className={styles.innerWrap}
style={wrapCls === styles.maxState ? { top } : { height: '100%' }}
>
<div className={styles.backNormal}>
<FullscreenExitOutlined
className={styles.fullscreenExitIcon}
title="退出全屏"
onClick={handleBackToNormal}
/>
</div>
{children}
</div>
</div>
);
};
export default FullScreen;

View File

@@ -0,0 +1,16 @@
@import '~antd/es/style/themes/default.less';
.container > * {
background-color: @popover-bg;
border-radius: 4px;
box-shadow: @shadow-1-down;
}
@media screen and (max-width: @screen-xs) {
.container {
width: 100% !important;
}
.container > * {
border-radius: 0 !important;
}
}

View File

@@ -0,0 +1,17 @@
import type { DropDownProps } from 'antd/es/dropdown';
import { Dropdown } from 'antd';
import React from 'react';
import classNames from 'classnames';
import styles from './index.less';
export type HeaderDropdownProps = {
overlayClassName?: string;
overlay: React.ReactNode | (() => React.ReactNode) | any;
placement?: 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topCenter' | 'topRight' | 'bottomCenter';
} & Omit<DropDownProps, 'overlay'>;
const HeaderDropdown: React.FC<HeaderDropdownProps> = ({ overlayClassName: cls, ...restProps }) => (
<Dropdown overlayClassName={classNames(styles.container, cls)} {...restProps} />
);
export default HeaderDropdown;

View File

@@ -0,0 +1,8 @@
import { createFromIconfontCN } from '@ant-design/icons';
import defaultSettings from '../../../config/defaultSettings';
const IconFont = createFromIconfontCN({
scriptUrl: defaultSettings.iconfontUrl,
});
export default IconFont;

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { LogoutOutlined } from '@ant-design/icons';
import { Menu } from 'antd';
import { useModel } from 'umi';
import HeaderDropdown from '../HeaderDropdown';
import styles from './index.less';
import TMEAvatar from '../TMEAvatar';
import cx from 'classnames';
import { AUTH_TOKEN_KEY } from '@/common/constants';
import { history } from 'umi';
export type GlobalHeaderRightProps = {
menu?: boolean;
onClickLogin?: () => void;
};
/**
* 退出登录
* 并返回到首页
*/
const loginOut = async () => {
localStorage.removeItem(AUTH_TOKEN_KEY);
history.push('/login');
window.location.reload();
};
const { APP_TARGET } = process.env;
const AvatarDropdown: React.FC<GlobalHeaderRightProps> = () => {
const { initialState = {}, setInitialState } = useModel('@@initialState');
const onMenuClick = (event: any) => {
const { key } = event;
if (key === 'logout' && initialState) {
loginOut().then(() => {
setInitialState({ ...initialState, currentUser: undefined });
});
return;
}
};
const { currentUser = {} } = initialState as any;
console.log(currentUser, 'currentUser');
const menuHeaderDropdown = (
<Menu className={styles.menu} selectedKeys={[]} onClick={onMenuClick}>
<Menu.Item key="logout">
<LogoutOutlined />
退
</Menu.Item>
</Menu>
);
return (
<HeaderDropdown overlay={menuHeaderDropdown} disabled={APP_TARGET === 'inner'}>
<span className={`${styles.action} ${styles.account}`}>
<TMEAvatar className={styles.avatar} size="small" staffName={currentUser.staffName} />
<span className={cx(styles.name, 'anticon')}>{currentUser.staffName}</span>
</span>
</HeaderDropdown>
);
};
export default AvatarDropdown;

View File

@@ -0,0 +1,105 @@
@import '~antd/es/style/themes/default.less';
@pro-header-hover-bg: rgba(0, 0, 0, 0.025);
.menu {
:global(.anticon) {
margin-right: 8px;
}
:global(.ant-dropdown-menu-item) {
min-width: 160px;
}
}
.right {
display: flex;
float: right;
height: 48px;
margin-left: auto;
overflow: hidden;
.action {
display: flex;
align-items: center;
height: 48px;
padding: 0 12px;
cursor: pointer;
transition: all 0.3s;
>span {
vertical-align: middle;
}
&:hover {
background: @pro-header-hover-bg;
}
&:global(.opened) {
background: @pro-header-hover-bg;
}
}
.search {
padding: 0 12px;
&:hover {
background: transparent;
}
}
.account {
.avatar {
margin-right: 8px;
color: @primary-color;
vertical-align: top;
background: rgba(255, 255, 255, 0.85);
}
}
}
.dark {
.action {
.download {
display: flex;
align-items: center;
}
.menuName {
margin-left: 5px;
color: #fff;
font-size: 13px;
}
&:hover {
background: #296df3;
}
&:global(.opened) {
background: #252a3d;
}
}
}
.actionIcon {
font-size: 20px;
}
.tooltip {
padding-top: 0 !important;
font-size: 12px !important;
:global {
.ant-tooltip-arrow {
display: none;
}
.ant-tooltip-inner {
min-height: 0 !important;
padding: 3px 6px !important;
}
}
}

View File

@@ -0,0 +1,33 @@
import { Space } from 'antd';
import React from 'react';
import { useModel } from 'umi';
import Avatar from './AvatarDropdown';
import styles from './index.less';
import cx from 'classnames';
export type SiderTheme = 'light' | 'dark';
const GlobalHeaderRight: React.FC = () => {
const { initialState } = useModel('@@initialState');
if (!initialState || !initialState.settings) {
return null;
}
const { navTheme, layout } = initialState.settings;
let className = styles.right;
if ((navTheme === 'dark' && layout === 'top') || layout === 'mix') {
className = cx(styles.right, styles.dark);
}
function handleLogin() {}
return (
<Space className={className}>
<Avatar onClickLogin={handleLogin} />
</Space>
);
};
export default GlobalHeaderRight;

View File

@@ -0,0 +1,340 @@
@font-face {
font-family: "iconfont"; /* Project id 2436113 */
src: url('iconfont.woff2?t=1659425018463') format('woff2'),
url('iconfont.woff?t=1659425018463') format('woff'),
url('iconfont.ttf?t=1659425018463') format('truetype'),
url('iconfont.svg?t=1659425018463#iconfont') format('svg');
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.iconbaobiaokanban:before {
content: "\e66b";
}
.iconkanban:before {
content: "\e638";
}
.iconyunyingkanban:before {
content: "\e608";
}
.iconshujukanban1:before {
content: "\eb66";
}
.iconjingqingqidai01:before {
content: "\e607";
}
.icontouzi:before {
content: "\e67a";
}
.iconriqi:before {
content: "\e609";
}
.iconyinleren_:before {
content: "\e606";
}
.icondapan:before {
content: "\e668";
}
.iconbangdan:before {
content: "\e669";
}
.iconshujuwajue:before {
content: "\e667";
}
.iconshoucang1:before {
content: "\e600";
}
.icontianjiazhibiao:before {
content: "\e632";
}
.icontianjiafenzu:before {
content: "\e666";
}
.iconyouxiajiaogouxuan:before {
content: "\e8b7";
}
.iconxiaoshouzhibiaoshezhi:before {
content: "\e665";
}
.iconyingyongbiaoge:before {
content: "\e6ae";
}
.iconzhibiao:before {
content: "\e66a";
}
.iconsearch:before {
content: "\e7c9";
}
.iconfactory-color:before {
content: "\e69d";
}
.iconportray-color:before {
content: "\e69e";
}
.iconvisualize-color:before {
content: "\e69f";
}
.iconamount-color:before {
content: "\e68f";
}
.iconapi-color:before {
content: "\e690";
}
.iconcontent-color:before {
content: "\e691";
}
.iconbox-color:before {
content: "\e692";
}
.iconchat-color:before {
content: "\e693";
}
.iconclient-color:before {
content: "\e694";
}
.icondata-process:before {
content: "\e695";
}
.iconbi-color:before {
content: "\e696";
}
.iconfiled-color:before {
content: "\e697";
}
.iconinvoking-color:before {
content: "\e698";
}
.iconissue-color:before {
content: "\e699";
}
.iconplatform-color:before {
content: "\e69a";
}
.iconfile-color:before {
content: "\e69b";
}
.iconname-color:before {
content: "\e69c";
}
.icondraft:before {
content: "\e605";
}
.iconunknown:before {
content: "\e604";
}
.iconnormal:before {
content: "\e603";
}
.iconfreezed:before {
content: "\e602";
}
.iconlogowenzi:before {
content: "\e660";
}
.iconlogobiaoshi:before {
content: "\e664";
}
.iconchaoyinshuxitonglogo:before {
content: "\e663";
}
.iconzanwuquanxiandianjishenqing_1:before {
content: "\e662";
}
.iconqingchuangjianmuluhuokanban:before {
content: "\e661";
}
.iconzichan:before {
content: "\e65f";
}
.iconhangweifenxi:before {
content: "\e65e";
}
.iconshujuzichan:before {
content: "\e65d";
}
.iconshujukanban:before {
content: "\e659";
}
.iconshujujieru:before {
content: "\e65a";
}
.iconshujutansuo:before {
content: "\e65b";
}
.iconminjiefenxi:before {
content: "\e65c";
}
.iconyanfagongju:before {
content: "\e658";
}
.iconshujuanquan:before {
content: "\e614";
}
.iconCE:before {
content: "\e601";
}
.iconkanbantu-shuaxin:before {
content: "\e657";
}
.icondaohang-sousuo:before {
content: "\e63e";
}
.icondaohang-bangzhu:before {
content: "\e63f";
}
.iconkanbantu-fenxiang:before {
content: "\e640";
}
.iconquanju-riqi:before {
content: "\e641";
}
.icondaohang-shezhi:before {
content: "\e642";
}
.icondaohang-zichangouwuche:before {
content: "\e643";
}
.iconquanju-xiazai:before {
content: "\e644";
}
.iconkanbantu-quanping:before {
content: "\e645";
}
.iconshujuzichan-yewushujuzichan:before {
content: "\e646";
}
.iconshujukanban-tianjiakanban:before {
content: "\e647";
}
.iconqingkong:before {
content: "\e648";
}
.iconshujuzichan-jishushujuzichan:before {
content: "\e649";
}
.iconshujuzichan-zichanfaxian:before {
content: "\e64a";
}
.icontishi-beizhu1:before {
content: "\e64b";
}
.iconshujukanban-tianjiamulu:before {
content: "\e64c";
}
.icontubiao-zhuzhuangtu:before {
content: "\e64d";
}
.icondaohang-xiaoxitishi:before {
content: "\e64e";
}
.icontubiao-bingtu:before {
content: "\e64f";
}
.icontishi-beizhu2:before {
content: "\e650";
}
.iconshezhi-quanxianshezhi:before {
content: "\e651";
}
.iconhangweifenxi-mokuaifenxi:before {
content: "\e652";
}
.icontubiao-loudoutu:before {
content: "\e653";
}
.icontubiao-zhexiantu:before {
content: "\e654";
}
.icontubiao-biaoge:before {
content: "\e655";
}
.iconhangweifenxi-baobiaoliebiao:before {
content: "\e656";
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,576 @@
{
"id": "2436113",
"name": "SuperSonic 超音数系统官方图标库",
"font_family": "iconfont",
"css_prefix_text": "icon",
"description": "TME数据中台产品",
"glyphs": [
{
"icon_id": "22419559",
"name": "报表看板",
"font_class": "baobiaokanban",
"unicode": "e66b",
"unicode_decimal": 58987
},
{
"icon_id": "17815082",
"name": "看板",
"font_class": "kanban",
"unicode": "e638",
"unicode_decimal": 58936
},
{
"icon_id": "29598913",
"name": "运营看板",
"font_class": "yunyingkanban",
"unicode": "e608",
"unicode_decimal": 58888
},
{
"icon_id": "3868281",
"name": "数据看板",
"font_class": "shujukanban1",
"unicode": "eb66",
"unicode_decimal": 60262
},
{
"icon_id": "580614",
"name": "敬请期待",
"font_class": "jingqingqidai01",
"unicode": "e607",
"unicode_decimal": 58887
},
{
"icon_id": "845846",
"name": "投资",
"font_class": "touzi",
"unicode": "e67a",
"unicode_decimal": 59002
},
{
"icon_id": "7294994",
"name": "日期",
"font_class": "riqi",
"unicode": "e609",
"unicode_decimal": 58889
},
{
"icon_id": "2029508",
"name": "音乐人_16",
"font_class": "yinleren_",
"unicode": "e606",
"unicode_decimal": 58886
},
{
"icon_id": "9504044",
"name": "大盘",
"font_class": "dapan",
"unicode": "e668",
"unicode_decimal": 58984
},
{
"icon_id": "26652200",
"name": "榜单",
"font_class": "bangdan",
"unicode": "e669",
"unicode_decimal": 58985
},
{
"icon_id": "4313898",
"name": "数据挖掘",
"font_class": "shujuwajue",
"unicode": "e667",
"unicode_decimal": 58983
},
{
"icon_id": "12694976",
"name": "KHCFDC_收藏",
"font_class": "shoucang1",
"unicode": "e600",
"unicode_decimal": 58880
},
{
"icon_id": "10690834",
"name": "添加指标",
"font_class": "tianjiazhibiao",
"unicode": "e632",
"unicode_decimal": 58930
},
{
"icon_id": "21030367",
"name": "添加维度",
"font_class": "tianjiafenzu",
"unicode": "e666",
"unicode_decimal": 58982
},
{
"icon_id": "1727419",
"name": "203右下角勾选",
"font_class": "youxiajiaogouxuan",
"unicode": "e8b7",
"unicode_decimal": 59575
},
{
"icon_id": "2462841",
"name": "销售指标设置",
"font_class": "xiaoshouzhibiaoshezhi",
"unicode": "e665",
"unicode_decimal": 58981
},
{
"icon_id": "8097138",
"name": "应用表格",
"font_class": "yingyongbiaoge",
"unicode": "e6ae",
"unicode_decimal": 59054
},
{
"icon_id": "12331689",
"name": "指标",
"font_class": "zhibiao",
"unicode": "e66a",
"unicode_decimal": 58986
},
{
"icon_id": "6242439",
"name": "search",
"font_class": "search",
"unicode": "e7c9",
"unicode_decimal": 59337
},
{
"icon_id": "25630410",
"name": "factory-color",
"font_class": "factory-color",
"unicode": "e69d",
"unicode_decimal": 59037
},
{
"icon_id": "25630419",
"name": "portray-color",
"font_class": "portray-color",
"unicode": "e69e",
"unicode_decimal": 59038
},
{
"icon_id": "25630420",
"name": "visualize-color",
"font_class": "visualize-color",
"unicode": "e69f",
"unicode_decimal": 59039
},
{
"icon_id": "25630396",
"name": "amount-color",
"font_class": "amount-color",
"unicode": "e68f",
"unicode_decimal": 59023
},
{
"icon_id": "25630397",
"name": "api-color",
"font_class": "api-color",
"unicode": "e690",
"unicode_decimal": 59024
},
{
"icon_id": "25630398",
"name": "content-color",
"font_class": "content-color",
"unicode": "e691",
"unicode_decimal": 59025
},
{
"icon_id": "25630399",
"name": "box-color",
"font_class": "box-color",
"unicode": "e692",
"unicode_decimal": 59026
},
{
"icon_id": "25630400",
"name": "chat-color",
"font_class": "chat-color",
"unicode": "e693",
"unicode_decimal": 59027
},
{
"icon_id": "25630401",
"name": "client-color",
"font_class": "client-color",
"unicode": "e694",
"unicode_decimal": 59028
},
{
"icon_id": "25630402",
"name": "data-process",
"font_class": "data-process",
"unicode": "e695",
"unicode_decimal": 59029
},
{
"icon_id": "25630403",
"name": "bi-color",
"font_class": "bi-color",
"unicode": "e696",
"unicode_decimal": 59030
},
{
"icon_id": "25630404",
"name": "filed-color",
"font_class": "filed-color",
"unicode": "e697",
"unicode_decimal": 59031
},
{
"icon_id": "25630405",
"name": "invoking-color",
"font_class": "invoking-color",
"unicode": "e698",
"unicode_decimal": 59032
},
{
"icon_id": "25630406",
"name": "issue-color",
"font_class": "issue-color",
"unicode": "e699",
"unicode_decimal": 59033
},
{
"icon_id": "25630407",
"name": "platform-color",
"font_class": "platform-color",
"unicode": "e69a",
"unicode_decimal": 59034
},
{
"icon_id": "25630408",
"name": "file-color",
"font_class": "file-color",
"unicode": "e69b",
"unicode_decimal": 59035
},
{
"icon_id": "25630409",
"name": "name-color",
"font_class": "name-color",
"unicode": "e69c",
"unicode_decimal": 59036
},
{
"icon_id": "21480366",
"name": "icon-task-status-draft",
"font_class": "draft",
"unicode": "e605",
"unicode_decimal": 58885
},
{
"icon_id": "21480363",
"name": "icon-task-status-system-freeze",
"font_class": "unknown",
"unicode": "e604",
"unicode_decimal": 58884
},
{
"icon_id": "21480360",
"name": "icon-task-status-normal",
"font_class": "normal",
"unicode": "e603",
"unicode_decimal": 58883
},
{
"icon_id": "21480337",
"name": "icon-task-status-freezed",
"font_class": "freezed",
"unicode": "e602",
"unicode_decimal": 58882
},
{
"icon_id": "20901515",
"name": "logo文字",
"font_class": "logowenzi",
"unicode": "e660",
"unicode_decimal": 58976
},
{
"icon_id": "20901503",
"name": "logo标识",
"font_class": "logobiaoshi",
"unicode": "e664",
"unicode_decimal": 58980
},
{
"icon_id": "20897340",
"name": "超音数系统logo",
"font_class": "chaoyinshuxitonglogo",
"unicode": "e663",
"unicode_decimal": 58979
},
{
"icon_id": "20852244",
"name": "暂无权限点击申请",
"font_class": "zanwuquanxiandianjishenqing_1",
"unicode": "e662",
"unicode_decimal": 58978
},
{
"icon_id": "20851776",
"name": "请创建目录或看板",
"font_class": "qingchuangjianmuluhuokanban",
"unicode": "e661",
"unicode_decimal": 58977
},
{
"icon_id": "20830143",
"name": "资产",
"font_class": "zichan",
"unicode": "e65f",
"unicode_decimal": 58975
},
{
"icon_id": "20829646",
"name": "行为分析",
"font_class": "hangweifenxi",
"unicode": "e65e",
"unicode_decimal": 58974
},
{
"icon_id": "20829640",
"name": "数据资产",
"font_class": "shujuzichan",
"unicode": "e65d",
"unicode_decimal": 58973
},
{
"icon_id": "20829629",
"name": "数据看板",
"font_class": "shujukanban",
"unicode": "e659",
"unicode_decimal": 58969
},
{
"icon_id": "20829630",
"name": "数据接入",
"font_class": "shujujieru",
"unicode": "e65a",
"unicode_decimal": 58970
},
{
"icon_id": "20829631",
"name": "数据探索",
"font_class": "shujutansuo",
"unicode": "e65b",
"unicode_decimal": 58971
},
{
"icon_id": "20829633",
"name": "敏捷分析",
"font_class": "minjiefenxi",
"unicode": "e65c",
"unicode_decimal": 58972
},
{
"icon_id": "19149997",
"name": "研发工具",
"font_class": "yanfagongju",
"unicode": "e658",
"unicode_decimal": 58968
},
{
"icon_id": "3977827",
"name": "数据安全",
"font_class": "shujuanquan",
"unicode": "e614",
"unicode_decimal": 58900
},
{
"icon_id": "20782797",
"name": "CE",
"font_class": "CE",
"unicode": "e601",
"unicode_decimal": 58881
},
{
"icon_id": "20624066",
"name": "看板图-刷新",
"font_class": "kanbantu-shuaxin",
"unicode": "e657",
"unicode_decimal": 58967
},
{
"icon_id": "20623681",
"name": "导航-搜索",
"font_class": "daohang-sousuo",
"unicode": "e63e",
"unicode_decimal": 58942
},
{
"icon_id": "20623682",
"name": "导航-帮助",
"font_class": "daohang-bangzhu",
"unicode": "e63f",
"unicode_decimal": 58943
},
{
"icon_id": "20623683",
"name": "看板图-分享",
"font_class": "kanbantu-fenxiang",
"unicode": "e640",
"unicode_decimal": 58944
},
{
"icon_id": "20623684",
"name": "全局-日期",
"font_class": "quanju-riqi",
"unicode": "e641",
"unicode_decimal": 58945
},
{
"icon_id": "20623685",
"name": "导航-设置",
"font_class": "daohang-shezhi",
"unicode": "e642",
"unicode_decimal": 58946
},
{
"icon_id": "20623686",
"name": "导航-资产购物车",
"font_class": "daohang-zichangouwuche",
"unicode": "e643",
"unicode_decimal": 58947
},
{
"icon_id": "20623687",
"name": "全局-下载",
"font_class": "quanju-xiazai",
"unicode": "e644",
"unicode_decimal": 58948
},
{
"icon_id": "20623688",
"name": "看板图-全屏",
"font_class": "kanbantu-quanping",
"unicode": "e645",
"unicode_decimal": 58949
},
{
"icon_id": "20623689",
"name": "数据资产-业务数据资产",
"font_class": "shujuzichan-yewushujuzichan",
"unicode": "e646",
"unicode_decimal": 58950
},
{
"icon_id": "20623690",
"name": "数据看板-添加看板",
"font_class": "shujukanban-tianjiakanban",
"unicode": "e647",
"unicode_decimal": 58951
},
{
"icon_id": "20623691",
"name": "清空",
"font_class": "qingkong",
"unicode": "e648",
"unicode_decimal": 58952
},
{
"icon_id": "20623692",
"name": "数据资产-技术数据资产",
"font_class": "shujuzichan-jishushujuzichan",
"unicode": "e649",
"unicode_decimal": 58953
},
{
"icon_id": "20623693",
"name": "数据资产-资产发现",
"font_class": "shujuzichan-zichanfaxian",
"unicode": "e64a",
"unicode_decimal": 58954
},
{
"icon_id": "20623695",
"name": "提示-备注1",
"font_class": "tishi-beizhu1",
"unicode": "e64b",
"unicode_decimal": 58955
},
{
"icon_id": "20623696",
"name": "数据看板-添加目录",
"font_class": "shujukanban-tianjiamulu",
"unicode": "e64c",
"unicode_decimal": 58956
},
{
"icon_id": "20623697",
"name": "图表-柱状图",
"font_class": "tubiao-zhuzhuangtu",
"unicode": "e64d",
"unicode_decimal": 58957
},
{
"icon_id": "20623698",
"name": "导航-消息提示",
"font_class": "daohang-xiaoxitishi",
"unicode": "e64e",
"unicode_decimal": 58958
},
{
"icon_id": "20623699",
"name": "图表-饼图",
"font_class": "tubiao-bingtu",
"unicode": "e64f",
"unicode_decimal": 58959
},
{
"icon_id": "20623700",
"name": "提示-备注2",
"font_class": "tishi-beizhu2",
"unicode": "e650",
"unicode_decimal": 58960
},
{
"icon_id": "20623701",
"name": "设置-权限设置",
"font_class": "shezhi-quanxianshezhi",
"unicode": "e651",
"unicode_decimal": 58961
},
{
"icon_id": "20623702",
"name": "行为分析-模块分析",
"font_class": "hangweifenxi-mokuaifenxi",
"unicode": "e652",
"unicode_decimal": 58962
},
{
"icon_id": "20623703",
"name": "图表-漏斗图",
"font_class": "tubiao-loudoutu",
"unicode": "e653",
"unicode_decimal": 58963
},
{
"icon_id": "20623704",
"name": "图表-折线图",
"font_class": "tubiao-zhexiantu",
"unicode": "e654",
"unicode_decimal": 58964
},
{
"icon_id": "20623705",
"name": "图表-表格",
"font_class": "tubiao-biaoge",
"unicode": "e655",
"unicode_decimal": 58965
},
{
"icon_id": "20623706",
"name": "行为分析-报表列表",
"font_class": "hangweifenxi-baobiaoliebiao",
"unicode": "e656",
"unicode_decimal": 58966
}
]
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -0,0 +1,3 @@
.s2icon {
line-height: 1;
}

View File

@@ -0,0 +1,27 @@
import type { CSSProperties, FC } from 'react';
import cx from 'classnames';
import iconfont from './iconfont.css';
import styles from './index.less';
export interface S2IconProps {
icon: string;
color?: string;
size?: string | number;
style?: CSSProperties;
className?: string;
}
const S2Icon: FC<S2IconProps> = ({ color, size, icon, style, className }) => {
return (
<span
className={cx(styles.s2icon, iconfont.iconfont, icon, className)}
style={{ color, fontSize: size, ...style }}
/>
);
};
export const ICON = iconfont;
export const AssetIcon = <S2Icon icon={ICON.iconzichan} />;
export default S2Icon;

View File

@@ -0,0 +1,156 @@
import { Avatar, TreeSelect, Tag } from 'antd';
import React, { useEffect, useState } from 'react';
import { getDepartmentTree, getUserByDeptid } from './service';
import TMEAvatar from '@/components/TMEAvatar';
type Props = {
type: 'selectedPerson' | 'selectedDepartment';
value?: any;
onChange?: (value: boolean) => void;
treeSelectProps?: Record<string, any>;
};
const isDisableCheckbox = (name: string, type: string) => {
const isPersonNode = name.includes('(');
if (type === 'selectedPerson') {
return !isPersonNode;
}
if (type === 'selectedDepartment') {
if (isPersonNode) {
return true;
}
return false;
}
return true;
};
// 转化树结构
export function changeTreeData(treeData: any = [], type: string) {
return treeData.map((item: any) => {
return {
title: item.name,
value: item.key,
key: item.key,
isLeaf: !!item.emplid,
children: item?.subDepartments ? changeTreeData(item.subDepartments, type) : [],
disableCheckbox: isDisableCheckbox(item.name, type),
checkable: !isDisableCheckbox(item.name, type),
icon: item.name.includes('(') && (
<Avatar size={18} shape="square" src={`${item.avatarImg}`} alt="avatar" />
),
};
});
}
const SelectPartner: React.FC<Props> = ({
type = 'selectedPerson',
value,
onChange,
treeSelectProps = {},
}) => {
const [treeData, setTreeData] = useState([]);
const getDetpartment = async () => {
const res = await getDepartmentTree();
const data = changeTreeData(res.data, type);
setTreeData(data);
};
useEffect(() => {
getDetpartment();
}, []);
const updateTreeData = (list: any, key: any, children: any) => {
return list.map((node: any) => {
if (node.key === key) {
let childrenData = node.children;
if (node.children && !node.children.find((item: any) => item?.key === children[0]?.key)) {
childrenData = [...children, ...node.children];
}
return { ...node, children: childrenData };
}
if (node.children.length !== 0) {
return { ...node, children: updateTreeData(node.children, key, children) };
}
return node;
});
};
const onLoadData = (target: any) => {
const { key } = target;
const loadData = async () => {
const childData = await getUserByDeptid(key);
if (childData.data.length === 0) {
return;
}
setTimeout(() => {
setTreeData((origin) => updateTreeData(origin, key, changeTreeData(childData.data, type)));
}, 300);
};
return new Promise<void>((resolve) => {
loadData().then(() => {
resolve();
});
});
};
const handleChange = (newValue: any) => {
onChange?.(newValue);
};
const tagRender = (props: any) => {
const { label } = props;
const onPreventMouseDown = (event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
event.stopPropagation();
};
const enEname = label.split('(')[0];
return (
<Tag
onMouseDown={onPreventMouseDown}
closable={true}
onClose={() => {
const { value: propsValue } = props;
const newValue = value.filter((code: string) => {
return code !== propsValue;
});
onChange?.(newValue);
}}
style={{ marginRight: 3, marginBottom: 3 }}
>
{type === 'selectedPerson' && <TMEAvatar size="small" staffName={enEname} />}
<span
style={{
position: 'relative',
top: '2px',
left: '5px',
}}
>
{label}
</span>
</Tag>
);
};
return (
<>
<TreeSelect
showSearch
style={{ width: '100%' }}
value={value}
loadData={onLoadData}
dropdownStyle={{ maxHeight: 800, overflow: 'auto' }}
allowClear
multiple
onChange={handleChange}
treeCheckable={true}
treeIcon={true}
treeData={treeData}
tagRender={tagRender}
treeNodeFilterProp={'title'}
listHeight={500}
showCheckedStrategy={TreeSelect.SHOW_PARENT}
{...treeSelectProps}
/>
</>
);
};
export default SelectPartner;

View File

@@ -0,0 +1,13 @@
import { request } from 'umi';
export async function getDepartmentTree() {
return request<any>('/api/tpp/getDetpartmentTree', {
method: 'GET',
});
}
export async function getUserByDeptid(id: any) {
return request<any>(`/api/tpp/getUserByDeptid/${id}`, {
method: 'GET',
});
}

View File

@@ -0,0 +1,9 @@
.userAvatar {
width: 24px;
height: 24px;
border-radius: 50%;
}
.userText {
margin-left: 10px;
}

View File

@@ -0,0 +1,70 @@
import { useState } from 'react';
import type { FC } from 'react';
import { Select, message } from 'antd';
import type { UserItem } from './service';
import { getAllUser } from './service';
import styles from './index.less';
import { useFetchDataEffect } from '@/utils/curd';
import TMEAvatar from '../TMEAvatar';
interface Props {
value?: string[];
placeholder?: string;
isMultiple?: boolean;
onChange?: (owners: string | string[]) => void;
}
const SelectTMEPerson: FC<Props> = ({ placeholder, value, isMultiple = true, onChange }) => {
const [userList, setUserList] = useState<UserItem[]>([]);
useFetchDataEffect(
{
fetcher: async () => {
const res = await getAllUser();
if (res.code !== 200) {
message.error(res.msg);
throw new Error(res.msg);
}
return res.data || [];
},
updater: (list) => {
const users = list.map((item: UserItem) => {
const { enName, chName, name } = item;
return {
...item,
enName: enName || name,
chName: chName || name,
};
});
setUserList(users);
},
cleanup: () => {
setUserList([]);
},
},
[],
);
return (
<Select
value={value}
placeholder={placeholder ?? '请选择用户名'}
mode={isMultiple ? 'multiple' : undefined}
allowClear
showSearch
onChange={onChange}
>
{userList.map((item) => {
return (
<Select.Option key={item.enName} value={item.enName}>
<TMEAvatar size="small" staffName={item.enName} />
<span className={styles.userText}>{item.displayName}</span>
</Select.Option>
);
})}
</Select>
);
};
export default SelectTMEPerson;

View File

@@ -0,0 +1,19 @@
import request from 'umi-request';
export type UserItem = {
enName?: string;
displayName: string;
chName?: string;
name?: string;
email: string;
};
export type GetAllUserRes = Result<UserItem[]>;
// 获取所有用户
export async function getAllUser(): Promise<GetAllUserRes> {
const { APP_TARGET } = process.env;
if (APP_TARGET === 'inner') {
return request.get('/api/oa/user/all');
}
return request.get(`${process.env.AUTH_API_BASE_URL}user/getUserList`);
}

View File

@@ -0,0 +1,18 @@
@borderColor: #eee;
.sqlEditor {
min-width: 0;
height: 100%;
border: solid 1px @borderColor;
:global {
.ace_editor {
font-family: 'Menlo', 'Monaco', 'Ubuntu Mono', 'Consolas', 'source-code-pro' !important;
}
}
}
.fullScreenBtnBox {
display: flex;
justify-content: end;
}

View File

@@ -0,0 +1,282 @@
/* eslint-disable */
import React, { useRef, useEffect, useCallback, useState, useMemo } from 'react';
import AceEditor, { IAceOptions } from 'react-ace';
import languageTools from 'ace-builds/src-min-noconflict/ext-language_tools';
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';
import ReactAce, { IAceEditorProps } from 'react-ace/lib/ace';
import { Typography } from 'antd';
import { debounce } from 'lodash';
import FullScreen from '../FullScreen';
import styles from './index.less';
type TMode = 'sql' | 'mysql' | 'sqlserver';
enum EHintMeta {
table = 'table',
variable = 'variable',
column = 'column',
}
const DEFAULT_FONT_SIZE = '14px';
// const THEME_DEFAULT = 'sqlserver';
const MODE_DEFAULT = 'sql';
// const HEIGHT_DEFAULT = '300px';
const HEIGHT_DEFAULT = '100%';
const EDITOR_OPTIONS: IAceOptions = {
behavioursEnabled: true,
enableSnippets: false,
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
autoScrollEditorIntoView: true,
wrap: true,
useWorker: false,
};
export interface ISqlEditorProps {
hints?: { [name: string]: string[] };
value?: string;
height?: string;
/**
* 需引入对应的包 'ace-builds/src-min-noconflict/mode-${mode}'
*/
mode?: TMode;
/**
* 需引入对应的包 'ace-builds/src-min-noconflict/theme-${theme}'
*/
// theme?: TTheme;
isRightTheme?: boolean;
editorConfig?: IAceEditorProps;
sizeChanged?: number;
fullScreenBtnVisible?: boolean;
onSqlChange?: (sql: string) => void;
onChange?: (sql: string) => void;
onSelect?: (sql: string) => void;
onCmdEnter?: () => void;
}
/**
* Editor Component
* @param props ISqlEditorProps
*/
function SqlEditor(props: ISqlEditorProps) {
const refEditor = useRef<ReactAce>();
const {
hints = {},
value,
height = HEIGHT_DEFAULT,
mode = MODE_DEFAULT,
isRightTheme = false,
sizeChanged,
editorConfig,
fullScreenBtnVisible = true,
onSqlChange,
onChange,
onSelect,
onCmdEnter,
} = props;
const resize = useCallback(
debounce(() => {
refEditor.current?.editor.resize();
}, 300),
[],
);
const change = useCallback((sql: string) => {
onSqlChange?.(sql);
onChange?.(sql);
}, []);
const selectionChange = useCallback(
debounce((selection: any) => {
const rawSelectedQueryText: any = refEditor.current?.editor.session.doc.getTextRange(
selection.getRange(),
);
const selectedQueryText = rawSelectedQueryText?.length > 1 ? rawSelectedQueryText : null;
onSelect?.(selectedQueryText);
}, 300),
[],
);
const commands = useMemo(
() => [
{
name: 'execute',
bindKey: { win: 'Ctrl-Enter', mac: 'Command-Enter' },
exec: onCmdEnter,
},
],
[],
);
useEffect(() => {
resize();
}, [sizeChanged, height]);
useEffect(() => {
setHintsPopover(hints);
}, [hints]);
const [isSqlIdeFullScreen, setIsSqlIdeFullScreen] = useState<boolean>(false);
const handleNormalScreenSqlIde = () => {
setIsSqlIdeFullScreen(false);
// setSqlEditorHeight(getDefaultSqlEditorHeight(screenSize));
};
return (
<div className={styles.sqlEditor} style={{ height }}>
<FullScreen
isFullScreen={isSqlIdeFullScreen}
top={`${0}px`}
triggerBackToNormal={handleNormalScreenSqlIde}
>
<AceEditor
ref={refEditor}
name="aceEditor"
width="100%"
height="100%"
fontSize={DEFAULT_FONT_SIZE}
mode={mode}
theme={isRightTheme ? 'sqlserver' : 'monokai'}
value={value}
showPrintMargin={false}
highlightActiveLine={true}
setOptions={EDITOR_OPTIONS}
commands={commands as any}
onChange={change}
onSelectionChange={selectionChange}
// autoScrollEditorIntoView={true}
{...editorConfig}
/>
</FullScreen>
{fullScreenBtnVisible && (
<span
className={styles.fullScreenBtnBox}
onClick={() => {
setIsSqlIdeFullScreen(true);
}}
>
<Typography.Link></Typography.Link>
</span>
)}
</div>
);
}
interface ICompleters {
value: string;
name?: string;
caption?: string;
meta?: string;
type?: string;
score?: number;
}
function setHintsPopover(hints: ISqlEditorProps['hints']) {
const {
textCompleter,
keyWordCompleter,
// snippetCompleter,
setCompleters,
} = languageTools;
const customHintsCompleter = {
identifierRegexps: [/[a-zA-Z_0-9.\-\u00A2-\uFFFF]/],
getCompletions: (editor, session, pos, prefix, callback) => {
const { tableKeywords, tableColumnKeywords, variableKeywords, columns } =
formatCompleterFromHints(hints);
if (prefix[prefix.length - 1] === '.') {
const tableName = prefix.substring(0, prefix.length - 1);
const AliasTableColumnKeywords = genAliasTableColumnKeywords(editor, tableName, hints);
const hintList = tableKeywords.concat(
variableKeywords,
AliasTableColumnKeywords,
tableColumnKeywords[tableName] || [],
);
return callback(null, hintList);
}
callback(null, tableKeywords.concat(variableKeywords, columns));
},
};
const completers = [
textCompleter,
keyWordCompleter,
// snippetCompleter,
customHintsCompleter,
];
setCompleters(completers);
}
function formatCompleterFromHints(hints: ISqlEditorProps['hints']) {
const variableKeywords: ICompleters[] = [];
const tableKeywords: ICompleters[] = [];
const tableColumnKeywords: { [tableName: string]: ICompleters[] } = {};
const columns: ICompleters[] = [];
let score = 1000;
Object.keys(hints).forEach((key) => {
const meta: EHintMeta = isVariable(key) as any;
if (!meta) {
const { columnWithTableName, column } = genTableColumnKeywords(hints[key], key);
tableColumnKeywords[key] = columnWithTableName;
columns.push(...column);
tableKeywords.push({
name: key,
value: key,
score: score--,
meta: isTable(),
});
} else {
variableKeywords.push({ score: score--, value: key, meta });
}
});
return { tableKeywords, tableColumnKeywords, variableKeywords, columns };
}
function genTableColumnKeywords(table: string[], tableName: string) {
let score = 100;
const columnWithTableName: ICompleters[] = [];
const column: ICompleters[] = [];
table.forEach((columnVal) => {
const basis = { score: score--, meta: isColumn() };
columnWithTableName.push({
caption: `${tableName}.${columnVal}`,
name: `${tableName}.${columnVal}`,
value: `${tableName}.${columnVal}`,
...basis,
});
column.push({ value: columnVal, name: columnVal, ...basis });
});
return { columnWithTableName, column };
}
function genAliasTableColumnKeywords(
editor,
aliasTableName: string,
hints: ISqlEditorProps['hints'],
) {
const content = editor.getSession().getValue();
const tableName = Object.keys(hints).find((tableName) => {
const reg = new RegExp(`.+${tableName}\\s*(as|AS)?(?=\\s+${aliasTableName}\\s*)`, 'im');
return reg.test(content);
});
if (!tableName) {
return [];
}
const { columnWithTableName } = genTableColumnKeywords(hints[tableName], aliasTableName);
return columnWithTableName;
}
function isVariable(key: string) {
return key.startsWith('$') && key.endsWith('$') && EHintMeta.variable;
}
function isTable(key?: string) {
return EHintMeta.table;
}
function isColumn(key?: string) {
return EHintMeta.column;
}
export default SqlEditor;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,15 @@
import type { FC } from 'react';
import { Avatar } from 'antd';
import type { AvatarProps } from 'antd';
import avatarIcon from './assets/avatar.gif';
interface Props extends AvatarProps {
staffName?: string;
avatarImg?: string;
}
const TMEAvatar: FC<Props> = ({ avatarImg, ...restProps }) => (
<Avatar src={`${avatarImg}`} alt="avatar" icon={<img src={avatarIcon} />} {...restProps} />
);
export default TMEAvatar;

View File

@@ -0,0 +1,42 @@
export * from './models/base';
type ObjToArrayParams = Record<string, string>;
const keyTypeTran = {
string: String,
number: Number,
};
/**
* obj转成valuelabel的数组
* @param _obj
*/
export const objToArray = (_obj: ObjToArrayParams, keyType: string = 'string') => {
return Object.keys(_obj).map((key) => {
return {
value: keyTypeTran[keyType](key),
label: _obj[key],
};
});
};
type EnumToArrayItem = {
value: number;
label: string;
showSelect?: boolean;
};
export type EnumToArrayParams = Record<string, EnumToArrayItem>;
export const enumToArray = (_obj: EnumToArrayParams) => {
return Object.keys(_obj).map((key) => {
return _obj[key];
});
};
// 枚举类转出的key value列表转key value对象
export const enumArrayTrans = (_array: EnumToArrayItem[]) => {
const returnObj = {};
_array.map((item) => {
returnObj[item.value] = item.label;
return item;
});
return returnObj;
};

View File

@@ -0,0 +1,48 @@
export const EnumTransDbType = {
mysql: 'mysql',
tdw: 'tdw',
clickhouse: 'clickhouse',
kafka: 'kafka',
binlog: 'binlog',
hbase: 'hbase',
kugou_datahub: 'kugou_datahub',
aiting_datahub: 'aiting_datahub',
http: 'http',
};
export const EnumTransModelType = {
edit: '编辑',
add: '新增',
};
export const EnumDescSensitivity = {
low: {
value: 1,
label: '低',
},
middle: {
value: 2,
label: '中',
},
height: {
value: 3,
label: '高',
},
};
export const EnumDbTypeOwnKeys = {
mysql: ['ip', 'port', 'dbName', 'username', 'password'],
clickhouse: ['ip', 'port', 'dbName', 'username', 'password'],
tdw: ['dbName', 'username', 'password'],
kafka: ['bootstrap', 'dbName', 'username', 'password'],
binlog: ['ip', 'port', 'dbName', 'username', 'password'],
hbase: ['config'],
kugou_datahub: ['config'],
aiting_datahub: ['config'],
http: ['url'],
};
export enum EnumDashboardType {
DIR = 0, // 目录
DASHBOARD = 1, // 看板
}

View File

@@ -0,0 +1,217 @@
@import '~antd/es/style/themes/default.less';
:root:root {
--primary-color: #f87653;
--blue: #296df3;
--deep-blue: #446dff;
--chat-blue: #1b4aef;
--body-background: #f7fafa;
--deep-background: #f0f0f0;
--light-background: #f5f5f5;
--component-background: #fff;
--header-color: #edf2f2;
--text-color: #181a1a;
--text-color-secondary: #3d4242;
--text-color-third: #626a6a;
--text-color-fourth: #889191;
--text-color-fifth: #afb6b6;
--text-color-six: #a3a4a6;
--text-color-fifth-4: hsla(180, 5%, 70%, 0.4);
--tooltip-max-width: 350px;
--success-color: #52c41a;
--processing-color: #ff2442;
--error-color: #ff4d4f;
--highlight-color: #ff4d4f;
}
html,
body,
#root {
height: 100%;
}
.colorWeak {
filter: invert(80%);
}
.ant-layout {
min-height: 100vh;
}
canvas {
display: block;
}
body {
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
ul,
ol {
list-style: none;
}
@media (max-width: @screen-xs) {
.ant-table {
width: 100%;
overflow-x: auto;
&-thead>tr,
&-tbody>tr {
>th,
>td {
white-space: pre;
>span {
display: block;
}
}
}
}
}
// 兼容IE11
@media screen and(-ms-high-contrast: active),
(-ms-high-contrast: none) {
body .ant-design-pro>.ant-layout {
min-height: 100vh;
}
}
.ant-card-body {
padding: 24px !important;
}
.ant-pro-page-container-children-content {
margin: 12px 12px 0 !important;
}
.ant-page-header {
padding-bottom: 10px !important;
}
.ant-spin-spinning {
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.ant-table-selection-extra {
.ant-dropdown-trigger {
display: none !important;
}
}
.initialLoading {
.ant-spin-spinning {
max-height: none !important;
}
.loadingPlaceholder {
height: 100vh;
}
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
}
.ant-menu-dark.ant-menu-horizontal>.ant-menu-item,
.ant-menu-dark.ant-menu-horizontal>.ant-menu-submenu {
font-weight: bold;
font-size: 14px;
&>span>a,
&>a {
color: white;
}
}
.ant-pro-top-nav-header-logo h1 {
font-size: 18px;
}
.ant-layout-header {
background: linear-gradient(to right, #153d8f, #0a276d);
background-color: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
}
.customizeHeader {
background-color: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
}
.ant-pro-top-nav-header-main-left {
min-width: 100px !important;
}
.ant-pro-top-nav-header-logo {
min-width: 100px !important;
}
.link {
color: #296df3;
cursor: pointer;
}
.closeTab {
position: relative;
width: 10px;
height: 10px;
// opacity: 0;
}
.closeTab::before,
.closeTab::after {
position: absolute;
top: -2px;
left: 0;
width: 1px;
height: 10px;
background-color: rgb(50, 50, 50);
content: ' ';
}
.closeTab::before {
transform: rotate(45deg);
}
.closeTab::after {
transform: rotate(-45deg);
}
.dot {
float: right;
width: 8px;
height: 8px;
background: #bfbfbf;
border-radius: 100%;
}
.bdWrapper {
margin: -24px;
.ant-layout-sider {
top: 48px !important;
}
}
.logo {
position: relative;
padding-bottom: 5px;
color: #fff;
font-size: 20px;
font-weight: 700;
padding-right: 50px;
}
.ant-notification-topRight {
right: 240px !important;
}

View File

@@ -0,0 +1,85 @@
import { Button, message, notification } from 'antd';
import React from 'react';
import { useIntl } from 'umi';
import defaultSettings from '../config/defaultSettings';
const { pwa } = defaultSettings;
const isHttps = document.location.protocol === 'https:';
// if pwa is true
if (pwa) {
// Notify user if offline now
window.addEventListener('sw.offline', () => {
message.warning(useIntl().formatMessage({ id: 'app.pwa.offline' }));
});
// Pop up a prompt on the page asking the user if they want to use the latest version
window.addEventListener('sw.updated', (event: Event) => {
const e = event as CustomEvent;
const reloadSW = async () => {
// Check if there is sw whose state is waiting in ServiceWorkerRegistration
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration
const worker = e.detail && e.detail.waiting;
if (!worker) {
return true;
}
// Send skip-waiting event to waiting SW with MessageChannel
await new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (msgEvent) => {
if (msgEvent.data.error) {
reject(msgEvent.data.error);
} else {
resolve(msgEvent.data);
}
};
worker.postMessage({ type: 'skip-waiting' }, [channel.port2]);
});
// Refresh current page to use the updated HTML and other assets after SW has skiped waiting
window.location.reload(true);
return true;
};
const key = `open${Date.now()}`;
const btn = (
<Button
type="primary"
onClick={() => {
notification.close(key);
reloadSW();
}}
>
{useIntl().formatMessage({ id: 'app.pwa.serviceworker.updated.ok' })}
</Button>
);
notification.open({
message: useIntl().formatMessage({ id: 'app.pwa.serviceworker.updated' }),
description: useIntl().formatMessage({ id: 'app.pwa.serviceworker.updated.hint' }),
btn,
key,
onClose: async () => null,
});
});
} else if ('serviceWorker' in navigator && isHttps) {
// unregister service worker
const { serviceWorker } = navigator;
if (serviceWorker.getRegistrations) {
serviceWorker.getRegistrations().then((sws) => {
sws.forEach((sw) => {
sw.unregister();
});
});
}
serviceWorker.getRegistration().then((sw) => {
if (sw) sw.unregister();
});
// remove all caches
if (window.caches && window.caches.keys) {
caches.keys().then((keys) => {
keys.forEach((key) => {
caches.delete(key);
});
});
}
}

View File

@@ -0,0 +1,12 @@
import { useEffect, useRef } from 'react';
export const useMounted = () => {
const mountedRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
return () => mountedRef.current;
};

View File

@@ -0,0 +1,24 @@
import component from './zh-CN/component';
import globalHeader from './zh-CN/globalHeader';
import menu from './zh-CN/menu';
import pwa from './zh-CN/pwa';
import settingDrawer from './zh-CN/settingDrawer';
import settings from './zh-CN/settings';
import pages from './zh-CN/pages';
export default {
'navBar.lang': '语言',
'layout.user.link.help': '帮助',
'layout.user.link.privacy': '隐私',
'layout.user.link.terms': '条款',
'app.preview.down.block': '下载此页面到本地项目',
'app.welcome.link.fetch-blocks': '获取全部区块',
'app.welcome.link.block-list': '基于 block 开发,快速构建标准页面',
...pages,
...globalHeader,
...menu,
...settingDrawer,
...settings,
...pwa,
...component,
};

View File

@@ -0,0 +1,5 @@
export default {
'component.tagSelect.expand': '展开',
'component.tagSelect.collapse': '收起',
'component.tagSelect.all': '全部',
};

View File

@@ -0,0 +1,17 @@
export default {
'component.globalHeader.search': '站内搜索',
'component.globalHeader.search.example1': '搜索提示一',
'component.globalHeader.search.example2': '搜索提示二',
'component.globalHeader.search.example3': '搜索提示三',
'component.globalHeader.help': '使用文档',
'component.globalHeader.notification': '通知',
'component.globalHeader.notification.empty': '你已查看所有通知',
'component.globalHeader.message': '消息',
'component.globalHeader.message.empty': '您已读完所有消息',
'component.globalHeader.event': '待办',
'component.globalHeader.event.empty': '你已完成所有待办',
'component.noticeIcon.clear': '清空',
'component.noticeIcon.cleared': '清空了',
'component.noticeIcon.empty': '暂无数据',
'component.noticeIcon.view-more': '查看更多',
};

View File

@@ -0,0 +1,14 @@
export default {
'menu.welcome': '欢迎',
'menu.result': '结果页',
'menu.result.success': '成功页',
'menu.result.fail': '失败页',
'menu.exception': '异常页',
'menu.exception.not-permission': '403',
'menu.exception.not-find': '404',
'menu.exception.server-error': '500',
'menu.semanticModel': '语义建模',
'menu.chatSetting': '问答设置',
'menu.login': '登录',
'menu.chat': '问答对话',
};

View File

@@ -0,0 +1,65 @@
export default {
'pages.layouts.userLayout.title': 'Ant Design 是西湖区最具影响力的 Web 设计规范',
'pages.login.accountLogin.tab': '账户密码登录',
'pages.login.accountLogin.errorMessage': '错误的用户名和密码admin/ant.design)',
'pages.login.username.placeholder': '用户名: admin or user',
'pages.login.username.required': '用户名是必填项!',
'pages.login.password.placeholder': '密码: ant.design',
'pages.login.password.required': '密码是必填项!',
'pages.login.phoneLogin.tab': '手机号登录',
'pages.login.phoneLogin.errorMessage': '验证码错误',
'pages.login.phoneNumber.placeholder': '请输入手机号!',
'pages.login.phoneNumber.required': '手机号是必填项!',
'pages.login.phoneNumber.invalid': '不合法的手机号!',
'pages.login.captcha.placeholder': '请输入验证码!',
'pages.login.captcha.required': '验证码是必填项!',
'pages.login.phoneLogin.getVerificationCode': '获取验证码',
'pages.getCaptchaSecondText': '秒后重新获取',
'pages.login.rememberMe': '自动登录',
'pages.login.forgotPassword': '忘记密码 ?',
'pages.login.submit': '登录',
'pages.login.loginWith': '其他登录方式 :',
'pages.login.registerAccount': '注册账户',
'pages.welcome.advancedComponent': '高级表格',
'pages.welcome.link': '欢迎使用',
'pages.welcome.advancedLayout': '高级布局',
'pages.welcome.alertMessage': '更快更强的重型组件,已经发布。',
'pages.admin.subPage.title': ' 这个页面只有 admin 权限才能查看',
'pages.admin.subPage.alertMessage': 'umi ui 现已发布,欢迎使用 npm run ui 启动体验。',
'pages.searchTable.createForm.newRule': '新建规则',
'pages.searchTable.updateForm.ruleConfig': '规则配置',
'pages.searchTable.updateForm.basicConfig': '基本信息',
'pages.searchTable.updateForm.ruleName.nameLabel': '规则名称',
'pages.searchTable.updateForm.ruleName.nameRules': '请输入规则名称!',
'pages.searchTable.updateForm.ruleDesc.descLabel': '规则描述',
'pages.searchTable.updateForm.ruleDesc.descPlaceholder': '请输入至少五个字符',
'pages.searchTable.updateForm.ruleDesc.descRules': '请输入至少五个字符的规则描述!',
'pages.searchTable.updateForm.ruleProps.title': '配置规则属性',
'pages.searchTable.updateForm.object': '监控对象',
'pages.searchTable.updateForm.ruleProps.templateLabel': '规则模板',
'pages.searchTable.updateForm.ruleProps.typeLabel': '规则类型',
'pages.searchTable.updateForm.schedulingPeriod.title': '设定调度周期',
'pages.searchTable.updateForm.schedulingPeriod.timeLabel': '开始时间',
'pages.searchTable.updateForm.schedulingPeriod.timeRules': '请选择开始时间!',
'pages.searchTable.titleDesc': '描述',
'pages.searchTable.ruleName': '规则名称为必填项',
'pages.searchTable.titleCallNo': '服务调用次数',
'pages.searchTable.titleStatus': '状态',
'pages.searchTable.nameStatus.default': '关闭',
'pages.searchTable.nameStatus.running': '运行中',
'pages.searchTable.nameStatus.online': '已上线',
'pages.searchTable.nameStatus.abnormal': '异常',
'pages.searchTable.titleUpdatedAt': '上次调度时间',
'pages.searchTable.exception': '请输入异常原因!',
'pages.searchTable.titleOption': '操作',
'pages.searchTable.config': '配置',
'pages.searchTable.subscribeAlert': '订阅警报',
'pages.searchTable.title': '查询表格',
'pages.searchTable.new': '新建',
'pages.searchTable.chosen': '已选择',
'pages.searchTable.item': '项',
'pages.searchTable.totalServiceCalls': '服务调用次数总计',
'pages.searchTable.tenThousand': '万',
'pages.searchTable.batchDeletion': '批量删除',
'pages.searchTable.batchApproval': '批量审批',
};

View File

@@ -0,0 +1,6 @@
export default {
'app.pwa.offline': '当前处于离线状态',
'app.pwa.serviceworker.updated': '有新内容',
'app.pwa.serviceworker.updated.hint': '请点击“刷新”按钮或者手动刷新页面',
'app.pwa.serviceworker.updated.ok': '刷新',
};

View File

@@ -0,0 +1,31 @@
export default {
'app.setting.pagestyle': '整体风格设置',
'app.setting.pagestyle.dark': '暗色菜单风格',
'app.setting.pagestyle.light': '亮色菜单风格',
'app.setting.content-width': '内容区域宽度',
'app.setting.content-width.fixed': '定宽',
'app.setting.content-width.fluid': '流式',
'app.setting.themecolor': '主题色',
'app.setting.themecolor.dust': '薄暮',
'app.setting.themecolor.volcano': '火山',
'app.setting.themecolor.sunset': '日暮',
'app.setting.themecolor.cyan': '明青',
'app.setting.themecolor.green': '极光绿',
'app.setting.themecolor.daybreak': '拂晓蓝(默认)',
'app.setting.themecolor.geekblue': '极客蓝',
'app.setting.themecolor.purple': '酱紫',
'app.setting.navigationmode': '导航模式',
'app.setting.sidemenu': '侧边菜单布局',
'app.setting.topmenu': '顶部菜单布局',
'app.setting.fixedheader': '固定 Header',
'app.setting.fixedsidebar': '固定侧边菜单',
'app.setting.fixedsidebar.hint': '侧边菜单布局时可配置',
'app.setting.hideheader': '下滑时隐藏 Header',
'app.setting.hideheader.hint': '固定 Header 时可配置',
'app.setting.othersettings': '其他设置',
'app.setting.weakmode': '色弱模式',
'app.setting.copy': '拷贝设置',
'app.setting.copyinfo': '拷贝成功,请到 src/defaultSettings.js 中替换默认配置',
'app.setting.production.hint':
'配置栏只在开发环境用于预览,生产环境不会展现,请拷贝后手动修改配置文件',
};

View File

@@ -0,0 +1,55 @@
export default {
'app.settings.menuMap.basic': '基本设置',
'app.settings.menuMap.security': '安全设置',
'app.settings.menuMap.binding': '账号绑定',
'app.settings.menuMap.notification': '新消息通知',
'app.settings.basic.avatar': '头像',
'app.settings.basic.change-avatar': '更换头像',
'app.settings.basic.email': '邮箱',
'app.settings.basic.email-message': '请输入您的邮箱!',
'app.settings.basic.nickname': '昵称',
'app.settings.basic.nickname-message': '请输入您的昵称!',
'app.settings.basic.profile': '个人简介',
'app.settings.basic.profile-message': '请输入个人简介!',
'app.settings.basic.profile-placeholder': '个人简介',
'app.settings.basic.country': '国家/地区',
'app.settings.basic.country-message': '请输入您的国家或地区!',
'app.settings.basic.geographic': '所在省市',
'app.settings.basic.geographic-message': '请输入您的所在省市!',
'app.settings.basic.address': '街道地址',
'app.settings.basic.address-message': '请输入您的街道地址!',
'app.settings.basic.phone': '联系电话',
'app.settings.basic.phone-message': '请输入您的联系电话!',
'app.settings.basic.update': '更新基本信息',
'app.settings.security.strong': '强',
'app.settings.security.medium': '中',
'app.settings.security.weak': '弱',
'app.settings.security.password': '账户密码',
'app.settings.security.password-description': '当前密码强度',
'app.settings.security.phone': '密保手机',
'app.settings.security.phone-description': '已绑定手机',
'app.settings.security.question': '密保问题',
'app.settings.security.question-description': '未设置密保问题,密保问题可有效保护账户安全',
'app.settings.security.email': '备用邮箱',
'app.settings.security.email-description': '已绑定邮箱',
'app.settings.security.mfa': 'MFA 设备',
'app.settings.security.mfa-description': '未绑定 MFA 设备,绑定后,可以进行二次确认',
'app.settings.security.modify': '修改',
'app.settings.security.set': '设置',
'app.settings.security.bind': '绑定',
'app.settings.binding.taobao': '绑定淘宝',
'app.settings.binding.taobao-description': '当前未绑定淘宝账号',
'app.settings.binding.alipay': '绑定支付宝',
'app.settings.binding.alipay-description': '当前未绑定支付宝账号',
'app.settings.binding.dingding': '绑定钉钉',
'app.settings.binding.dingding-description': '当前未绑定钉钉账号',
'app.settings.binding.bind': '绑定',
'app.settings.notification.password': '账户密码',
'app.settings.notification.password-description': '其他用户的消息将以站内信的形式通知',
'app.settings.notification.messages': '系统消息',
'app.settings.notification.messages-description': '系统消息将以站内信的形式通知',
'app.settings.notification.todo': '待办任务',
'app.settings.notification.todo-description': '待办任务将以站内信的形式通知',
'app.settings.open': '开',
'app.settings.close': '关',
};

View File

@@ -0,0 +1,22 @@
{
"name": "Ant Design Pro",
"short_name": "Ant Design Pro",
"display": "standalone",
"start_url": "./?utm_source=homescreen",
"theme_color": "#002140",
"background_color": "#001529",
"icons": [
{
"src": "icons/icon-192x192.png",
"sizes": "192x192"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512"
}
]
}

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();
}
}

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