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,17 @@
import Message from '../ChatMsg/Message';
import { PREFIX_CLS } from '../../common/constants';
type Props = {
data: any;
};
const Text: React.FC<Props> = ({ data }) => {
const prefixCls = `${PREFIX_CLS}-item`;
return (
<Message position="left" bubbleClassName={`${prefixCls}-text-bubble`} noWaterMark>
<div className={`${prefixCls}-text`}>{data}</div>
</Message>
);
};
export default Text;

View File

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

View File

@@ -0,0 +1,154 @@
import { MsgDataType, MsgValidTypeEnum, SuggestionDataType } from '../../common/type';
import { useEffect, useState } from 'react';
import Typing from './Typing';
import ChatMsg from '../ChatMsg';
import { querySuggestionInfo, chatQuery } from '../../service';
import { MSG_VALID_TIP, PARSE_ERROR_TIP, PREFIX_CLS } from '../../common/constants';
import Text from './Text';
import Suggestion from '../Suggestion';
import Tools from '../Tools';
import SemanticDetail from '../SemanticDetail';
type Props = {
msg: string;
conversationId?: number;
classId?: number;
isLastMessage?: boolean;
suggestionEnable?: boolean;
msgData?: MsgDataType;
onLastMsgDataLoaded?: (data: MsgDataType) => void;
onSelectSuggestion?: (value: string) => void;
onUpdateMessageScroll?: () => void;
};
const ChatItem: React.FC<Props> = ({
msg,
conversationId,
classId,
isLastMessage,
suggestionEnable,
msgData,
onLastMsgDataLoaded,
onSelectSuggestion,
onUpdateMessageScroll,
}) => {
const [data, setData] = useState<MsgDataType>();
const [suggestionData, setSuggestionData] = useState<SuggestionDataType>();
const [loading, setLoading] = useState(false);
const [metricInfoList, setMetricInfoList] = useState<any[]>([]);
const [tip, setTip] = useState('');
const setMsgData = (value: MsgDataType) => {
setData(value);
};
const updateData = (res: Result<MsgDataType>) => {
if (res.code === 401) {
setTip(res.msg);
return false;
}
if (res.code !== 200) {
setTip(PARSE_ERROR_TIP);
return false;
}
const { queryColumns, queryResults, queryState } = res.data || {};
if (queryState !== MsgValidTypeEnum.NORMAL && queryState !== MsgValidTypeEnum.EMPTY) {
setTip(MSG_VALID_TIP[queryState || MsgValidTypeEnum.INVALID]);
return false;
}
if (queryColumns && queryColumns.length > 0 && queryResults) {
setMsgData(res.data);
setTip('');
return true;
}
setTip(PARSE_ERROR_TIP);
return false;
};
const updateSuggestionData = (semanticRes: MsgDataType, suggestionRes: any) => {
const { aggregateType, queryColumns, entityInfo } = semanticRes;
setSuggestionData({
currentAggregateType: aggregateType,
columns: queryColumns || [],
mainEntity: entityInfo,
suggestions: suggestionRes,
});
};
const getSuggestions = async (domainId: number, semanticResData: MsgDataType) => {
if (!domainId) {
return;
}
const res = await querySuggestionInfo(domainId);
updateSuggestionData(semanticResData, res.data.data);
};
const onSendMsg = async () => {
setLoading(true);
const semanticRes = await chatQuery(msg, conversationId, classId);
const semanticValid = updateData(semanticRes.data);
if (suggestionEnable && semanticValid) {
const semanticResData = semanticRes.data.data;
await getSuggestions(semanticResData.entityInfo?.domainInfo?.itemId, semanticRes.data.data);
} else {
setSuggestionData(undefined);
}
if (onLastMsgDataLoaded) {
onLastMsgDataLoaded(semanticRes.data.data);
}
setLoading(false);
};
useEffect(() => {
if (msgData) {
updateData({ code: 200, data: msgData, msg: 'success' });
} else if (msg) {
onSendMsg();
}
}, [msg, msgData]);
if (loading) {
return <Typing />;
}
if (tip) {
return <Text data={tip} />;
}
if (!data) {
return null;
}
const onCheckMetricInfo = (data: any) => {
setMetricInfoList([...metricInfoList, data]);
if (onUpdateMessageScroll) {
onUpdateMessageScroll();
}
};
const prefixCls = `${PREFIX_CLS}-item`;
return (
<div>
<ChatMsg data={data} onCheckMetricInfo={onCheckMetricInfo} />
<Tools isLastMessage={isLastMessage} />
{suggestionEnable && suggestionData && isLastMessage && (
<Suggestion {...suggestionData} onSelect={onSelectSuggestion} />
)}
<div className={`${prefixCls}-metric-info-list`}>
{metricInfoList.map(item => (
<SemanticDetail
dataSource={item}
onDimensionSelect={(value: string) => {
if (onSelectSuggestion) {
onSelectSuggestion(value);
}
}}
/>
))}
</div>
</div>
);
};
export default ChatItem;

View File

@@ -0,0 +1,38 @@
@import '../../styles/index.less';
@chat-item-prefix-cls: ~'@{supersonic-chat-prefix}-item';
.@{chat-item-prefix-cls} {
&-metric-info-list {
margin-top: 30px;
display: flex;
flex-direction: column;
row-gap: 30px;
}
&-typing {
width: 100%;
padding: 0 5px;
.ant-spin-dot {
width: 100% !important;
height: 100% !important;
}
}
&-typing-bubble {
width: fit-content;
padding: 16px !important;
}
&-text-bubble {
width: fit-content;
}
&-text {
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: break-word;
user-select: text;
}
}

View File

@@ -0,0 +1,30 @@
import { PREFIX_CLS } from '../../../common/constants';
type Props = {
domain: string;
onApplyAuth?: (domain: string) => void;
};
const ApplyAuth: React.FC<Props> = ({ domain, onApplyAuth }) => {
const prefixCls = `${PREFIX_CLS}-apply-auth`;
return (
<div className={prefixCls}>
{onApplyAuth ? (
<span
className={`${prefixCls}-apply`}
onClick={() => {
onApplyAuth(domain);
}}
>
</span>
) : (
'请联系管理员申请权限'
)}
</div>
);
};
export default ApplyAuth;

View File

@@ -0,0 +1,13 @@
@import '../../../styles/index.less';
@apply-auth-cls: ~'@{supersonic-chat-prefix}-apply-auth';
.@{apply-auth-cls} {
font-size: 14px;
color: var(--text-color);
&-apply {
color: var(--chat-blue);
cursor: pointer;
}
}

View File

