mirror of
https://github.com/tencentmusic/supersonic.git
synced 2026-04-21 22:34:28 +08:00
first commit
This commit is contained in:
50
webapp/packages/chat-sdk/src/common/constants.ts
Normal file
50
webapp/packages/chat-sdk/src/common/constants.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { MsgValidTypeEnum } from './type';
|
||||
|
||||
export enum NumericUnit {
|
||||
None = '无',
|
||||
TenThousand = '万',
|
||||
EnTenThousand = 'w',
|
||||
OneHundredMillion = '亿',
|
||||
Thousand = 'k',
|
||||
Million = 'M',
|
||||
Giga = 'G',
|
||||
}
|
||||
|
||||
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 const CLS_PREFIX = 'ss-chat';
|
||||
|
||||
export const DATE_TYPES = {
|
||||
DAY: [{ label: '近7天', value: 7 }, { label: '近30天', value: 30 }, { label: '近60天', value: 60 }, { label: '近90天', value: 90 }],
|
||||
WEEK: [{ label: '近4周', value: 4 }, { label: '近12周', value: 12 }, { label: '近24周', value: 24 }, { label: '近52周', value: 52 }],
|
||||
MONTH: [{ label: '近3个月', value: 3 }, { label: '近6个月', value: 6 }, { label: '近12个月', value: 12 }, { label: '近24个月', value: 24 }],
|
||||
};
|
||||
|
||||
export const THEME_COLOR_LIST = [
|
||||
'#3369FF',
|
||||
'#36D2B8',
|
||||
'#DB8D76',
|
||||
'#47B359',
|
||||
'#8545E6',
|
||||
'#E0B18B',
|
||||
'#7258F3',
|
||||
'#0095FF',
|
||||
'#52CC8F',
|
||||
'#6675FF',
|
||||
'#CC516E',
|
||||
'#5CA9E6',
|
||||
];
|
||||
|
||||
export const PARSE_ERROR_TIP = '小Q不太懂您说什么呐,回去一定补充知识';
|
||||
|
||||
export const MSG_VALID_TIP = {
|
||||
[MsgValidTypeEnum.SEARCH_EXCEPTION]: '数据查询异常',
|
||||
[MsgValidTypeEnum.INVALID]: '小Q不太懂您说什么呐,回去一定补充知识',
|
||||
};
|
||||
|
||||
export const PREFIX_CLS = 'ss-chat';
|
||||
148
webapp/packages/chat-sdk/src/common/type.ts
Normal file
148
webapp/packages/chat-sdk/src/common/type.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
export type SearchRecommendItem = {
|
||||
complete: boolean;
|
||||
domainId: number;
|
||||
domainName: string;
|
||||
recommend: string;
|
||||
subRecommend: string;
|
||||
schemaElementType: string;
|
||||
};
|
||||
|
||||
export type FieldType = {
|
||||
bizName: string;
|
||||
id: number;
|
||||
name: string;
|
||||
status: number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type DomainInfoType = {
|
||||
bizName: string;
|
||||
itemId: number;
|
||||
name: string;
|
||||
primaryEntityBizName: string;
|
||||
value: string;
|
||||
words: string[];
|
||||
};
|
||||
|
||||
export type EntityInfoType = {
|
||||
domainInfo: DomainInfoType;
|
||||
dimensions: FieldType[];
|
||||
metrics: FieldType[];
|
||||
entityId: number;
|
||||
};
|
||||
|
||||
export type DateInfoType = {
|
||||
dateList: any[];
|
||||
dateMode: number;
|
||||
period: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
text: string;
|
||||
unit: number;
|
||||
};
|
||||
|
||||
export type FilterItemType = {
|
||||
elementID: number;
|
||||
name: string;
|
||||
operator: string;
|
||||
type: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ChatContextType = {
|
||||
aggType: string;
|
||||
domainId: number;
|
||||
domainName: string;
|
||||
dateInfo: DateInfoType;
|
||||
dimensions: FieldType[];
|
||||
metrics: FieldType[];
|
||||
entity: number;
|
||||
filters: FilterItemType[];
|
||||
};
|
||||
|
||||
export enum MsgValidTypeEnum {
|
||||
NORMAL = 0,
|
||||
SEARCH_EXCEPTION = 1,
|
||||
EMPTY = 2,
|
||||
INVALID = 3,
|
||||
};
|
||||
|
||||
export type MsgDataType = {
|
||||
id: number;
|
||||
question: string;
|
||||
aggregateType: string;
|
||||
appletResponse: string;
|
||||
chatContext: ChatContextType;
|
||||
entityInfo: EntityInfoType;
|
||||
queryAuthorization: any;
|
||||
queryColumns: ColumnType[];
|
||||
queryResults: any[];
|
||||
queryId: number;
|
||||
queryMode: string;
|
||||
queryState: MsgValidTypeEnum;
|
||||
};
|
||||
|
||||
export type QueryDataType = {
|
||||
queryColumns: ColumnType[];
|
||||
queryResults: any[];
|
||||
};
|
||||
|
||||
export type ColumnType = {
|
||||
authorized: boolean;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
showType: string;
|
||||
type: string;
|
||||
dataFormatType: string;
|
||||
dataFormat: {
|
||||
decimalPlaces: number;
|
||||
needmultiply100: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export enum SemanticTypeEnum {
|
||||
DOMAIN = 'DOMAIN',
|
||||
DIMENSION = 'DIMENSION',
|
||||
METRIC = 'METRIC',
|
||||
VALUE = 'VALUE',
|
||||
};
|
||||
|
||||
export const SEMANTIC_TYPE_MAP = {
|
||||
[SemanticTypeEnum.DOMAIN]: '主题域',
|
||||
[SemanticTypeEnum.DIMENSION]: '维度',
|
||||
[SemanticTypeEnum.METRIC]: '指标',
|
||||
[SemanticTypeEnum.VALUE]: '维度值',
|
||||
};
|
||||
|
||||
export type SuggestionItemType = {
|
||||
domain: number;
|
||||
name: string;
|
||||
bizName: string
|
||||
};
|
||||
|
||||
export type SuggestionType = {
|
||||
dimensions: SuggestionItemType[];
|
||||
metrics: SuggestionItemType[];
|
||||
};
|
||||
|
||||
export type SuggestionDataType = {
|
||||
currentAggregateType: string,
|
||||
columns: ColumnType[],
|
||||
mainEntity: EntityInfoType,
|
||||
suggestions: SuggestionType,
|
||||
};
|
||||
|
||||
export type HistoryMsgItemType = {
|
||||
questionId: number;
|
||||
queryText: string;
|
||||
queryResponse: MsgDataType;
|
||||
chatId: number;
|
||||
createTime: string;
|
||||
feedback: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
export type HistoryType = {
|
||||
hasNextPage: boolean;
|
||||
list: HistoryMsgItemType[];
|
||||
};
|
||||
17
webapp/packages/chat-sdk/src/components/ChatItem/Text.tsx
Normal file
17
webapp/packages/chat-sdk/src/components/ChatItem/Text.tsx
Normal 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;
|
||||
19
webapp/packages/chat-sdk/src/components/ChatItem/Typing.tsx
Normal file
19
webapp/packages/chat-sdk/src/components/ChatItem/Typing.tsx
Normal 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;
|
||||
154
webapp/packages/chat-sdk/src/components/ChatItem/index.tsx
Normal file
154
webapp/packages/chat-sdk/src/components/ChatItem/index.tsx
Normal 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;
|
||||
38
webapp/packages/chat-sdk/src/components/ChatItem/style.less
Normal file
38
webapp/packages/chat-sdk/src/components/ChatItem/style.less
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
149
webapp/packages/chat-sdk/src/components/ChatMsg/Bar/index.tsx
Normal file
149
webapp/packages/chat-sdk/src/components/ChatMsg/Bar/index.tsx
Normal 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;
|
||||
@@ -0,0 +1,8 @@
|
||||
@import '../../../styles/index.less';
|
||||
|
||||
@bar-cls: ~'@{supersonic-chat-prefix}-bar';
|
||||
|
||||
.@{bar-cls} {
|
||||
height: 300px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
62
webapp/packages/chat-sdk/src/components/ChatMsg/index.tsx
Normal file
62
webapp/packages/chat-sdk/src/components/ChatMsg/index.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
162
webapp/packages/chat-sdk/src/components/Suggestion/index.tsx
Normal file
162
webapp/packages/chat-sdk/src/components/Suggestion/index.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
70
webapp/packages/chat-sdk/src/components/Tools/index.tsx
Normal file
70
webapp/packages/chat-sdk/src/components/Tools/index.tsx
Normal 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;
|
||||
37
webapp/packages/chat-sdk/src/components/Tools/style.less
Normal file
37
webapp/packages/chat-sdk/src/components/Tools/style.less
Normal 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;
|
||||
}
|
||||
}
|
||||
41
webapp/packages/chat-sdk/src/demo/Chat.tsx
Normal file
41
webapp/packages/chat-sdk/src/demo/Chat.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Input } from 'antd';
|
||||
import styles from './style.module.less';
|
||||
import { useState } from 'react';
|
||||
import ChatItem from '../components/ChatItem';
|
||||
import { queryContext, searchRecommend } from '../service';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
const Chat = () => {
|
||||
const [inputMsg, setInputMsg] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = e.target;
|
||||
setInputMsg(value);
|
||||
searchRecommend(value);
|
||||
};
|
||||
|
||||
const onSearch = () => {
|
||||
setMsg(inputMsg);
|
||||
queryContext(inputMsg);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.inputMsg}>
|
||||
<Search
|
||||
placeholder="请输入问题"
|
||||
value={inputMsg}
|
||||
onChange={onInputChange}
|
||||
onSearch={onSearch}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.chatItem}>
|
||||
<ChatItem msg={msg} suggestionEnable isLastMessage />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Chat;
|
||||
11
webapp/packages/chat-sdk/src/demo/style.module.less
Normal file
11
webapp/packages/chat-sdk/src/demo/style.module.less
Normal file
@@ -0,0 +1,11 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 20px;
|
||||
padding: 30px;
|
||||
background:
|
||||
linear-gradient(180deg,rgba(23,74,228,0) 29.44%,rgba(23,74,228,.06)),linear-gradient(90deg,#f3f3f7,#f3f3f7 20%,#ebf0f9 60%,#f3f3f7 80%,#f3f3f7);
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
39
webapp/packages/chat-sdk/src/index.tsx
Normal file
39
webapp/packages/chat-sdk/src/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import './styles/index.less';
|
||||
|
||||
// import React from 'react';
|
||||
// import ReactDOM from 'react-dom/client';
|
||||
// import Chat from './demo/Chat';
|
||||
|
||||
// const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
|
||||
|
||||
// root.render(
|
||||
// <React.StrictMode>
|
||||
// <Chat />
|
||||
// </React.StrictMode>
|
||||
// );
|
||||
|
||||
export { default as ChatMsg } from './components/ChatMsg';
|
||||
|
||||
export { default as ChatItem } from './components/ChatItem';
|
||||
|
||||
export type {
|
||||
SearchRecommendItem,
|
||||
FieldType,
|
||||
DomainInfoType,
|
||||
EntityInfoType,
|
||||
DateInfoType,
|
||||
ChatContextType,
|
||||
MsgValidTypeEnum,
|
||||
MsgDataType,
|
||||
ColumnType,
|
||||
SuggestionItemType,
|
||||
SuggestionType,
|
||||
SuggestionDataType,
|
||||
FilterItemType,
|
||||
HistoryType,
|
||||
HistoryMsgItemType,
|
||||
} from './common/type';
|
||||
|
||||
export { getHistoryMsg, searchRecommend, queryContext } from './service';
|
||||
|
||||
export { setToken } from './utils/utils';
|
||||
51
webapp/packages/chat-sdk/src/service/axiosInstance.ts
Normal file
51
webapp/packages/chat-sdk/src/service/axiosInstance.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// 引入axios库
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { getToken } from '../utils/utils';
|
||||
|
||||
// 创建axios实例
|
||||
const axiosInstance: AxiosInstance = axios.create({
|
||||
// 设置基本URL,所有请求都会使用这个URL作为前缀
|
||||
baseURL: '',
|
||||
// 设置请求超时时间(毫秒)
|
||||
timeout: 30000,
|
||||
// 设置请求头
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config: any) => {
|
||||
const token = getToken();
|
||||
if (token && config?.headers) {
|
||||
config.headers.auth = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
// 请求错误时的处理
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response: any) => {
|
||||
if (Number(response.data.code) === 403) {
|
||||
window.location.href = '/#/login';
|
||||
return response;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
// 对响应错误进行处理
|
||||
if (error.response && error.response.status === 401) {
|
||||
// 如果响应状态码为401,表示未授权,可以在这里处理重新登录等操作
|
||||
console.log('Unauthorized, please log in again.');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default axiosInstance;
|
||||
70
webapp/packages/chat-sdk/src/service/index.ts
Normal file
70
webapp/packages/chat-sdk/src/service/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import axios from './axiosInstance';
|
||||
import { ChatContextType, HistoryType, MsgDataType, SearchRecommendItem } from '../common/type';
|
||||
import { QueryDataType } from '../common/type';
|
||||
|
||||
const DEFAULT_CHAT_ID = 0;
|
||||
|
||||
const prefix = '/api';
|
||||
|
||||
export function searchRecommend(queryText: string, chatId?: number, domainId?: number) {
|
||||
return axios.post<Result<SearchRecommendItem[]>>(`${prefix}/chat/query/search`, {
|
||||
queryText,
|
||||
chatId: chatId || DEFAULT_CHAT_ID,
|
||||
domainId,
|
||||
});
|
||||
}
|
||||
|
||||
export function chatQuery(queryText: string, chatId?: number, domainId?: number, isSaveQuestionAnswer?: boolean) {
|
||||
return axios.post<Result<MsgDataType>>(`${prefix}/chat/query/query`, {
|
||||
queryText,
|
||||
chatId: chatId || DEFAULT_CHAT_ID,
|
||||
domainId,
|
||||
isSaveQuestionAnswer
|
||||
});
|
||||
}
|
||||
|
||||
export function queryData(chatContext: ChatContextType) {
|
||||
return axios.post<Result<QueryDataType>>(`${prefix}/chat/query/queryData`, chatContext);
|
||||
}
|
||||
|
||||
export function queryContext(queryText: string, chatId?: number) {
|
||||
return axios.post<Result<ChatContextType>>(`${prefix}/chat/query/queryContext`, {
|
||||
queryText,
|
||||
chatId: chatId || DEFAULT_CHAT_ID,
|
||||
});
|
||||
}
|
||||
|
||||
export function querySuggestionInfo(domainId: number) {
|
||||
return axios.get<Result<any>>(`${prefix}/chat/recommend/${domainId}`);
|
||||
}
|
||||
|
||||
export function getHistoryMsg(current: number, chatId: number = DEFAULT_CHAT_ID, pageSize: number = 10) {
|
||||
return axios.post<Result<HistoryType>>(`${prefix}/chat/manage/pageQueryInfo?chatId=${chatId}`, {
|
||||
current,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
export function queryMetricInfo(data: any) {
|
||||
return axios.get(`/semantic/metric/getMetric/${data.classId}/${data.uniqueId}`);
|
||||
}
|
||||
|
||||
export function getRelatedDimensionFromStatInfo(data: any) {
|
||||
return axios.get(
|
||||
`/semantic/metric/getRelatedDimensionFromStatInfo/${data.classId}/${data.uniqueId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getMetricQueryInfo(data: any) {
|
||||
return axios.get<any>(
|
||||
`getMetricQueryInfo/${data.classId}/${data.metricName}`
|
||||
);
|
||||
}
|
||||
|
||||
export function saveConversation(chatName: string) {
|
||||
return axios.post<Result<any>>(`${prefix}/chat/manage/save?chatName=${chatName}`);
|
||||
}
|
||||
|
||||
export function getAllConversations() {
|
||||
return axios.get<Result<any>>(`${prefix}/chat/manage/getAll`);
|
||||
}
|
||||
19
webapp/packages/chat-sdk/src/setupProxy.js
Normal file
19
webapp/packages/chat-sdk/src/setupProxy.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
|
||||
module.exports = function(app) {
|
||||
// app.use(
|
||||
// '/api',
|
||||
// createProxyMiddleware({
|
||||
// target: 'http://10.91.206.71:9079',
|
||||
// changeOrigin: true,
|
||||
// })
|
||||
// );
|
||||
app.use(
|
||||
'/api',
|
||||
// '/api',
|
||||
createProxyMiddleware({
|
||||
target: 'http://supersonic.test.tmeoa.com',
|
||||
changeOrigin: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
5
webapp/packages/chat-sdk/src/setupTests.ts
Normal file
5
webapp/packages/chat-sdk/src/setupTests.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
50
webapp/packages/chat-sdk/src/styles/global.less
Normal file
50
webapp/packages/chat-sdk/src/styles/global.less
Normal file
@@ -0,0 +1,50 @@
|
||||
@import './index.less';
|
||||
|
||||
@prefix-cls: ~'@{supersonic-chat-prefix}';
|
||||
|
||||
.@{prefix-cls} {
|
||||
&-dimension,
|
||||
&-metric {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
right: 0.5px;
|
||||
bottom: -2px;
|
||||
left: 0.5px;
|
||||
height: 2px;
|
||||
margin: 0 1px;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
&-dimension {
|
||||
&::after {
|
||||
background: var(--chat-blue);
|
||||
}
|
||||
}
|
||||
|
||||
&-metric {
|
||||
&::after {
|
||||
background: #31c462;
|
||||
}
|
||||
}
|
||||
|
||||
&-table-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&-even-row {
|
||||
background-color: #fbfbfb;
|
||||
}
|
||||
|
||||
&-no-border-table {
|
||||
.ant-table-cell {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr.ant-table-row:hover > td {
|
||||
background-color: #efefef !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
webapp/packages/chat-sdk/src/styles/index.less
Normal file
29
webapp/packages/chat-sdk/src/styles/index.less
Normal file
@@ -0,0 +1,29 @@
|
||||
@import './reboot.less';
|
||||
|
||||
@import './variables.less';
|
||||
|
||||
@import './global.less';
|
||||
|
||||
@import '../components/ChatMsg/Bar/style.less';
|
||||
|
||||
@import '../components/ChatMsg/Table/style.less';
|
||||
|
||||
@import '../components/ChatMsg/Message/style.less';
|
||||
|
||||
@import '../components/ChatMsg/MetricCard/style.less';
|
||||
|
||||
@import "../components/ChatMsg/MetricTrend/style.less";
|
||||
|
||||
@import "../components/ChatMsg/ApplyAuth/style.less";
|
||||
|
||||
@import "../components/ChatMsg/NoPermissionChart/style.less";
|
||||
|
||||
@import "../components/ChatMsg/SemanticInfoPopover/style.less";
|
||||
|
||||
@import "../components/SemanticDetail/style.less";
|
||||
|
||||
@import '../components/ChatItem/style.less';
|
||||
|
||||
@import "../components/Tools/style.less";
|
||||
|
||||
@import "../components/Suggestion/style.less";
|
||||
14
webapp/packages/chat-sdk/src/styles/reboot.less
Normal file
14
webapp/packages/chat-sdk/src/styles/reboot.less
Normal file
@@ -0,0 +1,14 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
79
webapp/packages/chat-sdk/src/styles/variables.less
Normal file
79
webapp/packages/chat-sdk/src/styles/variables.less
Normal file
@@ -0,0 +1,79 @@
|
||||
@supersonic-chat-prefix: ~'ss-chat';
|
||||
|
||||
:root:root {
|
||||
--primary: 180deg 4%;
|
||||
--primary-color: #f87653;
|
||||
--blue: #296df3;
|
||||
--deep-blue: #446dff;
|
||||
--chat-blue: #1b4aef;
|
||||
--wy-color: #c20c0c;
|
||||
--detail-width: 1300px;
|
||||
--primary-1: #fff0f0;
|
||||
--primary-2: #ffc7c7;
|
||||
--primary-3: #ff9ea1;
|
||||
--primary-4: #ff757e;
|
||||
--primary-5: #ff4d5e;
|
||||
--primary-6: #ff2441;
|
||||
--primary-7: #d91434;
|
||||
--primary-8: rgba(255, 36, 66, 0.1);
|
||||
--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;
|
||||
--info-color: #ff2442;
|
||||
--success-color: #52c41a;
|
||||
--processing-color: #ff2442;
|
||||
--error-color: #ff4d4f;
|
||||
--highlight-color: #ff4d4f;
|
||||
--newrank-color: #ff7800;
|
||||
--warning-color: #faad14;
|
||||
--normal-color: #d9d9d9;
|
||||
--white: #fff;
|
||||
--white-30: hsla(0, 0%, 100%, 0.3);
|
||||
--black: #000;
|
||||
--disabled-color: #afb6b6;
|
||||
--disabled-bg: #eceeee;
|
||||
--border-color-base: #e1e6e6;
|
||||
--chat-border-color-base: #d5d7db;
|
||||
--light-blue-background: rgba(58, 100, 255, 0.1);
|
||||
--link-color: #3a64ff;
|
||||
--link-hover-color: #638aff;
|
||||
--link-active-color: #2748d9;
|
||||
--link-bg-color: rgba(58, 100, 255, 0.1);
|
||||
--text-accent-color: #3a64ff;
|
||||
--primary-green: #00b354;
|
||||
--link-hover-bg-color: rgba(58, 100, 255, 0.06);
|
||||
--success-2: rgba(82, 196, 26, 0.2);
|
||||
--success-pink: #ff8193;
|
||||
--disabled-bg-3: hsla(180, 6%, 93%, 0.3);
|
||||
--tooltip-bg: #fff;
|
||||
--record-btn: #00b354;
|
||||
--record-btn-bg: rgba(0, 179, 84, 0.1);
|
||||
--record-btn-bg-3: rgba(0, 179, 84, 0.3);
|
||||
--border-color-base-bg-5: hsla(180, 9%, 89%, 0.5);
|
||||
--user-gao-color: #fcad36;
|
||||
--user-hao-color: #ec6f6f;
|
||||
--user-all-color: #252526;
|
||||
--nr-menu-highlight-color: #ff2442;
|
||||
--nr-menu-icon-hover-color: #ff2442;
|
||||
--nr-sider-background: #fff;
|
||||
--nr-menu-bg: #fff;
|
||||
--nr-sider-fixed-zindex: 12;
|
||||
--nr-header-fixed-zindex: 11;
|
||||
--newrank-color-bg: rgba(255, 120, 0, 0.1);
|
||||
--newrank-color-bg-3: rgba(255, 120, 0, 0.3);
|
||||
--warning-05: rgba(250, 173, 20, 0.05);
|
||||
--bridge-account-color: #ff2442;
|
||||
--bridge-agency-color: #3a64ff;
|
||||
--bridge-free-color: #ff7800;
|
||||
--bridge-medium-color: #00b354;
|
||||
}
|
||||
167
webapp/packages/chat-sdk/src/typings.d.ts
vendored
Normal file
167
webapp/packages/chat-sdk/src/typings.d.ts
vendored
Normal file
@@ -0,0 +1,167 @@
|
||||
declare module 'slash2';
|
||||
declare module '*.css';
|
||||
declare module '*.less';
|
||||
declare module '*.scss';
|
||||
declare module '*.sass';
|
||||
declare module '*.svg';
|
||||
declare module '*.png';
|
||||
declare module '*.jpg';
|
||||
declare module '*.jpeg';
|
||||
declare module '*.gif';
|
||||
declare module '*.bmp';
|
||||
declare module '*.tiff';
|
||||
declare module 'omit.js';
|
||||
declare module 'numeral';
|
||||
declare module '@antv/data-set';
|
||||
declare module 'mockjs';
|
||||
declare module 'react-fittext';
|
||||
declare module 'bizcharts-plugin-slider';
|
||||
declare module 'react-split-pane/lib/Pane';
|
||||
|
||||
// preview.pro.ant.design only do not use in your production ;
|
||||
// preview.pro.ant.design Dedicated environment variable, please do not use it in your project.
|
||||
declare let ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION: 'site' | undefined;
|
||||
|
||||
declare const REACT_APP_ENV: 'test' | 'dev' | 'pre' | false;
|
||||
|
||||
type Result<T> = {
|
||||
code: number;
|
||||
data: T;
|
||||
msg: string;
|
||||
};
|
||||
|
||||
type DavinciResponseHeader = {
|
||||
code: number;
|
||||
msg: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
type DavinciResponse<T> = {
|
||||
header: DavinciResponseHeader;
|
||||
payload: T;
|
||||
};
|
||||
|
||||
// 达芬奇接口返回的参数格式
|
||||
type DavinciResult<T> = {
|
||||
payload: T;
|
||||
header: {
|
||||
msg: string;
|
||||
code: number;
|
||||
token: string;
|
||||
};
|
||||
};
|
||||
|
||||
// 新请求器下的超音数分页接口声明泛型
|
||||
type TPaginationResponse<T> = {
|
||||
content: T[];
|
||||
current: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
type DavinciPaginationResponse<T> = DavinciResult<{
|
||||
resultList: T[];
|
||||
pageNo: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
[key: string]: any;
|
||||
}>;
|
||||
|
||||
type BDResponse<T> = {
|
||||
code: string;
|
||||
data: T;
|
||||
msg: string;
|
||||
traceId: string;
|
||||
};
|
||||
|
||||
type TopNConfig = {
|
||||
computeType: 'field' | 'dimension';
|
||||
column: string;
|
||||
direction: 'asc' | 'desc';
|
||||
limit: number;
|
||||
};
|
||||
|
||||
type ColumnType = {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type DataType = {
|
||||
columns: ColumnType[];
|
||||
pageNo: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
resultList: any[];
|
||||
sqlToExec: string;
|
||||
timeUsed: number;
|
||||
};
|
||||
|
||||
type QueryVariable = { name: string; value: string | number }[];
|
||||
|
||||
type GetDataParams = {
|
||||
groups: string[];
|
||||
aggregators: { column: string; func: string }[];
|
||||
filters: any[];
|
||||
params?: QueryVariable;
|
||||
orders?: { column: string; direction?: string; sortList?: string[] }[];
|
||||
limit: number;
|
||||
cache: boolean;
|
||||
expired: number;
|
||||
flush: boolean;
|
||||
pageNo?: number;
|
||||
pageSize?: number;
|
||||
nativeQuery: boolean;
|
||||
topN?: TopNConfig;
|
||||
classId?: number;
|
||||
};
|
||||
|
||||
type ReportEventParams = {
|
||||
event: string;
|
||||
dt_pgid?: string;
|
||||
page_title: string;
|
||||
page_path?: string;
|
||||
entity_id?: string | number;
|
||||
singer_id?: number;
|
||||
producer?: string;
|
||||
ip?: string;
|
||||
song_id?: number;
|
||||
album_id?: number;
|
||||
brand_id?: number;
|
||||
company_id?: number;
|
||||
song_ids?: string;
|
||||
compare_Ids?: string;
|
||||
element_name?: string;
|
||||
entrance_name?: string;
|
||||
category_id?: string;
|
||||
category_type?: string;
|
||||
conversation_name?: string;
|
||||
msg?: string;
|
||||
msg_type?: string;
|
||||
search_value?: string;
|
||||
[key: string]: string | number;
|
||||
};
|
||||
|
||||
type RowSpanMapIndexItem = number[];
|
||||
type RowSpanMap = Record<string, RowSpanMapIndexItem>;
|
||||
|
||||
type Pagination = {
|
||||
current?: number;
|
||||
pageSize?: number;
|
||||
sort?: string;
|
||||
orderCondition?: string;
|
||||
};
|
||||
|
||||
type PromiseSettledItem = {
|
||||
status: string;
|
||||
value?: any;
|
||||
reason?: any;
|
||||
};
|
||||
|
||||
type PromiseSettledList = PromiseSettledItem[];
|
||||
|
||||
type PaginationResponse<T> = Result<{
|
||||
content: T[];
|
||||
current: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}>;
|
||||
176
webapp/packages/chat-sdk/src/utils/utils.ts
Normal file
176
webapp/packages/chat-sdk/src/utils/utils.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import moment, { Moment } from 'moment';
|
||||
import { NumericUnit } from '../common/constants';
|
||||
|
||||
export function formatByDecimalPlaces(value: number | string, decimalPlaces: number) {
|
||||
if (isNaN(+value) || decimalPlaces < 0 || decimalPlaces > 100) {
|
||||
return value;
|
||||
}
|
||||
let strValue = (+value).toFixed(decimalPlaces);
|
||||
if (!/^[0-9.]+$/g.test(strValue)) {
|
||||
return '0';
|
||||
}
|
||||
while (strValue.includes('.') && (strValue.endsWith('.') || strValue.endsWith('0'))) {
|
||||
strValue = strValue.slice(0, -1);
|
||||
}
|
||||
return strValue;
|
||||
}
|
||||
|
||||
export function formatByThousandSeperator(value: number | string) {
|
||||
if (isNaN(+value)) {
|
||||
return value;
|
||||
}
|
||||
const partValues = value.toString().split('.');
|
||||
partValues[0] = partValues[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return partValues.join('.');
|
||||
}
|
||||
|
||||
export function formatByUnit(value: number | string, unit: NumericUnit) {
|
||||
const numericValue = +value;
|
||||
if (isNaN(numericValue) || unit === NumericUnit.None) {
|
||||
return value;
|
||||
}
|
||||
let exponentValue = 0;
|
||||
switch (unit) {
|
||||
case NumericUnit.TenThousand:
|
||||
case NumericUnit.EnTenThousand:
|
||||
exponentValue = 4;
|
||||
break;
|
||||
case NumericUnit.OneHundredMillion:
|
||||
exponentValue = 8;
|
||||
break;
|
||||
case NumericUnit.Thousand:
|
||||
exponentValue = 3;
|
||||
break;
|
||||
case NumericUnit.Million:
|
||||
exponentValue = 6;
|
||||
break;
|
||||
case NumericUnit.Giga:
|
||||
exponentValue = 9;
|
||||
break;
|
||||
}
|
||||
return numericValue / Math.pow(10, exponentValue);
|
||||
}
|
||||
|
||||
export const getFormattedValue = (value: number | string, remainZero?: boolean) => {
|
||||
if (remainZero && (value === undefined || +value === 0)) {
|
||||
return 0;
|
||||
}
|
||||
if (value === undefined) {
|
||||
return '-';
|
||||
}
|
||||
if (!isFinite(+value)) {
|
||||
return value;
|
||||
}
|
||||
const unit =
|
||||
+value >= 100000000
|
||||
? NumericUnit.OneHundredMillion
|
||||
: +value >= 10000
|
||||
? NumericUnit.EnTenThousand
|
||||
: NumericUnit.None;
|
||||
|
||||
let formattedValue = formatByUnit(value, unit);
|
||||
formattedValue = formatByDecimalPlaces(
|
||||
formattedValue,
|
||||
unit === NumericUnit.OneHundredMillion ? 2 : +value < 1 ? 3 : 1,
|
||||
);
|
||||
formattedValue = formatByThousandSeperator(formattedValue);
|
||||
if ((typeof formattedValue === 'number' && isNaN(formattedValue)) || +formattedValue === 0) {
|
||||
return '-';
|
||||
}
|
||||
return `${formattedValue}${unit === NumericUnit.None ? '' : unit}`;
|
||||
};
|
||||
|
||||
export const groupByColumn = (data: any[], column: string) => {
|
||||
return data.reduce((result, item) => {
|
||||
const resultData = { ...result };
|
||||
const key = item[column];
|
||||
if (!resultData[key]) {
|
||||
resultData[key] = [];
|
||||
}
|
||||
resultData[key].push(item);
|
||||
return resultData;
|
||||
}, {});
|
||||
};
|
||||
|
||||
// 获取任意两个日期中的所有日期
|
||||
export function enumerateDaysBetweenDates(startDate: Moment, endDate: Moment, dateType?: any) {
|
||||
let daysList: any[] = [];
|
||||
const day = endDate.diff(startDate, dateType || 'days');
|
||||
const format = dateType === 'months' ? 'YYYY-MM' : 'YYYY-MM-DD';
|
||||
daysList.push(startDate.format(format));
|
||||
for (let i = 1; i <= day; i++) {
|
||||
daysList.push(startDate.add(1, dateType || 'days').format(format));
|
||||
}
|
||||
return daysList;
|
||||
}
|
||||
|
||||
export const normalizeTrendData = (
|
||||
resultList: any[],
|
||||
dateColumnName: string,
|
||||
valueColumnName: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
dateType?: string,
|
||||
) => {
|
||||
const dateList = enumerateDaysBetweenDates(moment(startDate), moment(endDate), dateType);
|
||||
const result = dateList.map((date) => {
|
||||
const item = resultList.find((result) => result[dateColumnName] === date);
|
||||
return {
|
||||
...(item || {}),
|
||||
[dateColumnName]: date,
|
||||
[valueColumnName]: item ? item[valueColumnName] : 0,
|
||||
};
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getMinMaxDate = (resultList: any[], dateColumnName: string) => {
|
||||
const dateList = resultList.map((item) => moment(item[dateColumnName]));
|
||||
return [moment.min(dateList).format('YYYY-MM-DD'), moment.max(dateList).format('YYYY-MM-DD')];
|
||||
};
|
||||
|
||||
export function hexToRgbObj(hex) {
|
||||
var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getLightenDarkenColor(col, amt) {
|
||||
let result;
|
||||
if (col?.includes('rgb')) {
|
||||
const [r, g, b, a] = col.match(/\d+/g).map(Number);
|
||||
result = { r, g, b, a };
|
||||
} else {
|
||||
result = hexToRgbObj(col) || {};
|
||||
}
|
||||
return `rgba(${result.r + amt},${result.g + amt},${result.b + amt}${
|
||||
result.a ? `,${result.a}` : ''
|
||||
})`;
|
||||
}
|
||||
|
||||
export function getChartLightenColor(col) {
|
||||
return getLightenDarkenColor(col, 80);
|
||||
}
|
||||
|
||||
export const isMobile = window.navigator.userAgent.match(/(iPhone|iPod|Android|ios)/i);
|
||||
|
||||
|
||||
export function isProd() {
|
||||
return (
|
||||
window.location.origin.includes('tmeoa.com') ||
|
||||
window.location.hostname === 's2.tencentmusic.com'
|
||||
);
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem('SUPERSONIC_CHAT_TOKEN', token);
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem('SUPERSONIC_CHAT_TOKEN');
|
||||
}
|
||||
Reference in New Issue
Block a user