This commit is contained in:
pengxiaolong
2025-05-13 19:39:53 +08:00
parent 37da6765b8
commit c006a8e63d
1232 changed files with 96963 additions and 883 deletions

View File

@@ -0,0 +1,2 @@
import SearchResult from './index.vue';
export default SearchResult;

View File

@@ -0,0 +1,566 @@
<template>
<SearchResultLoading
v-if="isLoading"
:class="['search-result-loading', !isPC && 'search-result-loading-h5']"
/>
<SearchResultDefault
v-else-if="isSearchDefaultShow"
:class="['search-result-default', !isPC && 'search-result-default-h5']"
/>
<div
v-else
:class="[
'tui-search-result',
!isPC && 'tui-search-result-h5',
isPC && isResultDetailShow && 'tui-search-result-with-border',
]"
>
<div
v-if="props.searchType !== 'conversation' && (isPC || !isResultDetailShow)"
class="tui-search-result-main"
>
<div class="tui-search-result-list">
<div
v-for="result in searchResult"
:key="result.key"
class="tui-search-result-list-item"
>
<div
v-if="props.searchType === 'global'"
class="header"
>
{{ TUITranslateService.t(`TUISearch.${result.label}`) }}
</div>
<div class="list">
<div
v-for="item in result.list"
:key="item.conversation.conversationID"
:class="[generateListItemClass(item)]"
>
<SearchResultItem
v-if="result.key === 'contact' || result.key === 'group' || item.conversation"
:listItem="item"
:type="result.key"
displayType="info"
:keywordList="keywordList"
@showResultDetail="showResultDetail"
@navigateToChatPosition="navigateToChatPosition"
/>
</div>
</div>
<div
v-if="currentSearchTabKey === 'all' || result.cursor"
class="more"
@click="getMoreResult(result)"
>
<Icon
class="more-icon"
:file="searchIcon"
width="12px"
height="12px"
/>
<div class="more-text">
<span>{{ TUITranslateService.t("TUISearch.查看更多") }}</span>
<span>{{ TUITranslateService.t(`TUISearch.${result.label}`) }}</span>
</div>
</div>
</div>
</div>
</div>
<div
v-if="isResultDetailShow || props.searchType === 'conversation'"
:class="[
'tui-search-result-detail',
props.searchType === 'conversation' && 'tui-search-result-in-conversation',
]"
>
<SearchResultLoading
v-if="isSearchDetailLoading"
:class="['search-result-loading', !isPC && 'search-result-loading-h5']"
/>
<div
v-if="!isSearchDetailLoading && isResultDetailShow && props.searchType !== 'conversation'"
class="tui-search-message-header"
>
<div class="header-content">
<div class="header-content-count normal">
<span>{{ searchConversationMessageTotalCount }}</span>
<span>{{ TUITranslateService.t("TUISearch.条与") }}</span>
</div>
<div class="header-content-keyword">
<span
v-for="(keyword, index) in keywordList"
:key="index"
>
<span class="normal">"</span>
<span class="highlight">{{ keyword }}</span>
<span class="normal">"</span>
</span>
</div>
<div class="header-content-type normal">
<span>{{
TUITranslateService.t("TUISearch.相关的")
}}</span>
<span>{{
TUITranslateService.t(
`TUISearch.${currentSearchTabKey === "allMessage"
? "结果"
: searchMessageTypeList[currentSearchTabKey].label
}`
)
}}</span>
</div>
</div>
<div
class="header-enter"
@click="enterConversation({ conversationID: currentSearchConversationID })"
>
<span>{{ TUITranslateService.t("TUISearch.进入聊天") }}</span>
<Icon
class="enter-icon"
:file="enterIcon"
width="14px"
height="14px"
/>
</div>
</div>
<div
v-if="!isSearchDetailLoading &&
searchConversationMessageList &&
searchConversationMessageList[0]
"
class="tui-search-message-list"
>
<template
v-if="props.searchType === 'global' ||
(currentSearchTabKey !== 'imageMessage' && currentSearchTabKey !== 'fileMessage')
"
>
<div
v-for="item in searchConversationMessageList"
:key="generateVueRenderKey(item.ID)"
:class="['list-item']"
>
<SearchResultItem
:listItem="item"
:listItemContent="item.getMessageContent()"
:type="currentSearchTabKey"
:displayType="generateResultItemDisplayType()"
:keywordList="keywordList"
@showResultDetail="showResultDetail"
@navigateToChatPosition="navigateToChatPosition"
/>
</div>
</template>
<!-- Search within a conversation - messages such as files, pictures, and videos need to be displayed in groups according to time -->
<template v-else>
<div
v-for="group in searchConversationResultGroupByDate"
:key="generateVueRenderKey(group.date)"
:class="['list-group', currentSearchTabKey === 'fileMessage'? 'list-group-file' : 'list-group-image']"
>
<div :class="['list-group-date']">
{{ group.date }}
</div>
<div
v-for="item in group.list"
:key="generateVueRenderKey(item.ID)"
:class="['list-group-item']"
>
<SearchResultItem
:listItem="item"
:listItemContent="item.getMessageContent()"
:type="currentSearchTabKey"
:displayType="generateResultItemDisplayType()"
:keywordList="keywordList"
@showResultDetail="showResultDetail"
@navigateToChatPosition="navigateToChatPosition"
/>
</div>
</div>
</template>
<div
v-if="searchConversationResult && searchConversationResult.cursor"
class="more"
@click="getMoreResultInConversation"
>
<Icon
class="more-icon"
:file="searchIcon"
width="12px"
height="12px"
/>
<div class="more-text">
{{ TUITranslateService.t("TUISearch.查看更多历史记录") }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed, onMounted, onUnmounted } from '../../../adapter-vue';
import {
TUITranslateService,
TUIConversationService,
TUIStore,
StoreName,
IMessageModel,
SearchCloudMessagesParams,
} from '@tencentcloud/chat-uikit-engine';
import { TUIGlobal } from '@tencentcloud/universal-api';
import SearchResultItem from './search-result-item/index.vue';
import SearchResultDefault from './search-result-default/index.vue';
import SearchResultLoading from './search-result-loading/index.vue';
import { searchMessageTypeList, searchMessageTypeDefault } from '../search-type-list';
import Icon from '../../common/Icon.vue';
import searchIcon from '../../../assets/icon/search.svg';
import enterIcon from '../../../assets/icon/right-icon.svg';
import {
searchCloudMessages,
enterConversation,
generateSearchResultYMD,
debounce,
} from '../utils';
import { enableSampleTaskStatus } from '../../../utils/enableSampleTaskStatus';
import { isPC, isUniFrameWork } from '../../../utils/env';
import { SEARCH_TYPE, ISearchInputValue, ISearchMessageType, ISearchMessageTime } from '../type';
import { ISearchCloudMessageResult, ISearchResultListItem } from '../../../interface';
const props = defineProps({
searchType: {
type: String,
default: 'global', // "global" / "conversation"
validator(value: string) {
return ['global', 'conversation'].includes(value);
},
},
});
// search params
const keywordList = ref<string[]>([]);
const messageTypeList = ref<string | string[]>(
searchMessageTypeDefault[props.searchType as SEARCH_TYPE]?.value as string[],
);
const timePosition = ref<number>(0);
const timePeriod = ref<number>(0);
// Search by "and" after splitting the whole string by space
// For example: enter "111 222", search for messages with both 111 and 222, and also include messages that strictly search for "111 222"
const keywordListMatchType = ref<string>('and');
// current search tab key
const currentSearchTabKey = ref<string>(
searchMessageTypeDefault[props.searchType as SEARCH_TYPE]?.key,
);
// search results all
const searchResult = ref<{
[propsName: string]: { key: string; label: string; list: ISearchResultListItem[]; cursor: string | null };
}>({});
const searchAllMessageList = ref<ISearchResultListItem[]>([]);
const searchAllMessageTotalCount = ref<number>(0);
// search results detail
const currentSearchConversationID = ref<string>('');
const searchConversationResult = ref<ISearchCloudMessageResult>();
const searchConversationMessageList = ref<IMessageModel[]>([]);
const searchConversationMessageTotalCount = ref<number>();
// search results for file messages/image and video messages, grouped by timeline
const searchConversationResultGroupByDate = ref<
Array<{ date: string; list: IMessageModel[] }>
>([]);
// ui display control
const isResultDetailShow = ref<boolean>(false);
const isLoading = ref<boolean>(false);
const isSearchDetailLoading = ref<boolean>(false);
const isSearchDefaultShow = computed((): boolean => {
if (isLoading.value) {
return false;
}
if (props.searchType === 'global') {
if (!keywordList?.value?.length || Object?.keys(searchResult.value)?.length) {
return false;
} else {
return true;
}
} else {
if (searchConversationMessageList?.value?.length) {
return false;
} else {
return true;
}
}
});
function onCurrentConversationIDUpdate(id: string) {
props.searchType === 'conversation' && (currentSearchConversationID.value = id);
}
function onCurrentSearchInputValueUpdate(obj: ISearchInputValue) {
if (obj?.searchType === props?.searchType) {
keywordList.value = obj?.value ? obj.value.trim().split(/\s+/) : [];
}
}
function onCurrentSearchMessageTypeUpdate(typeObject: ISearchMessageType) {
if (typeObject?.searchType === props?.searchType) {
currentSearchTabKey.value
= typeObject?.value?.key || searchMessageTypeDefault[props.searchType as SEARCH_TYPE]?.key;
messageTypeList.value
= typeObject?.value?.value
|| searchMessageTypeDefault[props.searchType as SEARCH_TYPE]?.value;
}
}
function onCurrentSearchMessageTimeUpdate(timeObject: ISearchMessageTime) {
if (timeObject?.searchType === props?.searchType) {
timePosition.value = timeObject?.value?.value?.timePosition;
timePeriod.value = timeObject?.value?.value?.timePeriod;
}
}
onMounted(() => {
TUIStore.watch(StoreName.CONV, {
currentConversationID: onCurrentConversationIDUpdate,
});
TUIStore.watch(StoreName.SEARCH, {
currentSearchInputValue: onCurrentSearchInputValueUpdate,
currentSearchMessageType: onCurrentSearchMessageTypeUpdate,
currentSearchMessageTime: onCurrentSearchMessageTimeUpdate,
});
});
onUnmounted(() => {
TUIStore.unwatch(StoreName.CONV, {
currentConversationID: onCurrentConversationIDUpdate,
});
TUIStore.unwatch(StoreName.SEARCH, {
currentSearchInputValue: onCurrentSearchInputValueUpdate,
currentSearchMessageType: onCurrentSearchMessageTypeUpdate,
currentSearchMessageTime: onCurrentSearchMessageTimeUpdate,
});
});
const setMessageSearchResultList = (option?: { conversationID?: string | undefined; cursor?: string | undefined }) => {
searchCloudMessages(
{
keywordList: keywordList?.value?.length ? keywordList.value : undefined,
messageTypeList: typeof messageTypeList.value === 'string' ? [messageTypeList.value] : messageTypeList.value,
timePosition: timePosition.value,
timePeriod: timePeriod.value,
conversationID: option?.conversationID || undefined,
cursor: option?.cursor || undefined,
keywordListMatchType: keywordListMatchType.value,
} as SearchCloudMessagesParams,
)
.then((res: { data: ISearchCloudMessageResult }) => {
enableSampleTaskStatus('searchCloudMessage');
if (!option?.conversationID) {
option?.cursor
? (searchAllMessageList.value = [
...searchAllMessageList.value,
...res.data.searchResultList,
])
: (searchAllMessageList.value = res?.data?.searchResultList);
searchAllMessageTotalCount.value = res?.data?.totalCount;
const key = currentSearchTabKey.value === 'all' ? 'allMessage' : currentSearchTabKey.value;
if (
searchAllMessageList?.value?.length
&& currentSearchTabKey.value !== 'contact'
&& currentSearchTabKey.value !== 'group'
) {
searchResult.value = Object.assign({}, searchResult.value, {
[key]: {
key,
label: searchMessageTypeList[key].label,
list: currentSearchTabKey.value === 'all'
? searchAllMessageList?.value?.slice(0, 3)
: searchAllMessageList?.value,
cursor: res?.data?.cursor || null,
},
});
} else {
delete searchResult?.value[key];
}
} else {
searchConversationResult.value = res?.data;
option?.cursor
? (searchConversationMessageList.value = [
...searchConversationMessageList.value,
...(res?.data?.searchResultList[0]?.messageList as IMessageModel[]),
])
: (searchConversationMessageList.value = res?.data?.searchResultList[0]?.messageList);
searchConversationMessageTotalCount.value = res?.data?.totalCount;
if (
props?.searchType === 'conversation'
&& (currentSearchTabKey.value === 'fileMessage'
|| currentSearchTabKey.value === 'imageMessage')
) {
searchConversationResultGroupByDate.value = groupResultListByDate(
searchConversationMessageList.value,
);
} else {
searchConversationResultGroupByDate.value = [];
}
}
isLoading.value = false;
isSearchDetailLoading.value = false;
});
};
const setMessageSearchResultListDebounce = debounce(setMessageSearchResultList, 500);
const resetSearchResult = () => {
searchResult.value = {};
searchConversationResult.value = {} as ISearchCloudMessageResult;
searchConversationMessageList.value = [];
searchConversationResultGroupByDate.value = [];
};
watch(
() => [keywordList.value, currentSearchTabKey.value, timePosition.value, timePeriod.value],
(newValue, oldValue) => {
if (newValue === oldValue) {
return;
}
// Global search must have keywords, but search in conversation can be without keywords
if (!keywordList?.value?.length && props?.searchType === 'global') {
resetSearchResult();
return;
}
isLoading.value = true;
if (props.searchType === 'conversation') {
resetSearchResult();
setMessageSearchResultList({
conversationID: currentSearchConversationID.value,
});
} else {
if (oldValue && oldValue[1] === 'all' && newValue && newValue[1] === 'allMessage') {
searchResult?.value['allMessage']?.list
&& (searchResult.value['allMessage'].list = searchAllMessageList?.value);
Object?.keys(searchResult?.value)?.forEach((key: string) => {
if (key !== 'allMessage') {
delete searchResult?.value[key];
}
});
isLoading.value = false;
return;
} else {
isResultDetailShow.value = false;
resetSearchResult();
}
setMessageSearchResultListDebounce();
}
},
{ immediate: true },
);
const getMoreResult = (result: { key: string; label: string; list: ISearchResultListItem[]; cursor: string | null }) => {
if (currentSearchTabKey.value === 'all') {
// View more at this time: Switch to the result corresponding to the corresponding result to display the full search results of its type
TUIStore.update(StoreName.SEARCH, 'currentSearchMessageType', {
value: searchMessageTypeList[result.key],
searchType: props.searchType,
});
} else {
// View more results for a single category: Use the cursor as the search start position to pull the next part of the results
setMessageSearchResultList({ cursor: result?.cursor || undefined });
}
};
const getMoreResultInConversation = () => {
setMessageSearchResultList({
cursor: searchConversationResult?.value?.cursor,
conversationID: currentSearchConversationID?.value,
});
};
const showResultDetail = (isShow: boolean, targetType?: string, targetResult?: IMessageModel | ISearchResultListItem) => {
isResultDetailShow.value = isShow;
if (targetType) {
TUIStore.update(StoreName.SEARCH, 'currentSearchMessageType', {
value: searchMessageTypeList[targetType],
searchType: props.searchType,
});
}
currentSearchConversationID.value = (targetResult as ISearchResultListItem)?.conversation?.conversationID || '';
searchConversationMessageTotalCount.value = (targetResult as ISearchResultListItem)?.messageCount;
if (targetResult) {
isSearchDetailLoading.value = true;
setMessageSearchResultListDebounce({
conversationID: currentSearchConversationID.value,
});
}
};
const generateListItemClass = (item: ISearchResultListItem): string[] => {
return currentSearchConversationID.value === item?.conversation?.conversationID
? ['list-item', 'list-item-selected']
: ['list-item'];
};
const generateResultItemDisplayType = (): string => {
if (props.searchType === 'conversation' && currentSearchTabKey.value === 'fileMessage') {
return 'file';
} else if (props.searchType === 'conversation' && currentSearchTabKey.value === 'imageMessage') {
return 'image';
} else if (isPC) {
return 'bubble';
} else {
return 'info';
}
};
const groupResultListByDate = (
messageList: IMessageModel[],
): Array<{ date: string; list: IMessageModel[] }> => {
const result: Array<{ date: string; list: IMessageModel[] }> = [];
if (!messageList?.length) {
return result;
} else if (messageList?.length === 1) {
result.push({ date: generateSearchResultYMD(messageList[0]?.time), list: messageList });
return result;
}
let prevYMD = '';
let currYMD = '';
for (let i = 0; i < messageList?.length; i++) {
currYMD = generateSearchResultYMD(messageList[i]?.time);
if (prevYMD !== currYMD) {
result.push({ date: currYMD, list: [messageList[i]] });
} else {
result[result?.length - 1]?.list?.push(messageList[i]);
}
prevYMD = currYMD;
}
return result;
};
const navigateToChatPosition = (message: IMessageModel) => {
if (props.searchType === 'global') {
// Global search, close the search container
TUIStore.update(StoreName.SEARCH, 'currentSearchingStatus', {
isSearching: false,
searchType: props.searchType,
});
// switch conversation
TUIConversationService.switchConversation(message?.conversationID).then(() => {
TUIStore.update(StoreName.CHAT, 'messageSource', message);
isUniFrameWork && TUIGlobal?.navigateTo({
url: '/TUIKit/components/TUIChat/index',
});
});
} else if (props.searchType === 'conversation') {
// Search in conversation, close the search container
TUIStore.update(StoreName.SEARCH, 'isShowInConversationSearch', false);
TUIStore.update(StoreName.CHAT, 'messageSource', message);
isUniFrameWork && TUIGlobal?.navigateBack();
}
};
const generateVueRenderKey = (value: string): string => {
return `${currentSearchTabKey}-${value}`;
};
</script>
<style lang="scss" scoped src="./style/index.scss"></style>