@@ -0,0 +1,149 @@
import { CHART_BLUE_COLOR, CHART_SECONDARY_COLOR, PREFIX_CLS } from '../../../common/constants';
import { MsgDataType } from '../../../common/type';
import { getChartLightenColor, getFormattedValue } from '../../../utils/utils';
import type { ECharts } from 'echarts';
import * as echarts from 'echarts';
import React, { useEffect, useRef, useState } from 'react';
import NoPermissionChart from '../NoPermissionChart';
type Props = {
data: MsgDataType;
onApplyAuth?: (domain: string) => void;
};
const BarChart: React.FC<Props> = ({ data, onApplyAuth }) => {
const chartRef = useRef<any>();
const [instance, setInstance] = useState<ECharts>();
const { queryColumns, queryResults, entityInfo } = data;
const categoryColumnName =
queryColumns?.find(column => column.showType === 'CATEGORY')?.nameEn || '';
const metricColumn = queryColumns?.find(column => column.showType === 'NUMBER');
const metricColumnName = metricColumn?.nameEn || '';
const renderChart = () => {
let instanceObj: any;
if (!instance) {
instanceObj = echarts.init(chartRef.current);
setInstance(instanceObj);
} else {
instanceObj = instance;
}
const data = (queryResults || []).sort(
(a: any, b: any) => b[metricColumnName] - a[metricColumnName]
);
const xData = data.map(item => item[categoryColumnName]);
instanceObj.setOption({
legend: {
left: 0,
top: 0,
icon: 'rect',
itemWidth: 15,
itemHeight: 5,
},
xAxis: {
type: 'category',
axisTick: {
show: false,
},
axisLine: {
lineStyle: {
color: CHART_SECONDARY_COLOR,
},
},
axisLabel: {
width: 200,
overflow: 'truncate',
showMaxLabel: true,
hideOverlap: false,
interval: 0,
color: '#333',
rotate: 30,
},
data: xData,
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: {
opacity: 0.3,
},
},
axisLabel: {
formatter: function (value: any) {
return value === 0 ? 0 : getFormattedValue(value);
},
},
},
tooltip: {
trigger: 'axis',
formatter: function (params: any[]) {
const param = params[0];
const valueLabels = params
.map(
(item: any) =>
`<div style="margin-top: 3px;">${
item.marker
} <span style="display: inline-block; width: 70px; margin-right: 12px;">${
item.seriesName
}</span><span style="display: inline-block; width: 90px; text-align: right; font-weight: 500;">${getFormattedValue(
item.value
)}</span></div>`
)
.join('');
return `${param.name}<br />${valueLabels}`;
},
},
grid: {
left: '2%',
right: '1%',
bottom: '3%',
top: 50,
containLabel: true,
},
series: {
type: 'bar',
name: metricColumn?.name,
barWidth: 20,
itemStyle: {
borderRadius: [10, 10, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: CHART_BLUE_COLOR },
{ offset: 1, color: getChartLightenColor(CHART_BLUE_COLOR) },
]),
},
label: {
show: true,
position: 'top',
formatter: function ({ value }: any) {
return getFormattedValue(value);
},
},
data: data.map(item => {
return item[metricColumn?.nameEn || ''];
}),
},
});
instanceObj.resize();
};
useEffect(() => {
if (queryResults && queryResults.length > 0 && metricColumn?.authorized) {
renderChart();
}
}, [queryResults]);
if (!metricColumn?.authorized) {
return (
<NoPermissionChart
domain={entityInfo?.domainInfo.name || ''}
chartType="barChart"
onApplyAuth={onApplyAuth}
/>
);
}
return <div className={`${PREFIX_CLS}-bar`} ref={chartRef} />;
};
export default BarChart;

View File

@@ -0,0 +1,8 @@
@import '../../../styles/index.less';
@bar-cls: ~'@{supersonic-chat-prefix}-bar';
.@{bar-cls} {
height: 300px;
margin-top: 20px;
}

View File

@@ -0,0 +1,124 @@
import { EntityInfoType, ChatContextType } from '../../../common/type';
import moment from 'moment';
import { PREFIX_CLS } from '../../../common/constants';
type Props = {
position: 'left' | 'right';
width?: number | string;
height?: number | string;
bubbleClassName?: string;
noWaterMark?: boolean;
chatContext?: ChatContextType;
entityInfo?: EntityInfoType;
tip?: string;
aggregator?: string;
noTime?: boolean;
children?: React.ReactNode;
};
const Message: React.FC<Props> = ({
position,
width,
height,
children,
bubbleClassName,
chatContext,
entityInfo,
aggregator,
noTime,
}) => {
const { aggType, dateInfo, filters, metrics, domainName } = chatContext || {};
const prefixCls = `${PREFIX_CLS}-message`;
const timeSection =
!noTime && dateInfo?.text ? (
dateInfo.text
) : (
<div>{`${moment(dateInfo?.endDate).diff(dateInfo?.startDate, 'days') + 1}`}</div>
);
const metricSection =
metrics &&
metrics.map((metric, index) => {
let metricNode = <span className={`${PREFIX_CLS}-metric`}>{metric.name}</span>;
return (
<>
{metricNode}
{index < metrics.length - 1 && <span></span>}
</>
);
});
const aggregatorSection = aggregator !== 'tag' && aggType !== 'NONE' && aggType;
const hasFilterSection = filters && filters.length > 0;
const filterSection = hasFilterSection && (
<div className={`${prefixCls}-filter-section`}>
<div className={`${prefixCls}-field-name`}></div>
<div className={`${prefixCls}-filter-values`}>
{filters.map(filterItem => {
return (
<div className={`${prefixCls}-filter-item`} key={filterItem.name}>
{filterItem.name}{filterItem.value}
</div>
);
})}
</div>
</div>
);
const entityInfoList =
entityInfo?.dimensions?.filter(dimension => !dimension.bizName.includes('photo')) || [];
const hasEntityInfoSection =
entityInfoList.length > 0 && chatContext && chatContext.dimensions?.length > 0;
return (
<div className={prefixCls}>
<div className={`${prefixCls}-content`}>
<div className={`${prefixCls}-body`}>
<div
className={`${prefixCls}-bubble${bubbleClassName ? ` ${bubbleClassName}` : ''}`}
style={{ width, height }}
onClick={e => {
e.stopPropagation();
}}
>
{position === 'left' && chatContext && (
<div className={`${prefixCls}-top-bar`}>
{domainName}
{/* {dimensionSection} */}
{timeSection}
{metricSection}
{aggregatorSection}
{/* {tipSection} */}
</div>
)}
{(hasEntityInfoSection || hasFilterSection) && (
<div className={`${prefixCls}-info-bar`}>
{hasEntityInfoSection && (
<div className={`${prefixCls}-main-entity-info`}>
{entityInfoList.slice(0, 3).map(dimension => {
return (
<div className={`${prefixCls}-info-item`} key={dimension.bizName}>
<div className={`${prefixCls}-info-name`}>{dimension.name}</div>
<div className={`${prefixCls}-info-value`}>{dimension.value}</div>
</div>
);
})}
</div>
)}
{filterSection}
</div>
)}
<div className={`${prefixCls}-children`}>{children}</div>
</div>
</div>
</div>
</div>
);
};
export default Message;

View File

@@ -0,0 +1,89 @@
@import '../../../styles/index.less';
@msg-prefix-cls: ~'@{supersonic-chat-prefix}-message';
.@{msg-prefix-cls} {
&-content {
display: flex;
align-items: flex-start;
}
&-body {
width: 100%;
}
&-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);
}
&-top-bar {
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);
}
&-filter-section {
display: flex;
align-items: center;
color: var(--text-color-secondary);
font-weight: normal;
font-size: 13px;
}
&-filter-item {
padding: 2px 12px;
color: var(--text-color-secondary);
background-color: #edf2f2;
border-radius: 13px;
}
&-tip {
margin-left: 6px;
color: var(--text-color-third);
}
&-info-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-top: 20px;
column-gap: 20px;
}
&-main-entity-info {
display: flex;
flex-wrap: wrap;
align-items: center;
font-size: 13px;
column-gap: 20px;
}
&-info-item {
display: flex;
align-items: center;
}
&-info-Name {
color: var(--text-color-fourth);
}
&-info-value {
color: var(--text-color-secondary);
}
}

View File

@@ -0,0 +1,38 @@
import { PREFIX_CLS } from '../../../common/constants';
import { getFormattedValue } from '../../../utils/utils';
import ApplyAuth from '../ApplyAuth';
import { MsgDataType } from '../../../common/type';
type Props = {
data: MsgDataType;
onApplyAuth?: (domain: string) => void;
};
const MetricCard: React.FC<Props> = ({ data, onApplyAuth }) => {
const { queryColumns, queryResults, entityInfo } = data;
const indicatorColumn = queryColumns?.find(column => column.showType === 'NUMBER');
const indicatorColumnName = indicatorColumn?.nameEn || '';
const prefixCls = `${PREFIX_CLS}-metric-card`;
return (
<div className={prefixCls}>
<div className={`${prefixCls}-indicator`}>
{/* <div className={`${prefixCls}-date-range`}>
{startTime === endTime ? startTime : `${startTime} ~ ${endTime}`}
</div> */}
{!indicatorColumn?.authorized ? (
<ApplyAuth domain={entityInfo?.domainInfo.name || ''} onApplyAuth={onApplyAuth} />
) : (
<div className={`${prefixCls}-indicator-value`}>
{getFormattedValue(queryResults?.[0]?.[indicatorColumnName])}
</div>
)}
{/* <div className={`${prefixCls}-indicator-name`}>{query}</div> */}
</div>
</div>
);
};
export default MetricCard;

View File

@@ -0,0 +1,36 @@
@import '../../../styles/index.less';
@metric-card-prefix-cls: ~'@{supersonic-chat-prefix}-metric-card';
.@{metric-card-prefix-cls} {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 150px;
row-gap: 4px;
&-indicator {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
}
&-date-range {
color: var(--text-color-fourth);
font-size: 14px;
}
&-indicator-value {
color: var(--text-color);
font-weight: 600;
font-size: 30px;
}
&-indicator-name {
color: var(--text-color-fourth);
font-size: 14px;
}
}

View File

@@ -0,0 +1,197 @@
import { CHART_SECONDARY_COLOR, CLS_PREFIX, THEME_COLOR_LIST } from '../../../common/constants';
import {
formatByDecimalPlaces,
getFormattedValue,
getMinMaxDate,
groupByColumn,
normalizeTrendData,
} from '../../../utils/utils';
import type { ECharts } from 'echarts';
import * as echarts from 'echarts';
import React, { useEffect, useRef, useState } from 'react';
import moment from 'moment';
import { ColumnType } from '../../../common/type';
import NoPermissionChart from '../NoPermissionChart';
type Props = {
domain?: string;
dateColumnName: string;
categoryColumnName: string;
metricField: ColumnType;
resultList: any[];
onApplyAuth?: (domain: string) => void;
};
const MetricTrendChart: React.FC<Props> = ({
domain,
dateColumnName,
categoryColumnName,
metricField,
resultList,
onApplyAuth,
}) => {
const chartRef = useRef<any>();
const [instance, setInstance] = useState<ECharts>();
const renderChart = () => {
let instanceObj: any;
if (!instance) {
instanceObj = echarts.init(chartRef.current);
setInstance(instanceObj);
} else {
instanceObj = instance;
}
const valueColumnName = metricField.nameEn;
const groupDataValue = groupByColumn(resultList, categoryColumnName);
const [startDate, endDate] = getMinMaxDate(resultList, dateColumnName);
const groupData = Object.keys(groupDataValue).reduce((result: any, key) => {
result[key] =
startDate &&
endDate &&
(dateColumnName.includes('date') || dateColumnName.includes('month'))
? normalizeTrendData(
groupDataValue[key],
dateColumnName,
valueColumnName,
startDate,
endDate,
dateColumnName.includes('month') ? 'months' : 'days'
)
: groupDataValue[key].reverse();
return result;
}, {});
const sortedGroupKeys = Object.keys(groupData).sort((a, b) => {
return (
groupData[b][groupData[b].length - 1][valueColumnName] -
groupData[a][groupData[a].length - 1][valueColumnName]
);
});
const xData = groupData[sortedGroupKeys[0]]?.map((item: any) => {
const date = `${item[dateColumnName]}`;
return date.length === 10 ? moment(date).format('MM-DD') : date;
});
instanceObj.setOption({
legend: categoryColumnName && {
left: 0,
top: 0,
icon: 'rect',
itemWidth: 15,
itemHeight: 5,
type: 'scroll',
},
xAxis: {
type: 'category',
axisTick: {
alignWithLabel: true,
lineStyle: {
color: CHART_SECONDARY_COLOR,
},
},
axisLine: {
lineStyle: {
color: CHART_SECONDARY_COLOR,
},
},
axisLabel: {
showMaxLabel: true,
color: '#999',
},
data: xData,
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: {
opacity: 0.3,
},
},
axisLabel: {
formatter: function (value: any) {
return value === 0
? 0
: metricField.dataFormatType === 'percent'
? `${formatByDecimalPlaces(value, metricField.dataFormat?.decimalPlaces || 2)}%`
: getFormattedValue(value);
},
},
},
tooltip: {
trigger: 'axis',
formatter: function (params: any[]) {
const param = params[0];
const valueLabels = params
.sort((a, b) => b.value - a.value)
.map(
(item: any) =>
`<div style="margin-top: 3px;">${
item.marker
} <span style="display: inline-block; width: 70px; margin-right: 12px;">${
item.seriesName
}</span><span style="display: inline-block; width: 90px; text-align: right; font-weight: 500;">${
item.value === ''
? '-'
: metricField.dataFormatType === 'percent'
? `${formatByDecimalPlaces(
item.value,
metricField.dataFormat?.decimalPlaces || 2
)}%`
: getFormattedValue(item.value)
}</span></div>`
)
.join('');
return `${param.name}<br />${valueLabels}`;
},
},
grid: {
left: '1%',
right: '4%',
bottom: '3%',
top: categoryColumnName ? 45 : 20,
containLabel: true,
},
series: sortedGroupKeys.slice(0, 20).map((category, index) => {
const data = groupData[category];
return {
type: 'line',
name: categoryColumnName ? category : metricField.name,
symbol: 'circle',
showSymbol: data.length === 1,
smooth: true,
data: data.map((item: any) => {
const value = item[valueColumnName];
return metricField.dataFormatType === 'percent' &&
metricField.dataFormat?.needmultiply100
? value * 100
: value;
}),
color: THEME_COLOR_LIST[index],
};
}),
});
instanceObj.resize();
};
useEffect(() => {
if (metricField.authorized) {
renderChart();
}
}, [resultList, metricField]);
const prefixCls = `${CLS_PREFIX}-metric-trend`;
return (
<div>
{!metricField.authorized ? (
<NoPermissionChart domain={domain || ''} onApplyAuth={onApplyAuth} />
) : (
<div className={`${prefixCls}-flow-trend-chart`} ref={chartRef} />
)}
</div>
);
};
export default MetricTrendChart;