View File

@@ -0,0 +1,2 @@
import SearchResultDefault from './index.vue';
export default SearchResultDefault;

View File

@@ -0,0 +1,50 @@
<template>
<div :class="['search-result-default', !isPC && 'search-result-default-h5']">
<div class="search-result-default-main">
<Icon
:file="SearchDefaultIcon"
width="88px"
height="75px"
/>
<div class="default-text">
{{ TUITranslateService.t("TUISearch.暂无搜索结果") }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { TUITranslateService } from '@tencentcloud/chat-uikit-engine';
import { isPC } from '../../../../utils/env';
import Icon from '../../../common/Icon.vue';
import SearchDefaultIcon from '../../../../assets/icon/search-default.svg';
</script>
<style scoped lang="scss">
.search-result-default {
width: 100%;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
&-h5 {
background-color: #f4f4f4;
}
&-main {
display: flex;
flex-direction: column;
text-align: center;
justify-content: center;
align-items: center;
.default-text {
font-family: "PingFang SC", sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 20px;
color: #666;
}
}
}
</style>

View File

@@ -0,0 +1,211 @@
<!-- Used to display the search results of [Contacts]/[Groups]/[All Conversations], which is a display of user/group/conversation dimensions -->
<template>
<div
:class="[
'search-result-list-item',
!isPC && 'search-result-list-item-h5',
'search-result-list-item-' + displayType,
isHovering && 'hover-' + displayType,
]"
@click="onResultItemClicked"
@mouseenter="setHoverStatus(true)"
@mouseleave="setHoverStatus(false)"
>
<div
v-if="displayType === 'info' || displayType === 'bubble'"
:class="[displayType]"
>
<div :class="displayType + '-left'">
<img
:class="displayType + '-left-avatar'"
:src="avatarForShow || ''"
onerror="this.onerror=null;this.src='https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_21.png'"
>
</div>
<div :class="[displayType + '-main']">
<div :class="[displayType + '-main-name']">
{{ nameForShow }}
</div>
<div :class="[displayType + '-main-content']">
<MessageAbstractText
v-if="displayType === 'info' || listItem.type === TYPES.MSG_TEXT"
:content="contentForShow"
:highlightType="displayType === 'info' ? 'font' : 'background'"
:displayType="displayType"
/>
<MessageAbstractFile
v-else-if="listItem.type === TYPES.MSG_FILE"
:contentText="contentForShow"
:messageContent="listItemContent"
:displayType="displayType"
/>
<div v-else-if="listItem.type === TYPES.MSG_IMAGE" />
<div v-else-if="listItem.type === TYPES.MSG_VIDEO" />
<MessageAbstractCustom
v-else-if="listItem.type === TYPES.MSG_CUSTOM"
:contentText="contentForShow"
:message="listItem"
:messageContent="listItemContent"
/>
<div v-else>
{{ getMessageAbstractType(listItem) }}
</div>
</div>
</div>
<div :class="displayType + '-right'">
<div :class="displayType + '-right-time'">
{{ timeForShow }}
</div>
<div
v-if="displayType === 'bubble' && isHovering"
:class="displayType + '-right-to'"
@click.stop="navigateToChatPosition"
>
{{ TUITranslateService.t("TUISearch.定位到聊天位置") }}
</div>
</div>
</div>
<div
v-else-if="displayType === 'file'"
:class="[displayType]"
>
<div :class="[displayType + '-header']">
<img
:class="displayType + '-header-avatar'"
:src="avatarForShow"
onerror="this.onerror=null;this.src='https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_21.png'"
>
<div :class="[displayType + '-header-name']">
{{ nameForShow }}
</div>
<div
v-if="isHovering"
:class="displayType + '-header-to'"
@click.stop="navigateToChatPosition"
>
{{ TUITranslateService.t("TUISearch.定位到聊天位置") }}
</div>
<div :class="displayType + '-header-time'">
{{ timeForShow }}
</div>
</div>
<div :class="[displayType + '-main-content']">
<MessageAbstractFile
:contentText="contentForShow"
:messageContent="listItemContent"
displayType="bubble"
/>
</div>
</div>
<div
v-else-if="displayType === 'image'"
:class="[displayType]"
>
<div
class="image-container"
@click.stop="navigateToChatPosition"
>
<MessageAbstractImage
v-if="listItem.type === TYPES.MSG_IMAGE"
:messageContent="listItemContent"
/>
<MessageAbstractVideo
v-else-if="listItem.type === TYPES.MSG_VIDEO"
:messageContent="listItemContent"
/>
<div
v-if="isHovering"
class="image-container-hover"
>
<div class="image-container-hover-text">
{{ TUITranslateService.t("TUISearch.定位到聊天位置") }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import TUIChatEngine, { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
import { ref, watchEffect, withDefaults } from '../../../../adapter-vue';
import MessageAbstractText from './message-abstract/message-abstract-text.vue';
import MessageAbstractFile from './message-abstract/message-abstract-file.vue';
import MessageAbstractCustom from './message-abstract/message-abstract-custom.vue';
import MessageAbstractImage from './message-abstract/message-abstract-image.vue';
import MessageAbstractVideo from './message-abstract/message-abstract-video.vue';
import {
generateSearchResultShowName,
generateSearchResultAvatar,
generateSearchResultShowContent,
generateSearchResultTime,
enterConversation,
} from '../../utils';
import { messageTypeAbstractMap, searchResultItemDisplayTypeValues, searchMessageTypeValues, IHighlightContent } from '../../type';
import { ISearchResultListItem } from '../../../../interface';
import { isPC } from '../../../../utils/env';
interface IProps {
listItem: IMessageModel | ISearchResultListItem;
listItemContent?: Record<string, unknown>;
type: searchMessageTypeValues;
displayType: searchResultItemDisplayTypeValues;
keywordList: string[];
}
const props = withDefaults(defineProps<IProps>(), {
listItem: () => ({}) as IMessageModel | ISearchResultListItem,
listItemContent: () => ({}) as Record<string, unknown>,
type: 'allMessage',
displayType: 'info',
keywordList: () => ([]) as string[],
});
const emits = defineEmits(['showResultDetail', 'navigateToChatPosition']);
const TYPES = ref(TUIChatEngine.TYPES);
const avatarForShow = ref<string>('');
const nameForShow = ref<string>('');
const contentForShow = ref<IHighlightContent[]>([]);
const timeForShow = ref<string>('');
const isHovering = ref<boolean>(false);
watchEffect(() => {
avatarForShow.value = generateSearchResultAvatar(props.listItem);
nameForShow.value = generateSearchResultShowName(props.listItem, props?.listItemContent);
contentForShow.value = generateSearchResultShowContent(
props.listItem,
props.type,
props.keywordList as string[],
props?.displayType === 'info',
);
timeForShow.value = (props.listItem as IMessageModel)?.time
? generateSearchResultTime((props.listItem as IMessageModel)?.time * 1000)
: '';
});
const onResultItemClicked = () => {
if (props.type === 'contact' || props.type === 'group') {
enterConversation(props.listItem as IMessageModel);
} else {
if (props.displayType === 'info' && !(props.listItem as IMessageModel)?.ID) {
emits('showResultDetail', true, props.type, props.listItem);
} else {
navigateToChatPosition();
}
}
};
const setHoverStatus = (status: boolean) => {
isHovering.value = status;
};
const navigateToChatPosition = () => {
emits('navigateToChatPosition', props.listItem);
};
const getMessageAbstractType = (message: IMessageModel | ISearchResultListItem) => {
return message?.type
? TUITranslateService.t(`TUISearch.${messageTypeAbstractMap[message.type]}`)
: TUITranslateService.t(`TUISearch.[合并消息]`);
};
</script>
<style lang="scss" scoped src="./style/index.scss"></style>

View File

@@ -0,0 +1,237 @@
<template>
<!-- Custom message keyword keyword search description, so here only a few custom messages that need to display highlighted description type are parsed -->
<div
:class="['message-abstract-custom']"
@click.capture.stop
>
<template v-if="businessID === CHAT_MSG_CUSTOM_TYPE.SERVICE">
<div :class="['service']">
<h1 :class="['service-header']">
<label :class="['service-header-title']">{{ extensionJSON.title }}</label>
<a
v-if="extensionJSON.hyperlinks_text"
:class="['service-header-link', 'link']"
:href="extensionJSON.hyperlinks_text.value"
target="view_window"
>
{{ extensionJSON.hyperlinks_text.key }}
</a>
</h1>
<ul
v-if="extensionJSON.item && extensionJSON.item.length > 0"
:class="['service-list']"
>
<li
v-for="(item, index) in extensionJSON.item"
:key="index"
:class="['service-list-item']"
>
<a
v-if="isUrl(item.value)"
:class="['service-list-item-link', 'link']"
:href="item.value"
target="view_window"
>{{ item.key }}</a>
<p
v-else
:class="['service-list-item-key']"
>
{{ item.key }}
</p>
</li>
</ul>
<div :class="['service-description', 'description']">
<span
v-for="(contentItem, index) in descriptionForShow"
:key="index"
:class="[(contentItem && contentItem.isHighlight) ? 'highlight' : 'normal']"
>
{{ contentItem.text }}
</span>
</div>
</div>
</template>
<template v-else-if="businessID === CHAT_MSG_CUSTOM_TYPE.EVALUATE">
<div class="evaluate">
<div :class="['evaluate-description', 'description']">
<span
v-for="(contentItem, index) in descriptionForShow"
:key="index"
:class="[(contentItem && contentItem.isHighlight) ? 'highlight' : 'normal']"
>
{{ contentItem.text }}
</span>
</div>
<ul
v-if="extensionJSON.score"
class="evaluate-list"
>
<li
v-for="(item, index) in Math.max(extensionJSON.score, 0)"
:key="index"
class="evaluate-list-item"
>
<Icon
:file="star"
class="file-icon"
/>
</li>
</ul>
<article>{{ extensionJSON.comment }}</article>
</div>
</template>
<template v-else-if="businessID === CHAT_MSG_CUSTOM_TYPE.ORDER">
<div class="order">
<img
class="order-image"
:src="extensionJSON.imageUrl"
alt=""
>
<main class="order-main">
<h1 class="order-main-title">
{{ extensionJSON.title }}
</h1>
<div :class="['order-main-description', 'description']">
<span
v-for="(contentItem, index) in descriptionForShow"
:key="index"
:class="[(contentItem && contentItem.isHighlight) ? 'highlight' : 'normal']"
>
{{ contentItem.text }}
</span>
</div>
<span class="order-main-price">{{ extensionJSON.price }}</span>
</main>
</div>
</template>
<template v-else-if="businessID === CHAT_MSG_CUSTOM_TYPE.LINK">
<div class="text-link">
<div :class="['text-link-description', 'description']">
<p>{{ extensionJSON.text }}</p>
</div>
<a
:class="['link']"
:href="extensionJSON.link"
target="view_window"
>{{
TUITranslateService.t("message.custom.查看详情>>")
}}</a>
</div>
</template>
<template v-else>
<span>{{ defaultMessageContent }}</span>
</template>
</div>
</template>
<script setup lang="ts">
import { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
import { ref, computed, withDefaults } from '../../../../../adapter-vue';
import { CHAT_MSG_CUSTOM_TYPE } from '../../../../../constant';
import { JSONToObject, isUrl } from '../../../../../utils/index';
import Icon from '../../../../common/Icon.vue';
import star from '../../../../../assets/icon/star-light.png';
import { IHighlightContent } from '../../../type';
import { ISearchResultListItem } from '../../../../../interface';
interface IProps {
contentText: IHighlightContent[];
message: IMessageModel | ISearchResultListItem;
messageContent: Record<string, unknown> | undefined;
}
const props = withDefaults(defineProps<IProps>(), {
contentText: () => ([]) as IHighlightContent[],
message: () => ({}) as IMessageModel,
messageContent: () => ({}) as Record<string, unknown>,
});
const custom = ref<{ data?: string; description?: string; extension?: string }>(
(props?.message as IMessageModel)?.payload,
);
const extensionJSON = computed(() => custom?.value?.data ? JSONToObject(custom.value.data) : custom?.value?.data);
const businessID = computed(() => extensionJSON?.value?.businessID);
const descriptionForShow = ref<Array<{ text: string; isHighlight: boolean }>>(props?.contentText);
const defaultMessageContent = ref<string>(props?.messageContent?.custom as string || '[自定义消息]');
</script>
<style scoped lang="scss">
@import "../../../../../assets/styles/common";
.message-abstract-custom {
.service {
.service-header {
font-size: 14px;
color: #000;
}
.service-list {
.service-list-item {
font-size: 14px;
}
}
}
.evaluate {
.evaluate-list {
padding: 5px 0;
display: flex;
flex-direction: row;
.evaluate-item {
padding: 0 2px;
}
}
}
.order {
display: flex;
.order-main {
padding-left: 5px;
.order-main-title {
font-size: 14px;
color: #000;
}
.order-main-description {
font-family: PingFangSC-Regular, sans-serif;
width: 145px;
line-height: 17px;
font-size: 14px;
color: #999;
letter-spacing: 0;
margin-bottom: 6px;
word-break: break-word;
}
.order-main-price {
font-family: PingFangSC-Regular, sans-serif;
line-height: 25px;
color: #ff7201;
}
}
.order-img {
width: 67px;
height: 67px;
}
}
.link {
font-size: 14px;
color: #679ce1;
}
.description {
font-size: 14px;
color: #000;
.highlight {
background-color: #007aff33;
}
.normal {
font-size: 14px;
color: #000;
}
}
}
</style>

View File

@@ -0,0 +1,152 @@
<template>
<div :class="['message-abstract-file', `message-abstract-file-${displayType}`]">
<div :class="['message-abstract-file-left']">
<img
:class="['message-abstract-file-left-icon']"
:src="typeIcon.iconSrc"
>
</div>
<div :class="['message-abstract-file-main']">
<div :class="['message-abstract-file-main-name']">
<span
v-for="(contentItem, index) in contentText"
:key="index"
:class="[(contentItem && contentItem.isHighlight) ? 'highlight' : 'normal']"
>
{{ contentItem.text }}
</span>
</div>
<div :class="['message-abstract-file-main-size']">
{{ fileSize }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, withDefaults } from '../../../../../adapter-vue';
import { IHighlightContent } from '../../../type';
interface IProps {
contentText: Array<IHighlightContent>;
messageContent: Record<string, unknown> | undefined;
displayType: 'bubble' | 'info';
}
const props = withDefaults(defineProps<IProps>(), {
contentText: () => ([]) as Array<IHighlightContent>,
messageContent: () => ({}) as Record<string, unknown>,
displayType: 'bubble',
});
const contentText = ref<Array<{ text: string; isHighlight: boolean }>>(props.contentText);
const typeIcon = computed(() => {
const fileUrl = props?.messageContent?.url as string;
const index = fileUrl?.lastIndexOf('.');
const type = fileUrl?.substring(index + 1);
return handleFileIconForShow(type);
});
const fileSize = computed(() => props?.messageContent?.size);
const handleFileIconForShow = (type: string) => {
const urlBase = 'https://web.sdk.qcloud.com/component/TUIKit/assets/file-';
const fileTypes = [
'image',
'pdf',
'text',
'ppt',
'presentation',
'sheet',
'zip',
'word',
'video',
'unknown',
];
let url = '';
let iconType = '';
fileTypes?.forEach((typeName: string) => {
if (type?.includes(typeName)) {
url = urlBase + typeName + '.svg';
iconType = typeName;
}
});
return {
iconSrc: url ? url : urlBase + 'unknown.svg',
iconType: iconType ? iconType : 'unknown',
};
};
</script>
<style scoped lang="scss">
@import "../../../../../assets/styles/common";
.message-abstract-file {
display: flex;
flex: 1;
overflow: hidden;
flex-direction: row;
justify-content: center;
align-items: center;
&-left {
width: 42px;
height: 32px;
&-icon {
width: 32px;
height: 32px;
margin-right: 10px;
border-radius: 5px;
}
}
&-main {
flex: 1;
overflow: hidden;
&-name {
width: 100%;
color: #000;
font-size: 14px;
height: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.highlight {
background-color: #007aff33;
}
.normal {
color: #000;
}
}
&-size {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #888;
font-size: 12px;
}
}
&-bubble {
background-color: #f1f1f1;
.message-abstract-file-main {
.message-abstract-file-main-name {
color: #1f2329;
.normal {
color: #1f2329;
}
}
}
}
&-file {
margin: 8px 10px 5px;
padding: 10px;
background-color: #f1f1f1;
height: 51px;
}
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<div :class="['message-abstract-image-container']">
<img
:class="['message-abstract-image']"
:src="imageUrl"
>
</div>
</template>
<script setup lang="ts">
import { withDefaults, computed } from '../../../../../adapter-vue';
import { IImageMessageContent } from '../../../../../interface';
interface IProps {
messageContent: Record<string, unknown> | IImageMessageContent | undefined;
}
const props = withDefaults(defineProps<IProps>(), {
messageContent: () => ({}) as IImageMessageContent,
});
const imageUrl = computed<string>(() => (props.messageContent as IImageMessageContent).url || '');
</script>
<style scoped lang="scss">
@import "../../../../../assets/styles/common";
.message-abstract-image-container {
max-width: 100px;
max-height: 100px;
width: 100px;
height: 100px;
overflow: hidden;
background-color: #fff;
.message-abstract-image {
max-width: 100px;
max-height: 100px;
width: 100px;
height: 100px;
object-fit: contain;
}
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<div
:class="[
'message-abstract-text',
`message-abstract-text-${highlightType}`,
`message-abstract-text-${displayType}`,
]"
>
<span
v-for="(contentItem, index) in contentText"
:key="index"
:class="[(contentItem && contentItem.isHighlight) ? 'highlight' : 'normal']"
>
{{ transformTextWithKeysToEmojiNames(contentItem.text) }}
</span>
</div>
</template>
<script setup lang="ts">
import { ref, withDefaults } from '../../../../../adapter-vue';
import { transformTextWithKeysToEmojiNames } from '../../../../TUIChat/emoji-config';
import { IHighlightContent } from '../../../type';
interface IProps {
content: IHighlightContent[];
highlightType: 'font' | 'background';
displayType: 'info' | 'bubble';
}
const props = withDefaults(defineProps<IProps>(), {
content: () => ([]) as IHighlightContent[],
highlightType: 'font',
displayType: 'info',
});
const contentText = ref<Array<{ text: string; isHighlight: boolean }>>(props.content);
</script>
<style scoped lang="scss">
@import "../../../../../assets/styles/common";
.message-abstract-text {
justify-content: flex-start;
&-font {
color: #999;
.highlight {
color: #007aff;
}
.normal {
color: #999;
}
}
&-background {
color: #1f2329;
.highlight {
background-color: #007aff33;
}
.normal {
font-size: 14px;
}
}
&-info {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
.highlight {
font-size: 12px;
}
.normal {
font-size: 12px;
}
}
&-bubble {
font-size: 14px;
.highlight {
font-size: 14px;
}
.normal {
font-size: 14px;
}
}
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<div :class="['message-abstract-video']">
<div class="message-abstract-video-box">
<img
:src="videoUrl"
:class="['video-snapshot']"
>
<Icon
:file="playIcon"
class="video-play"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from '../../../../../adapter-vue';
import Icon from '../../../../common/Icon.vue';
import playIcon from '../../../../../assets/icon/video-play.png';
import { IVideoMessageContent } from '../../../../../interface';
interface IProps {
messageContent: Record<string, unknown> | IVideoMessageContent | undefined;
}
const props = withDefaults(defineProps<IProps>(), {
messageContent: () => ({}) as IVideoMessageContent,
});
const videoUrl = computed<string>(() => {
return (props.messageContent as IVideoMessageContent).snapshotUrl || (props.messageContent as IVideoMessageContent).url;
});
</script>
<style scoped lang="scss">
@import "../../../../../assets/styles/common";
.message-abstract-video {
max-width: 100px;
max-height: 100px;
width: 100px;
height: 100px;
overflow: hidden;
background-color: #fff;
&-box {
max-width: 100px;
max-height: 100px;
width: 100px;
height: 100px;
overflow: hidden;
background-color: #fff;
position: relative;
.video-snapshot {
max-width: 100px;
max-height: 100px;
width: 100px;
height: 100px;
object-fit: contain;
}
.video-play {
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
z-index: 3;
width: 35px;
height: 35px;
margin: auto;
}
}
}</style>

View File

@@ -0,0 +1,24 @@
.search-result-list-item-h5 {
padding: 10px 0;
border-radius: 0;
.bubble {
.bubble-left {
.bubble-left-avatar {
width: 48px;
height: 48px;
}
.bubble-main {
.bubble-main-name {
color: #333;
font-family: "PingFang SC", sans-serif;
font-size: 14px;
font-weight: 400;
letter-spacing: 0;
text-align: left;
}
}
}
}
}

View File

@@ -0,0 +1,3 @@
@import '../../../../../assets/styles/common';
@import './web';
@import './h5';

View File

@@ -0,0 +1,262 @@
.search-result-list-item {
padding: 8px 10px;
border-bottom: 1px solid rgba(0,0,0,0.1);
cursor: pointer;
&-image {
display: inline-block;
width: 100px;
height: 100px;
max-width: 100px;
max-height: 100px;
overflow: hidden;
box-sizing: content-box;
border: 1px solid #f1f1f1;
padding: 0;
margin: 5px;
}
&-file {
border: none;
}
.info {
display: flex;
flex-direction: row;
justify-content: center;
&-left {
&-avatar {
width: 36px;
height: 36px;
border-radius: 5px;
}
}
&-main {
flex: 1;
padding: 0 10px;
overflow: hidden;
&-name,
&-content {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-name {
color: #333;
font-size: 14px;
height: 20px;
}
&-content {
color: #999;
font-size: 12px;
.highlight {
color: #007aff;
}
.normal {
color: #999;
}
}
}
&-right {
width: fit-content;
&-time {
font-weight: 400;
font-size: 12px;
color: #999;
letter-spacing: 0;
white-space: nowrap;
}
}
}
.bubble {
display: flex;
flex-direction: row;
justify-content: center;
&-left {
&-avatar {
width: 36px;
height: 36px;
border-radius: 5px;
}
}
&-main {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 0 8px;
&-name {
max-width: 100%;
width: fit-content;
padding-bottom: 4px;
font-weight: 400;
font-size: 12px;
color: #999;
letter-spacing: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-content {
max-width: 100%;
width: fit-content;
box-sizing: border-box;
padding: 12px;
font-weight: 400;
font-size: 14px;
color: #000;
letter-spacing: 0;
word-wrap: break-word;
word-break: break-all;
overflow: hidden;
background: #eff0f1;
border-radius: 0 10px 10px;
.highlight {
background-color: #007aff33;
}
.normal {
color: #1f2329;
}
}
}
&-right {
display: flex;
flex-direction: column;
align-items: flex-end;
&-time {
font-weight: 400;
font-size: 12px;
color: #999;
letter-spacing: 0;
white-space: nowrap;
}
&-to {
cursor: pointer;
font-weight: 400;
font-size: 12px;
color: #007aff;
letter-spacing: 0;
white-space: nowrap;
}
}
}
.file {
display: flex;
flex-direction: column;
justify-content: center;
&-header {
flex: 1;
padding: 10px 0;
overflow: hidden;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
&-avatar {
width: 24px;
height: 24px;
border-radius: 4px;
margin-right: 3px;
}
&-name {
flex: 1;
}
&-name,
&-time,
&-to {
color: #666;
font-size: 14px;
height: 24px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-to {
cursor: pointer;
font-weight: 400;
color: #007aff;
letter-spacing: 0;
white-space: nowrap;
padding-right: 3px;
}
}
&-main-content {
padding: 10px;
background-color: #f1f1f1;
}
}
.image {
width: 100px;
height: 100px;
max-width: 100px;
max-height: 100px;
overflow: hidden;
box-sizing: content-box;
.image-container {
width: 100px;
height: 100px;
max-width: 100px;
max-height: 100px;
overflow: hidden;
position: relative;
.image-container-hover {
position: absolute;
bottom: 0;
width: 100%;
height: 40%;
background-color: rgba(0,0,0,0.3);
.image-container-hover-text {
width: 100%;
height: 100%;
font-size: 12px;
display: flex;
justify-content: center;
align-items: center;
color: #fff;
cursor: pointer;
user-select: none;
}
}
}
}
}
.hover-info {
border-radius: 5px;
background-color: #f5f5f5;
}
.hover-bubble {
background-color: #f5f5f5;
}

View File

@@ -0,0 +1,2 @@
import SearchResultLoading from './index.vue';
export default SearchResultLoading;

View File

@@ -0,0 +1,25 @@
<template>
<div :class="['search-result-loading', !isPC && 'search-result-loading-h5']">
<Loading
width="40px"
height="40px"
/>
</div>
</template>
<script setup lang="ts">
import Loading from '../../../common/Loading/index.vue';
import { isPC } from '../../../../utils/env';
</script>
<style scoped lang="scss">
.search-result-loading {
width: 100%;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
&-h5 {
background-color: #f4f4f4;
}
}
</style>

View File

@@ -0,0 +1,67 @@
.tui-search-result-h5 {
background-color: #f4f4f4;
.tui-search-result-main {
background-color: #f4f4f4;
.tui-search-result-list {
.tui-search-result-list-item {
background-color: #fff;
padding: 0 10px 10px;
border-radius: 5px;
margin-bottom: 10px;
}
}
}
.tui-search-result-detail {
background-color: #f4f4f4;
border: none;
.list-item {
margin: 0 10px;
width: calc(100% - 20px);
}
.list-group-date {
padding: 10px;
}
.list-group-image {
.list-group-item {
.search-result-list-item-h5 {
padding: 0;
}
}
}
.list-group-file {
.list-group-item {
background-color: #fff;
padding: 0 10px;
border-bottom: 1px solid #f4f4f4;
.search-result-list-item-h5 {
padding: 0 0 10px;
}
&:last-child {
border-bottom: none;
}
}
}
}
}
.search-result-loading,
.search-result-default {
width: 100%;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
&-h5 {
background-color: #f4f4f4;
}
}

View File

@@ -0,0 +1,3 @@
@import '../../../../assets/styles/common';
@import './web';
@import './h5';

View File

@@ -0,0 +1,180 @@
.tui-search-result {
overflow: hidden;
flex: 1;
display: flex;
width: 100%;
flex-direction: row;
box-sizing: border-box;
&-with-border {
border-top: 1px solid rgba(0,0,0,0.1);
}
&-detail {
width: 360px;
overflow-y: hidden;
border-left: 1px solid rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
flex: 1;
.tui-search-message-header {
padding: 10px;
display: flex;
flex-direction: row;
place-content: space-between space-between;
font-size: 14px;
align-items: center;
.header-content {
display: flex;
flex-flow: row nowrap;
flex: 1;
overflow: hidden;
color: #666;
white-space: nowrap;
.header-content-count {
width: fit-content;
white-space: nowrap;
}
.header-content-keyword {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-content-type {
width: 110px;
white-space: nowrap;
}
.normal {
color: #666;
}
.highlight {
color: #007aff;
}
}
.header-enter {
margin-left: 10px;
width: 70px;
color: #666;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
cursor: pointer;
}
}
.tui-search-message-list {
overflow-y: auto;
.list-item {
width: 100%;
flex: 1;
overflow: hidden;
}
.list-group {
&.list-group-image {
display: flex;
flex-flow: row wrap;
.list-group-item {
width: 111px;
height: 111px;
}
}
.list-group-date {
width: 100%;
box-sizing: border-box;
font-family: "PingFang SC", sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: 0;
text-align: left;
padding: 10px 10px 2px;
}
}
.more {
display: flex;
flex-direction: row;
font-size: 14px;
padding: 8px 0;
justify-content: center;
align-items: center;
user-select: none;
cursor: pointer;
.more-text {
padding-left: 8px;
font-size: 12px;
color: #007aff;
user-select: none;
}
}
}
}
&-main {
width: 350px;
padding: 10px;
overflow-y: auto;
display: flex;
flex-direction: column;
flex: 1;
.tui-search-result-list {
&-item {
.header {
font-size: 14px;
padding: 4px 0;
}
.list {
display: flex;
flex-direction: column;
.list-item {
cursor: pointer;
}
.list-item-selected {
background: #f2f2f2;
border-radius: 5px;
}
}
.more {
display: flex;
flex-direction: row;
font-size: 14px;
// padding: 8px 0;
padding-top: 10px;
user-select: none;
cursor: pointer;
.more-text {
padding-left: 8px;
font-size: 12px;
color: #007aff;
user-select: none;
}
}
}
}
}
.tui-search-result-in-conversation {
border: none;
}
}