View File

@@ -0,0 +1,205 @@
import { useEffect, useState } from 'react';
import { CLS_PREFIX, DATE_TYPES } from '../../../common/constants';
import { ColumnType, MsgDataType } from '../../../common/type';
import { groupByColumn, isMobile } from '../../../utils/utils';
import { queryData } from '../../../service';
import MetricTrendChart from './MetricTrendChart';
import classNames from 'classnames';
import { Spin } from 'antd';
import Table from '../Table';
import SemanticInfoPopover from '../SemanticInfoPopover';
type Props = {
data: MsgDataType;
onApplyAuth?: (domain: string) => void;
onCheckMetricInfo?: (data: any) => void;
};
const MetricTrend: React.FC<Props> = ({ data, onApplyAuth, onCheckMetricInfo }) => {
const { queryColumns, queryResults, entityInfo, chatContext } = data;
const [columns, setColumns] = useState<ColumnType[]>(queryColumns);
const metricFields = columns.filter((column: any) => column.showType === 'NUMBER') || [];
const [currentMetricField, setCurrentMetricField] = useState<ColumnType>(metricFields[0]);
const [onlyOneDate, setOnlyOneDate] = useState(false);
const [trendData, setTrendData] = useState(data);
const [dataSource, setDataSource] = useState<any[]>(queryResults);
const [mergeMetric, setMergeMetric] = useState(false);
const [currentDateOption, setCurrentDateOption] = useState<number>();
const [loading, setLoading] = useState(false);
const dateField: any = columns.find(
(column: any) => column.showType === 'DATE' || column.type === 'DATE'
);
const dateColumnName = dateField?.nameEn || '';
const categoryColumnName =
columns.find((column: any) => column.showType === 'CATEGORY')?.nameEn || '';
const getColumns = () => {
const categoryFieldData = groupByColumn(dataSource, categoryColumnName);
const result = [dateField];
const columnsValue = Object.keys(categoryFieldData).map(item => ({
authorized: currentMetricField.authorized,
name: item !== 'undefined' ? item : currentMetricField.name,
nameEn: `${item}${currentMetricField.name}`,
showType: 'NUMBER',
type: 'NUMBER',
}));
return result.concat(columnsValue);
};
const getResultList = () => {
return [
{
[dateField.nameEn]: dataSource[0][dateField.nameEn],
...dataSource.reduce((result, item) => {
result[`${item[categoryColumnName]}${currentMetricField.name}`] =
item[currentMetricField.nameEn];
return result;
}, {}),
},
];
};
useEffect(() => {
setDataSource(queryResults);
}, [queryResults]);
useEffect(() => {
let onlyOneDateValue = false;
let dataValue = trendData;
if (dateColumnName && dataSource.length > 0) {
const dateFieldData = groupByColumn(dataSource, dateColumnName);
onlyOneDateValue =
Object.keys(dateFieldData).length === 1 && Object.keys(dateFieldData)[0] !== undefined;
if (onlyOneDateValue) {
if (categoryColumnName !== '') {
dataValue = {
...trendData,
queryColumns: getColumns(),
queryResults: getResultList(),
};
} else {
setMergeMetric(true);
}
}
}
setOnlyOneDate(onlyOneDateValue);
setTrendData(dataValue);
}, [currentMetricField]);
const dateOptions = DATE_TYPES[chatContext.dateInfo?.period] || DATE_TYPES[0];
const onLoadData = async (value: number) => {
setLoading(true);
const { data } = await queryData({
...chatContext,
dateInfo: { ...chatContext.dateInfo, unit: value },
});
setLoading(false);
if (data.code === 200) {
setColumns(data.data?.queryColumns || []);
setDataSource(data.data?.queryResults || []);
}
};
const selectDateOption = (dateOption: number) => {
setCurrentDateOption(dateOption);
// const { domainName, dimensions, metrics, aggType, filters } = chatContext || {};
// const dimensionSection = dimensions?.join('、') || '';
// const metricSection = metrics?.join('、') || '';
// const aggregatorSection = aggType || '';
// const filterSection = filters
// .reduce((result, dimensionName) => {
// result = result.concat(dimensionName);
// return result;
// }, [])
// .join('、');
onLoadData(dateOption);
};
if (metricFields.length === 0) {
return null;
}
const prefixCls = `${CLS_PREFIX}-metric-trend`;
return (
<div className={prefixCls}>
<div className={`${prefixCls}-charts`}>
{!onlyOneDate && (
<div className={`${prefixCls}-date-options`}>
{dateOptions.map((dateOption: { label: string; value: number }, index: number) => {
const dateOptionClass = classNames(`${prefixCls}-date-option`, {
[`${prefixCls}-date-active`]: dateOption.value === currentDateOption,
[`${prefixCls}-date-mobile`]: isMobile,
});
return (
<>
<div
key={dateOption.value}
className={dateOptionClass}
onClick={() => {
selectDateOption(dateOption.value);
}}
>
{dateOption.label}
{dateOption.value === currentDateOption && (
<div className={`${prefixCls}-active-identifier`} />
)}
</div>
{index !== dateOptions.length - 1 && (
<div className={`${prefixCls}-date-option-divider`} />
)}
</>
);
})}
</div>
)}
{metricFields.length > 1 && !mergeMetric && (
<div className={`${prefixCls}-metric-fields`}>
{metricFields.map((metricField: ColumnType) => {
const metricFieldClass = classNames(`${prefixCls}-metric-field`, {
[`${prefixCls}-metric-field-active`]:
currentMetricField?.nameEn === metricField.nameEn,
});
return (
<div
className={metricFieldClass}
key={metricField.nameEn}
onClick={() => {
setCurrentMetricField(metricField);
}}
>
<SemanticInfoPopover
classId={chatContext.domainId}
uniqueId={metricField.nameEn}
onDetailBtnClick={onCheckMetricInfo}
>
{metricField.name}
</SemanticInfoPopover>
</div>
);
})}
</div>
)}
{onlyOneDate ? (
<Table data={trendData} onApplyAuth={onApplyAuth} />
) : (
<Spin spinning={loading}>
<MetricTrendChart
domain={entityInfo?.domainInfo.name}
dateColumnName={dateColumnName}
categoryColumnName={categoryColumnName}
metricField={currentMetricField}
resultList={dataSource}
onApplyAuth={onApplyAuth}
/>
</Spin>
)}
</div>
</div>
);
};
export default MetricTrend;

View File

@@ -0,0 +1,124 @@
@import '../../../styles/index.less';
@metric-trend-prefix-cls: ~'@{supersonic-chat-prefix}-metric-trend';
.@{metric-trend-prefix-cls} {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: 20px;
width: 100%;
row-gap: 4px;
&-indicator {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
}
&-date-range {
color: var(--text-color-fourth);
font-size: 14px;
}
&-indicator-value {
color: var(--text-color);
font-weight: 600;
font-size: 30px;
}
&-indicator-name {
color: var(--text-color-fourth);
font-size: 14px;
}
&-flow-trend-chart {
height: 300px;
}
&-charts {
display: flex;
flex-direction: column;
width: 100%;
row-gap: 20px;
}
&-metric-fields {
display: flex;
flex-wrap: wrap;
align-items: center;
row-gap: 12px;
}
&-metric-field {
display: inline-block;
box-sizing: border-box;
height: auto;
margin: 0;
margin-right: 8px;
padding: 1px 8px;
color: var(--text-color-third);
font-variant: tabular-nums;
line-height: 20px;
white-space: nowrap;
list-style: none;
border-color: transparent;
border-radius: 2px;
cursor: pointer;
opacity: 1;
transition: all 0.3s;
font-feature-settings: 'tnum', 'tnum';
&:hover {
color: var(--chat-blue);
}
}
&-metric-field-active {
color: #fff !important;
background-color: var(--chat-blue);
}
&-date-options {
display: flex;
align-items: center;
column-gap: 20px;
font-size: 14px;
}
&-date-option {
position: relative;
color: var(--text-color-secondary);
cursor: pointer;
&:hover {
color: var(--chat-blue);
}
}
&-date-option-active {
color: var(--chat-blue);
}
&-date-option-mobile {
font-size: 12px;
}
&-active-identifier {
position: absolute;
bottom: -6px;
width: 100%;
height: 4px;
background-color: var(--chat-blue);
border-radius: 4px 4px 0 0;
}
&-date-option-divider {
width: 1px;
height: 16px;
background-color: var(--text-color-fifth);
}
}

View File

@@ -0,0 +1,28 @@
import classNames from 'classnames';
import { CLS_PREFIX } from '../../../common/constants';
import ApplyAuth from '../ApplyAuth';
type Props = {
domain: string;
chartType?: string;
onApplyAuth?: (domain: string) => void;
};
const NoPermissionChart: React.FC<Props> = ({ domain, chartType, onApplyAuth }) => {
const prefixCls = `${CLS_PREFIX}-no-permission-chart`;
const chartHolderClass = classNames(`${prefixCls}-holder`, {
[`${prefixCls}-bar-chart-holder`]: chartType === 'barChart',
});
return (
<div className={prefixCls}>
<div className={chartHolderClass} />
<div className={`${prefixCls}-no-permission`}>
<ApplyAuth domain={domain} onApplyAuth={onApplyAuth} />
</div>
</div>
);
};
export default NoPermissionChart;

View File

@@ -0,0 +1,30 @@
@import '../../../styles/index.less';
@no-permission-chart-prefix-cls: ~'@{supersonic-chat-prefix}-no-permission-chart';
.@{no-permission-chart-prefix-cls} {
position: relative;
width: 100%;
height: 300px;
&-holder {
width: 100%;
height: 300px;
// background-image: url(~./images/line_chart_holder.png);
// background-repeat: no-repeat;
// background-size: 100% 300px;
}
&-bar-chart-holder {
margin-top: 20px;
// background-image: url(~./images/bar_chart_holder.png);
}
&-no-permission {
position: absolute;
top: 50%;
left: 50%;
padding: 4px 12px;
transform: translate(-50%, -50%);
}
}

View File

@@ -0,0 +1,25 @@
import { Tag } from 'antd';
import React from 'react';
import { SemanticTypeEnum, SEMANTIC_TYPE_MAP } from '../../../common/type';
type Props = {
infoType?: SemanticTypeEnum;
};
const SemanticTypeTag: React.FC<Props> = ({ infoType = SemanticTypeEnum.METRIC }) => {
return (
<Tag
color={
infoType === SemanticTypeEnum.DIMENSION || infoType === SemanticTypeEnum.DOMAIN
? 'blue'
: infoType === SemanticTypeEnum.VALUE
? 'geekblue'
: 'orange'
}
>
{SEMANTIC_TYPE_MAP[infoType]}
</Tag>
);
};
export default SemanticTypeTag;

View File

@@ -0,0 +1,104 @@
import { Popover, message, Row, Col, Button, Spin } from 'antd';
import React, { useEffect, useState } from 'react';
import { SemanticTypeEnum } from '../../../common/type';
import { queryMetricInfo } from '../../../service';
import SemanticTypeTag from './SemanticTypeTag';
import { isMobile } from '../../../utils/utils';
import { CLS_PREFIX } from '../../../common/constants';
type Props = {
children: React.ReactNode;
classId?: number;
infoType?: SemanticTypeEnum;
uniqueId: string | number;
onDetailBtnClick?: (data: any) => void;
};
const SemanticInfoPopover: React.FC<Props> = ({
classId,
infoType,
uniqueId,
children,
onDetailBtnClick,
}) => {
const [semanticInfo, setSemanticInfo] = useState<any>(undefined);
const [popoverVisible, setPopoverVisible] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const prefixCls = `${CLS_PREFIX}-semantic-info-popover`;
const text = (
<Row>
<Col flex="1">
<SemanticTypeTag infoType={infoType} />
</Col>
{onDetailBtnClick && (
<Col flex="0 1 40px">
{semanticInfo && (
<Button
type="link"
size="small"
onClick={() => {
onDetailBtnClick(semanticInfo);
}}
>
</Button>
)}
</Col>
)}
</Row>
);
const content = loading ? (
<div className={`${prefixCls}-spin-box`}>
<Spin />
</div>
) : (
<div>
<span>{semanticInfo?.description || '暂无数据'}</span>
</div>
);
const getMetricInfo = async () => {
setLoading(true);
const { data: resData } = await queryMetricInfo({
classId,
uniqueId,
});
const { code, data, msg } = resData;
setLoading(false);
if (code === '0') {
setSemanticInfo({
...data,
semanticInfoType: SemanticTypeEnum.METRIC,
});
} else {
message.error(msg);
}
};
useEffect(() => {
if (popoverVisible && !semanticInfo) {
getMetricInfo();
}
}, [popoverVisible]);
return (
<Popover
placement="top"
title={text}
content={content}
trigger="hover"
open={classId && !isMobile ? undefined : false}
onOpenChange={visible => {
setPopoverVisible(visible);
}}
overlayClassName={prefixCls}
>
{children}
</Popover>
);
};
export default SemanticInfoPopover;

View File

@@ -0,0 +1,18 @@
@import '../../../styles/index.less';
@semantic-info-popover-cls: ~'@{supersonic-chat-prefix}-semantic-info-popover';
.semantic-info-popover-cls {
max-width: 300px;
&-spin-box {
text-align: center;
padding-top: 10px;
}
.ant-popover-title{
padding: 5px 8px 4px;
}
.ant-popover-inner-content {
min-height: 60px;
min-width: 185px;
}
}

View File

@@ -0,0 +1,72 @@
import { formatByDecimalPlaces, getFormattedValue } from '../../../utils/utils';
import { Table as AntTable } from 'antd';
import { MsgDataType } from '../../../common/type';
import { CLS_PREFIX } from '../../../common/constants';
import ApplyAuth from '../ApplyAuth';
type Props = {
data: MsgDataType;
onApplyAuth?: (domain: string) => void;
};
const Table: React.FC<Props> = ({ data, onApplyAuth }) => {
const { entityInfo, queryColumns, queryResults } = data;
const prefixCls = `${CLS_PREFIX}-table`;
const tableColumns: any[] = queryColumns.map(
({ name, nameEn, showType, dataFormatType, dataFormat, authorized }) => {
return {
dataIndex: nameEn,
key: nameEn,
title: name,
render: (value: string | number) => {
if (!authorized) {
return (
<ApplyAuth domain={entityInfo?.domainInfo.name || ''} onApplyAuth={onApplyAuth} />
);
}
if (dataFormatType === 'percent') {
return (
<div className={`${prefixCls}-formatted-value`}>
{`${formatByDecimalPlaces(
dataFormat?.needmultiply100 ? +value * 100 : value,
dataFormat?.decimalPlaces || 2
)}%`}
</div>
);
}
if (showType === 'NUMBER') {
return (
<div className={`${prefixCls}-formatted-value`}>
{getFormattedValue(value as number)}
</div>
);
}
if (nameEn.includes('photo')) {
return (
<div className={`${prefixCls}-photo`}>
<img width={40} height={40} src={value as string} alt="" />
</div>
);
}
return value;
},
};
}
);
return (
<div className={prefixCls}>
<AntTable
pagination={queryResults.length <= 10 ? false : undefined}
size={queryResults.length === 1 ? 'middle' : 'small'}
columns={tableColumns}
dataSource={queryResults}
style={{ width: '100%' }}
/>
</div>
);
};
export default Table;

View File

@@ -0,0 +1,72 @@
@import '../../../styles/index.less';
@table-prefix-cls: ~'@{supersonic-chat-prefix}-table';
.@{table-prefix-cls} {
margin-top: 20px;
margin-bottom: 20px;
&-photo {
display: flex;
align-items: center;
justify-content: center;
}
table {
width: 100%;
}
.ant-table-container table > thead > tr:first-child th:first-child {
border-top-left-radius: 12px !important;
border-bottom-left-radius: 12px !important;
}
.ant-table-container table > thead > tr:first-child th:last-child {
border-top-right-radius: 12px !important;
border-bottom-right-radius: 12px !important;
}
.ant-table-tbody > tr.ant-table-row:hover > td {
background-color: #fafafa !important;
}
.ant-table-cell {
text-align: center !important;
}
.ant-table-thead {
.ant-table-cell {
padding-top: 10px;
padding-bottom: 10px;
color: #666;
font-size: 13px;
background: #f0f2f5;
&::before {
display: none;
}
}
}
.@{table-prefix-cls}-formatted-value {
font-weight: 500;
font-size: 16px;
}
.ant-table-thead .ant-table-cell {
padding-top: 8.5px;
padding-bottom: 8.5px;
color: #737b7b;
font-weight: 500;
font-size: 14px;
background-color: #edf2f2;
}
.ant-table-tbody {
.ant-table-cell {
padding: 15px 0;
color: #333;
font-size: 14px;
}
}
}

View File

@@ -0,0 +1,62 @@
import { isMobile } from '../../utils/utils';
import Bar from './Bar';
import Message from './Message';
import MetricCard from './MetricCard';
import MetricTrend from './MetricTrend';
import Table from './Table';
import { MsgDataType } from '../../common/type';
type Props = {
data: MsgDataType;
onCheckMetricInfo?: (data: any) => void;
};
const ChatMsg: React.FC<Props> = ({ data, onCheckMetricInfo }) => {
const { aggregateType, queryColumns, queryResults, chatContext, entityInfo } = data;
if (!queryColumns || !queryResults) {
return null;
}
const singleData = queryResults.length === 1;
const dateField = queryColumns.find(item => item.showType === 'DATE' || item.type === 'DATE');
const categoryField = queryColumns.filter(item => item.showType === 'CATEGORY');
const metricFields = queryColumns.filter(item => item.showType === 'NUMBER');
const getMsgContent = () => {
if (categoryField.length > 1 || aggregateType === 'tag') {
return <Table data={data} />;
}
if (dateField && metricFields.length > 0) {
return <MetricTrend data={data} onCheckMetricInfo={onCheckMetricInfo} />;
}
if (singleData) {
return <MetricCard data={data} />;
}
return <Bar data={data} />;
};
let width = '100%';
if ((categoryField.length > 1 || aggregateType === 'tag') && !isMobile) {
if (queryColumns.length === 1) {
width = '600px';
} else if (queryColumns.length === 2) {
width = '1000px';
}
}
return (
<Message
position="left"
chatContext={chatContext}
entityInfo={entityInfo}
aggregator={aggregateType}
tip={''}
width={width}
>
{getMsgContent()}
</Message>
);
};
export default ChatMsg;

View File

@@ -0,0 +1,42 @@
import { CLS_PREFIX } from '../../common/constants';
import { Row, Col } from 'antd';
type Props = {
description: string;
basicInfoList: any[];
};
const BasicInfoSection: React.FC<Props> = ({ description = '', basicInfoList }) => {
const prefixCls = `${CLS_PREFIX}-semantic-detail`;
return (
<>
<div className={`${prefixCls}-info-bar`}>
<div className={`${prefixCls}-main-entity-info`}>
{basicInfoList.map(item => {
return (
<div className={`${prefixCls}-info-item`}>
<div className={`${prefixCls}-info-name`}>{item.name}</div>
<div className={`${prefixCls}-info-value`}>{item.value}</div>
</div>
);
})}
</div>
</div>
{description && (
<>
<Row>
<Col flex="0 0 52px">
<div className={`${prefixCls}-description`}> : </div>
</Col>
<Col flex="1 1 auto">
<div className={`${prefixCls}-description`}>{description}</div>
</Col>
</Row>
</>
)}
</>
);
};
export default BasicInfoSection;

View File

@@ -0,0 +1,97 @@
import { message, Row, Col } from 'antd';
import { isMobile } from '../../utils/utils';
import { ReloadOutlined } from '@ant-design/icons';
import React, { useEffect, useState } from 'react';
import { getRelatedDimensionFromStatInfo } from '../../service';
import { CLS_PREFIX } from '../../common/constants';
type Props = {
classId?: number;
uniqueId: string | number;
onSelect?: (value: string) => void;
};
const PAGE_SIZE = isMobile ? 3 : 10;
const DimensionSection: React.FC<Props> = ({ classId, uniqueId, onSelect }) => {
const [dimensions, setDimensions] = useState<string[]>([]);
const [dimensionIndex, setDimensionIndex] = useState(0);
const prefixCls = `${CLS_PREFIX}-semantic-detail`;
const queryDimensionList = async () => {
const { data: resData } = await getRelatedDimensionFromStatInfo({
classId,
uniqueId,
});
const { code, data, msg } = resData;
if (code === '0') {
setDimensions(
data.map(item => {
return item.name;
})
);
} else {
message.error(msg);
}
};
useEffect(() => {
queryDimensionList();
}, []);
const reloadDimensionCmds = () => {
const dimensionPageCount = Math.ceil(dimensions.length / PAGE_SIZE);
setDimensionIndex((dimensionIndex + 1) % dimensionPageCount);
};
const dimensionList = dimensions.slice(
dimensionIndex * PAGE_SIZE,
(dimensionIndex + 1) * PAGE_SIZE
);
return (
<>
{dimensionList.length > 0 && (
<div className={`${prefixCls}-content-section`}>
<Row>
<Col flex="0 0 80px">
<div className={`${prefixCls}-label`}> </div>
</Col>
<Col flex="1 1" className={`${prefixCls}-content-col`}>
<div className={`${prefixCls}-content-col-box`}>
{dimensionList.map((dimension, index) => {
return (
<>
<span
className={`${prefixCls}-section-item`}
onClick={() => {
onSelect?.(dimension);
}}
>
{dimension}
</span>
{index < dimensionList.length - 1 && '、'}
</>
);
})}
</div>
</Col>
<Col flex="0 1 50px">
<div
className={`${prefixCls}-reload`}
onClick={() => {
reloadDimensionCmds();
}}
>
<ReloadOutlined className={`${prefixCls}-reload-icon`} />
{!isMobile && <div className={`${prefixCls}-reload-label`}></div>}
</div>
</Col>
</Row>
</div>
)}
</>
);
};
export default DimensionSection;

View File

@@ -0,0 +1,112 @@
import { useEffect, useState } from 'react';
import { getMetricQueryInfo } from '../../service';
import { message, Row, Col } from 'antd';
import { CLS_PREFIX } from '../../common/constants';
type Props = {
classId: number;
metricName: string;
onSelect?: (value: string) => void;
};
const RecommendQuestions: React.FC<Props> = ({ classId, metricName, onSelect }) => {
const [moreMode, setMoreMode] = useState<boolean>(false);
const [questionData, setQuestionData] = useState<any[]>([]);
const prefixCls = `${CLS_PREFIX}-semantic-detail`;
const queryMetricQueryInfo = async () => {
const { data: resData } = await getMetricQueryInfo({
classId,
metricName,
});
const { code, data, msg } = resData;
if (code === '0') {
setQuestionData(data);
} else {
message.error(msg);
}
};
useEffect(() => {
queryMetricQueryInfo();
}, []);
return (
<div className={`${prefixCls}-recommend-questions`}>
<div className={`${prefixCls}-header`}>
<Row>
<Col flex="0 0 85px">
<div className={`${prefixCls}-label`}> : </div>
</Col>
<Col flex="1 1" className={`${prefixCls}-content-col`}>
{!moreMode && (
<div className={`${prefixCls}-content-col-box`}>
{questionData.slice(0, 5).map((item, index) => {
const { question } = item;
return (
<>
{index !== 0 && '、'}
<span
key={question}
className={`${prefixCls}-question`}
onClick={() => {
onSelect?.(question);
}}
>
<span dangerouslySetInnerHTML={{ __html: question }} />
</span>
</>
);
})}
</div>
)}
</Col>
<Col flex="0 1 30px">
{!moreMode ? (
<span
onClick={() => {
setMoreMode(true);
}}
className={`${prefixCls}-more`}
>
</span>
) : (
<span
className={`${prefixCls}-more`}
onClick={() => {
setMoreMode(false);
}}
>
</span>
)}
</Col>
</Row>
</div>
{moreMode && (
<div className={`${prefixCls}-recommend-questions-content`}>
<div className={`${prefixCls}-questions`}>
{questionData.map(item => {
const { question } = item;
return (
<div
key={question}
className={`${prefixCls}-question`}
onClick={() => {
onSelect?.(question);
}}
>
<span dangerouslySetInnerHTML={{ __html: question }} />
</div>
);
})}
</div>
</div>
)}
</div>
);
};
export default RecommendQuestions;

View File

@@ -0,0 +1,53 @@
import Message from '../ChatMsg/Message';
import { Space, Row, Col, Divider } from 'antd';
import BasicInfoSection from './BasicInfoSection';
import DimensionSection from './DimensionSection';
import RecommendSection from './RecommendSection';
import SemanticTypeTag from '../ChatMsg/SemanticInfoPopover/SemanticTypeTag';
import { CLS_PREFIX } from '../../common/constants';
type Props = {
dataSource?: any;
onDimensionSelect?: (value: any) => void;
};
const SemanticDetail: React.FC<Props> = ({ dataSource, onDimensionSelect }) => {
const { name, nameEn, createdBy, description, className, classId, semanticInfoType } = dataSource;
const semanticDetailCls = `${CLS_PREFIX}-semantic-detail`;
return (
<Message position="left" width="100%" noTime>
<div>
<div>
<Row>
<Col flex="1">
<Space size={20}>
<span className={`${semanticDetailCls}-title`}>{`指标详情: ${name}`}</span>
</Space>
</Col>
<Col flex="0 1 40px">
<SemanticTypeTag infoType={semanticInfoType} />
</Col>
</Row>
</div>
<BasicInfoSection
description={description}
basicInfoList={[
{
name: '主题域',
value: className,
},
{ name: '创建人', value: createdBy },
]}
/>
<Divider style={{ margin: '12px 0 16px 0px' }} />
<DimensionSection classId={classId} uniqueId={nameEn} onSelect={onDimensionSelect} />
<Divider style={{ margin: '6px 0 12px 0px' }} />
<RecommendSection classId={classId} metricName={name} onSelect={onDimensionSelect} />
</div>
</Message>
);
};
export default SemanticDetail;

View File

@@ -0,0 +1,129 @@
@import '../../styles/variables.less';
@semantic-detail-cls: ~'@{supersonic-chat-prefix}-semantic-detail';
.@{semantic-detail-cls} {
&-info-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
margin: 20px 0;
column-gap: 20px;
}
&-description {
font-size: 13px;
color: var(--text-color-fourth);
}
&-main-entity-info {
display: flex;
flex-wrap: wrap;
align-items: center;
font-size: 13px;
column-gap: 20px;
}
&-info-item {
display: flex;
align-items: center;
}
&-info-name {
color: var(--text-color-fourth);
}
&-info-value {
color: var(--text-color-secondary);
}
&-title {
font-size: 16px;
margin-left: 15px;
line-height: 30px;
&::before {
display: block;
position: absolute;
content: "";
left: 0;
top: 6px;
height: 15px;
width: 3px;
font-size: 0;
background: #0e73ff;
border-radius: 2px;
border: 1px solid #0e73ff;
}
}
&-label {
font-size: 14px;
color: var(--text-color-fourth);
}
&-section-item {
cursor: pointer;
&:hover {
color: var(--chat-blue);
}
}
&-reload {
display: flex;
align-items: center;
color: var(--text-color-fourth);
font-size: 12px;
column-gap: 4px;
cursor: pointer;
position: relative;
top: 3px;
&:hover {
color: var(--chat-blue);
}
}
&-reload-icon {
font-size: 10px;
position: relative;
}
&-header {
font-size: 14px;
color: var(--text-color-fourth);
margin-bottom: 5px;
}
&-more {
cursor: pointer;
font-size: 12px;
&:hover {
color: var(--chat-blue);
}
}
&-recommend-questions-content {
height: 300px;
overflow: auto;
}
&-question {
cursor: pointer;
color: rgba(0, 0, 0, 0.87);
&:hover {
color: var(--chat-blue);
}
}
&-content-col {
min-width: 300px;
overflow-y: hidden;
height: 32px;
overflow-x: scroll;
}
&-content-col-box{
width: max-content;
}
}

View File

@@ -0,0 +1,162 @@
import { isMobile } from '../../utils/utils';
import { ReloadOutlined } from '@ant-design/icons';
import classNames from 'classnames';
import { useEffect, useState } from 'react';
import { EntityInfoType } from '../../common/type';
import Message from '../ChatMsg/Message';
import { CLS_PREFIX } from '../../common/constants';
type Props = {
currentMsgAggregator?: string;
columns: any[];
mainEntity: EntityInfoType;
suggestions: any;
onSelect?: (value: string) => void;
};
const PAGE_SIZE = isMobile ? 3 : 5;
const Suggestion: React.FC<Props> = ({
currentMsgAggregator,
columns,
mainEntity,
suggestions,
onSelect,
}) => {
const [dimensions, setDimensions] = useState<string[]>([]);
const [metrics, setMetrics] = useState<string[]>([]);
const [dimensionIndex, setDimensionIndex] = useState(0);
const [metricIndex, setMetricIndex] = useState(0);
const fields = columns
.filter(column => currentMsgAggregator !== 'tag' || column.showType !== 'NUMBER')
.concat(isMobile ? [] : mainEntity?.dimensions || [])
.map(item => item.name);
useEffect(() => {
setDimensions(
suggestions.dimensions
.filter((dimension: any) => !fields.some(field => field === dimension.name))
.map((item: any) => item.name)
);
setMetrics(
suggestions.metrics
.filter((metric: any) => !fields.some(field => field === metric.name))
.map((item: any) => item.name)
);
}, []);
const reloadDimensionCmds = () => {
const dimensionPageCount = Math.ceil(dimensions.length / PAGE_SIZE);
setDimensionIndex((dimensionIndex + 1) % dimensionPageCount);
};
const reloadMetricCmds = () => {
const metricPageCount = Math.ceil(metrics.length / PAGE_SIZE);
setMetricIndex((metricIndex + 1) % metricPageCount);
};
const dimensionList = dimensions.slice(
dimensionIndex * PAGE_SIZE,
(dimensionIndex + 1) * PAGE_SIZE
);
const metricList = metrics.slice(metricIndex * PAGE_SIZE, (metricIndex + 1) * PAGE_SIZE);
if (!dimensionList.length && !metricList.length) {
return null;
}
const prefixCls = `${CLS_PREFIX}-suggestion`;
const suggestionClass = classNames(prefixCls, {
[`${prefixCls}-mobile`]: isMobile,
});
const sectionItemClass = classNames({
[`${prefixCls}-section-item-selectable`]: onSelect !== undefined,
});
return (
<div className={suggestionClass}>
<Message position="left" width="fit-content" noWaterMark>
<div className={`${prefixCls}-tip`}></div>
{metricList.length > 0 && (
<div className={`${prefixCls}-content-section`}>
<div className={`${prefixCls}-title`}></div>
<div className={`${prefixCls}-section-items`}>
{metricList.map((metric, index) => {
let metricNode = (
<div
className={sectionItemClass}
onClick={() => {
if (onSelect) {
onSelect(metric);
}
}}
>
{metric}
</div>
);
return (
<>
{metricNode}
{index < metricList.length - 1 && '、'}
</>
);
})}
</div>
{metrics.length > PAGE_SIZE && (
<div
className={`${prefixCls}-reload`}
onClick={() => {
reloadMetricCmds();
}}
>
<ReloadOutlined className={`${prefixCls}-reload-icon`} />
{!isMobile && <div className={`${prefixCls}-reload-label`}></div>}
</div>
)}
</div>
)}
{dimensionList.length > 0 && (
<div className={`${prefixCls}-content-section`}>
<div className={`${prefixCls}-title`}></div>
<div className={`${prefixCls}-section-items`}>
{dimensionList.map((dimension, index) => {
return (
<>
<div
className={sectionItemClass}
onClick={() => {
if (onSelect) {
onSelect(dimension);
}
}}
>
{dimension}
</div>
{index < dimensionList.length - 1 && '、'}
</>
);
})}
</div>
{dimensions.length > PAGE_SIZE && (
<div
className={`${prefixCls}-reload`}
onClick={() => {
reloadDimensionCmds();
}}
>
<ReloadOutlined className={`${prefixCls}-reload-icon`} />
{!isMobile && <div className={`${prefixCls}-reload-label`}></div>}
</div>
)}
</div>
)}
</Message>
</div>
);
};
export default Suggestion;

View File

@@ -0,0 +1,59 @@
@import '../../styles/index.less';
@suggestion-prefix-cls: ~'@{supersonic-chat-prefix}-suggestion';
.@{suggestion-prefix-cls} {
margin-top: 30px;
.@{suggestion-prefix-cls}-mobile {
margin-top: 12px;
font-size: 13px;
}
&-tip {
margin-bottom: 12px;
}
&-content-section {
display: flex;
align-items: center;
margin-bottom: 10px;
row-gap: 12px;
}
&-title {
color: var(--text-color-fourth);
}
&-section-items {
display: flex;
flex-wrap: wrap;
align-items: center;
}
&-section-item-selectable {
cursor: pointer;
&:hover {
color: var(--chat-blue);
}
}
&-reload {
display: flex;
align-items: center;
margin-right: 14px;
margin-left: 20px;
color: var(--text-color-fourth);
font-size: 12px;
column-gap: 4px;
cursor: pointer;
&:hover {
color: var(--chat-blue);
}
}
&-reload-icon {
font-size: 10px;
}
}

View File

@@ -0,0 +1,70 @@
import { isMobile } from '../../utils/utils';
import { DislikeOutlined, LikeOutlined } from '@ant-design/icons';
import { Button, message } from 'antd';
import { CLS_PREFIX } from '../../common/constants';
type Props = {
isLastMessage?: boolean;
};
const Tools: React.FC<Props> = ({ isLastMessage }) => {
const prefixCls = `${CLS_PREFIX}-tools`;
const changeChart = () => {
message.info('正在开发中,敬请期待');
};
const addToDashboard = () => {
message.info('正在开发中,敬请期待');
};
const lockDomain = () => {
message.info('正在开发中,敬请期待');
};
const like = () => {
message.info('正在开发中,敬请期待');
};
const dislike = () => {
message.info('正在开发中,敬请期待');
};
const lockDomainSection = isLastMessage && (
<Button shape="round" onClick={lockDomain}>
</Button>
);
const feedbackSection = isLastMessage && (
<div className={`${prefixCls}-feedback`}>
<div></div>
<LikeOutlined className={`${prefixCls}-like`} onClick={like} />
<DislikeOutlined className={`${prefixCls}-dislike`} onClick={dislike} />
</div>
);
if (isMobile) {
return (
<div className={`${prefixCls}-mobile-tools`}>
{isLastMessage && <div className={`${prefixCls}-tools`}>{lockDomainSection}</div>}
{feedbackSection}
</div>
);
}
return (
<div className={prefixCls}>
<Button shape="round" onClick={changeChart}>
</Button>
<Button shape="round" onClick={addToDashboard}>
</Button>
{lockDomainSection}
{feedbackSection}
</div>
);
};
export default Tools;

View File

@@ -0,0 +1,37 @@
@import '../../styles/index.less';
@tools-cls: ~'@{supersonic-chat-prefix}-tools';
.@{tools-cls} {
display: flex;
align-items: center;
margin-top: 12px;
column-gap: 6px;
&-feedback {
display: flex;
align-items: center;
margin-left: 4px;
color: var(--text-color-third);
column-gap: 6px;
}
&-like {
margin-right: 4px;
}
&-mobile-tools {
display: flex;
flex-direction: column;
margin-top: 12px;
row-gap: 10px;
}
&-tools {
margin-top: 0;
}
&-feedback {
margin-left: 2px;
}
}