index.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. <template>
  2. <SearchResultLoading
  3. v-if="isLoading"
  4. :class="['search-result-loading', !isPC && 'search-result-loading-h5']"
  5. />
  6. <SearchResultDefault
  7. v-else-if="isSearchDefaultShow"
  8. :class="['search-result-default', !isPC && 'search-result-default-h5']"
  9. />
  10. <div
  11. v-else
  12. :class="[
  13. 'tui-search-result',
  14. !isPC && 'tui-search-result-h5',
  15. isPC && isResultDetailShow && 'tui-search-result-with-border',
  16. ]"
  17. >
  18. <div
  19. v-if="props.searchType !== 'conversation' && (isPC || !isResultDetailShow)"
  20. class="tui-search-result-main"
  21. >
  22. <div class="tui-search-result-list">
  23. <div
  24. v-for="result in searchResult"
  25. :key="result.key"
  26. class="tui-search-result-list-item"
  27. >
  28. <div
  29. v-if="props.searchType === 'global'"
  30. class="header"
  31. >
  32. {{ TUITranslateService.t(`TUISearch.${result.label}`) }}
  33. </div>
  34. <div class="list">
  35. <div
  36. v-for="item in result.list"
  37. :key="item.conversation.conversationID"
  38. :class="[generateListItemClass(item)]"
  39. >
  40. <SearchResultItem
  41. v-if="result.key === 'contact' || result.key === 'group' || item.conversation"
  42. :listItem="item"
  43. :type="result.key"
  44. displayType="info"
  45. :keywordList="keywordList"
  46. @showResultDetail="showResultDetail"
  47. @navigateToChatPosition="navigateToChatPosition"
  48. />
  49. </div>
  50. </div>
  51. <div
  52. v-if="currentSearchTabKey === 'all' || result.cursor"
  53. class="more"
  54. @click="getMoreResult(result)"
  55. >
  56. <Icon
  57. class="more-icon"
  58. :file="searchIcon"
  59. width="12px"
  60. height="12px"
  61. />
  62. <div class="more-text">
  63. <span>{{ TUITranslateService.t("TUISearch.查看更多") }}</span>
  64. <span>{{ TUITranslateService.t(`TUISearch.${result.label}`) }}</span>
  65. </div>
  66. </div>
  67. </div>
  68. </div>
  69. </div>
  70. <div
  71. v-if="isResultDetailShow || props.searchType === 'conversation'"
  72. :class="[
  73. 'tui-search-result-detail',
  74. props.searchType === 'conversation' && 'tui-search-result-in-conversation',
  75. ]"
  76. >
  77. <SearchResultLoading
  78. v-if="isSearchDetailLoading"
  79. :class="['search-result-loading', !isPC && 'search-result-loading-h5']"
  80. />
  81. <div
  82. v-if="!isSearchDetailLoading && isResultDetailShow && props.searchType !== 'conversation'"
  83. class="tui-search-message-header"
  84. >
  85. <div class="header-content">
  86. <div class="header-content-count normal">
  87. <span>{{ searchConversationMessageTotalCount }}</span>
  88. <span>{{ TUITranslateService.t("TUISearch.条与") }}</span>
  89. </div>
  90. <div class="header-content-keyword">
  91. <span
  92. v-for="(keyword, index) in keywordList"
  93. :key="index"
  94. >
  95. <span class="normal">"</span>
  96. <span class="highlight">{{ keyword }}</span>
  97. <span class="normal">"</span>
  98. </span>
  99. </div>
  100. <div class="header-content-type normal">
  101. <span>{{
  102. TUITranslateService.t("TUISearch.相关的")
  103. }}</span>
  104. <span>{{
  105. TUITranslateService.t(
  106. `TUISearch.${currentSearchTabKey === "allMessage"
  107. ? "结果"
  108. : searchMessageTypeList[currentSearchTabKey].label
  109. }`
  110. )
  111. }}</span>
  112. </div>
  113. </div>
  114. <div
  115. class="header-enter"
  116. @click="enterConversation({ conversationID: currentSearchConversationID })"
  117. >
  118. <span>{{ TUITranslateService.t("TUISearch.进入聊天") }}</span>
  119. <Icon
  120. class="enter-icon"
  121. :file="enterIcon"
  122. width="14px"
  123. height="14px"
  124. />
  125. </div>
  126. </div>
  127. <div
  128. v-if="!isSearchDetailLoading &&
  129. searchConversationMessageList &&
  130. searchConversationMessageList[0]
  131. "
  132. class="tui-search-message-list"
  133. >
  134. <template
  135. v-if="props.searchType === 'global' ||
  136. (currentSearchTabKey !== 'imageMessage' && currentSearchTabKey !== 'fileMessage')
  137. "
  138. >
  139. <div
  140. v-for="item in searchConversationMessageList"
  141. :key="generateVueRenderKey(item.ID)"
  142. :class="['list-item']"
  143. >
  144. <SearchResultItem
  145. :listItem="item"
  146. :listItemContent="item.getMessageContent()"
  147. :type="currentSearchTabKey"
  148. :displayType="generateResultItemDisplayType()"
  149. :keywordList="keywordList"
  150. @showResultDetail="showResultDetail"
  151. @navigateToChatPosition="navigateToChatPosition"
  152. />
  153. </div>
  154. </template>
  155. <!-- Search within a conversation - messages such as files, pictures, and videos need to be displayed in groups according to time -->
  156. <template v-else>
  157. <div
  158. v-for="group in searchConversationResultGroupByDate"
  159. :key="generateVueRenderKey(group.date)"
  160. :class="['list-group', currentSearchTabKey === 'fileMessage'? 'list-group-file' : 'list-group-image']"
  161. >
  162. <div :class="['list-group-date']">
  163. {{ group.date }}
  164. </div>
  165. <div
  166. v-for="item in group.list"
  167. :key="generateVueRenderKey(item.ID)"
  168. :class="['list-group-item']"
  169. >
  170. <SearchResultItem
  171. :listItem="item"
  172. :listItemContent="item.getMessageContent()"
  173. :type="currentSearchTabKey"
  174. :displayType="generateResultItemDisplayType()"
  175. :keywordList="keywordList"
  176. @showResultDetail="showResultDetail"
  177. @navigateToChatPosition="navigateToChatPosition"
  178. />
  179. </div>
  180. </div>
  181. </template>
  182. <div
  183. v-if="searchConversationResult && searchConversationResult.cursor"
  184. class="more"
  185. @click="getMoreResultInConversation"
  186. >
  187. <Icon
  188. class="more-icon"
  189. :file="searchIcon"
  190. width="12px"
  191. height="12px"
  192. />
  193. <div class="more-text">
  194. {{ TUITranslateService.t("TUISearch.查看更多历史记录") }}
  195. </div>
  196. </div>
  197. </div>
  198. </div>
  199. </div>
  200. </template>
  201. <script setup lang="ts">
  202. import { ref, watch, computed, onMounted, onUnmounted } from '../../../adapter-vue';
  203. import {
  204. TUITranslateService,
  205. TUIConversationService,
  206. TUIStore,
  207. StoreName,
  208. IMessageModel,
  209. SearchCloudMessagesParams,
  210. } from '@tencentcloud/chat-uikit-engine';
  211. import { TUIGlobal } from '@tencentcloud/universal-api';
  212. import SearchResultItem from './search-result-item/index.vue';
  213. import SearchResultDefault from './search-result-default/index.vue';
  214. import SearchResultLoading from './search-result-loading/index.vue';
  215. import { searchMessageTypeList, searchMessageTypeDefault } from '../search-type-list';
  216. import Icon from '../../common/Icon.vue';
  217. import searchIcon from '../../../assets/icon/search.svg';
  218. import enterIcon from '../../../assets/icon/right-icon.svg';
  219. import {
  220. searchCloudMessages,
  221. enterConversation,
  222. generateSearchResultYMD,
  223. debounce,
  224. } from '../utils';
  225. import { enableSampleTaskStatus } from '../../../utils/enableSampleTaskStatus';
  226. import { isPC, isUniFrameWork } from '../../../utils/env';
  227. import { SEARCH_TYPE, ISearchInputValue, ISearchMessageType, ISearchMessageTime } from '../type';
  228. import { ISearchCloudMessageResult, ISearchResultListItem } from '../../../interface';
  229. const props = defineProps({
  230. searchType: {
  231. type: String,
  232. default: 'global', // "global" / "conversation"
  233. validator(value: string) {
  234. return ['global', 'conversation'].includes(value);
  235. },
  236. },
  237. });
  238. // search params
  239. const keywordList = ref<string[]>([]);
  240. const messageTypeList = ref<string | string[]>(
  241. searchMessageTypeDefault[props.searchType as SEARCH_TYPE]?.value as string[],
  242. );
  243. const timePosition = ref<number>(0);
  244. const timePeriod = ref<number>(0);
  245. // Search by "and" after splitting the whole string by space
  246. // For example: enter "111 222", search for messages with both 111 and 222, and also include messages that strictly search for "111 222"
  247. const keywordListMatchType = ref<string>('and');
  248. // current search tab key
  249. const currentSearchTabKey = ref<string>(
  250. searchMessageTypeDefault[props.searchType as SEARCH_TYPE]?.key,
  251. );
  252. // search results all
  253. const searchResult = ref<{
  254. [propsName: string]: { key: string; label: string; list: ISearchResultListItem[]; cursor: string | null };
  255. }>({});
  256. const searchAllMessageList = ref<ISearchResultListItem[]>([]);
  257. const searchAllMessageTotalCount = ref<number>(0);
  258. // search results detail
  259. const currentSearchConversationID = ref<string>('');
  260. const searchConversationResult = ref<ISearchCloudMessageResult>();
  261. const searchConversationMessageList = ref<IMessageModel[]>([]);
  262. const searchConversationMessageTotalCount = ref<number>();
  263. // search results for file messages/image and video messages, grouped by timeline
  264. const searchConversationResultGroupByDate = ref<
  265. Array<{ date: string; list: IMessageModel[] }>
  266. >([]);
  267. // ui display control
  268. const isResultDetailShow = ref<boolean>(false);
  269. const isLoading = ref<boolean>(false);
  270. const isSearchDetailLoading = ref<boolean>(false);
  271. const isSearchDefaultShow = computed((): boolean => {
  272. if (isLoading.value) {
  273. return false;
  274. }
  275. if (props.searchType === 'global') {
  276. if (!keywordList?.value?.length || Object?.keys(searchResult.value)?.length) {
  277. return false;
  278. } else {
  279. return true;
  280. }
  281. } else {
  282. if (searchConversationMessageList?.value?.length) {
  283. return false;
  284. } else {
  285. return true;
  286. }
  287. }
  288. });
  289. function onCurrentConversationIDUpdate(id: string) {
  290. props.searchType === 'conversation' && (currentSearchConversationID.value = id);
  291. }
  292. function onCurrentSearchInputValueUpdate(obj: ISearchInputValue) {
  293. if (obj?.searchType === props?.searchType) {
  294. keywordList.value = obj?.value ? obj.value.trim().split(/\s+/) : [];
  295. }
  296. }
  297. function onCurrentSearchMessageTypeUpdate(typeObject: ISearchMessageType) {
  298. if (typeObject?.searchType === props?.searchType) {
  299. currentSearchTabKey.value
  300. = typeObject?.value?.key || searchMessageTypeDefault[props.searchType as SEARCH_TYPE]?.key;
  301. messageTypeList.value
  302. = typeObject?.value?.value
  303. || searchMessageTypeDefault[props.searchType as SEARCH_TYPE]?.value;
  304. }
  305. }
  306. function onCurrentSearchMessageTimeUpdate(timeObject: ISearchMessageTime) {
  307. if (timeObject?.searchType === props?.searchType) {
  308. timePosition.value = timeObject?.value?.value?.timePosition;
  309. timePeriod.value = timeObject?.value?.value?.timePeriod;
  310. }
  311. }
  312. onMounted(() => {
  313. TUIStore.watch(StoreName.CONV, {
  314. currentConversationID: onCurrentConversationIDUpdate,
  315. });
  316. TUIStore.watch(StoreName.SEARCH, {
  317. currentSearchInputValue: onCurrentSearchInputValueUpdate,
  318. currentSearchMessageType: onCurrentSearchMessageTypeUpdate,
  319. currentSearchMessageTime: onCurrentSearchMessageTimeUpdate,
  320. });
  321. });
  322. onUnmounted(() => {
  323. TUIStore.unwatch(StoreName.CONV, {
  324. currentConversationID: onCurrentConversationIDUpdate,
  325. });
  326. TUIStore.unwatch(StoreName.SEARCH, {
  327. currentSearchInputValue: onCurrentSearchInputValueUpdate,
  328. currentSearchMessageType: onCurrentSearchMessageTypeUpdate,
  329. currentSearchMessageTime: onCurrentSearchMessageTimeUpdate,
  330. });
  331. });
  332. const setMessageSearchResultList = (option?: { conversationID?: string | undefined; cursor?: string | undefined }) => {
  333. searchCloudMessages(
  334. {
  335. keywordList: keywordList?.value?.length ? keywordList.value : undefined,
  336. messageTypeList: typeof messageTypeList.value === 'string' ? [messageTypeList.value] : messageTypeList.value,
  337. timePosition: timePosition.value,
  338. timePeriod: timePeriod.value,
  339. conversationID: option?.conversationID || undefined,
  340. cursor: option?.cursor || undefined,
  341. keywordListMatchType: keywordListMatchType.value,
  342. } as SearchCloudMessagesParams,
  343. )
  344. .then((res: { data: ISearchCloudMessageResult }) => {
  345. enableSampleTaskStatus('searchCloudMessage');
  346. if (!option?.conversationID) {
  347. option?.cursor
  348. ? (searchAllMessageList.value = [
  349. ...searchAllMessageList.value,
  350. ...res.data.searchResultList,
  351. ])
  352. : (searchAllMessageList.value = res?.data?.searchResultList);
  353. searchAllMessageTotalCount.value = res?.data?.totalCount;
  354. const key = currentSearchTabKey.value === 'all' ? 'allMessage' : currentSearchTabKey.value;
  355. if (
  356. searchAllMessageList?.value?.length
  357. && currentSearchTabKey.value !== 'contact'
  358. && currentSearchTabKey.value !== 'group'
  359. ) {
  360. searchResult.value = Object.assign({}, searchResult.value, {
  361. [key]: {
  362. key,
  363. label: searchMessageTypeList[key].label,
  364. list: currentSearchTabKey.value === 'all'
  365. ? searchAllMessageList?.value?.slice(0, 3)
  366. : searchAllMessageList?.value,
  367. cursor: res?.data?.cursor || null,
  368. },
  369. });
  370. } else {
  371. delete searchResult?.value[key];
  372. }
  373. } else {
  374. searchConversationResult.value = res?.data;
  375. option?.cursor
  376. ? (searchConversationMessageList.value = [
  377. ...searchConversationMessageList.value,
  378. ...(res?.data?.searchResultList[0]?.messageList as IMessageModel[]),
  379. ])
  380. : (searchConversationMessageList.value = res?.data?.searchResultList[0]?.messageList);
  381. searchConversationMessageTotalCount.value = res?.data?.totalCount;
  382. if (
  383. props?.searchType === 'conversation'
  384. && (currentSearchTabKey.value === 'fileMessage'
  385. || currentSearchTabKey.value === 'imageMessage')
  386. ) {
  387. searchConversationResultGroupByDate.value = groupResultListByDate(
  388. searchConversationMessageList.value,
  389. );
  390. } else {
  391. searchConversationResultGroupByDate.value = [];
  392. }
  393. }
  394. isLoading.value = false;
  395. isSearchDetailLoading.value = false;
  396. });
  397. };
  398. const setMessageSearchResultListDebounce = debounce(setMessageSearchResultList, 500);
  399. const resetSearchResult = () => {
  400. searchResult.value = {};
  401. searchConversationResult.value = {} as ISearchCloudMessageResult;
  402. searchConversationMessageList.value = [];
  403. searchConversationResultGroupByDate.value = [];
  404. };
  405. watch(
  406. () => [keywordList.value, currentSearchTabKey.value, timePosition.value, timePeriod.value],
  407. (newValue, oldValue) => {
  408. if (newValue === oldValue) {
  409. return;
  410. }
  411. // Global search must have keywords, but search in conversation can be without keywords
  412. if (!keywordList?.value?.length && props?.searchType === 'global') {
  413. resetSearchResult();
  414. return;
  415. }
  416. isLoading.value = true;
  417. if (props.searchType === 'conversation') {
  418. resetSearchResult();
  419. setMessageSearchResultList({
  420. conversationID: currentSearchConversationID.value,
  421. });
  422. } else {
  423. if (oldValue && oldValue[1] === 'all' && newValue && newValue[1] === 'allMessage') {
  424. searchResult?.value['allMessage']?.list
  425. && (searchResult.value['allMessage'].list = searchAllMessageList?.value);
  426. Object?.keys(searchResult?.value)?.forEach((key: string) => {
  427. if (key !== 'allMessage') {
  428. delete searchResult?.value[key];
  429. }
  430. });
  431. isLoading.value = false;
  432. return;
  433. } else {
  434. isResultDetailShow.value = false;
  435. resetSearchResult();
  436. }
  437. setMessageSearchResultListDebounce();
  438. }
  439. },
  440. { immediate: true },
  441. );
  442. const getMoreResult = (result: { key: string; label: string; list: ISearchResultListItem[]; cursor: string | null }) => {
  443. if (currentSearchTabKey.value === 'all') {
  444. // View more at this time: Switch to the result corresponding to the corresponding result to display the full search results of its type
  445. TUIStore.update(StoreName.SEARCH, 'currentSearchMessageType', {
  446. value: searchMessageTypeList[result.key],
  447. searchType: props.searchType,
  448. });
  449. } else {
  450. // View more results for a single category: Use the cursor as the search start position to pull the next part of the results
  451. setMessageSearchResultList({ cursor: result?.cursor || undefined });
  452. }
  453. };
  454. const getMoreResultInConversation = () => {
  455. setMessageSearchResultList({
  456. cursor: searchConversationResult?.value?.cursor,
  457. conversationID: currentSearchConversationID?.value,
  458. });
  459. };
  460. const showResultDetail = (isShow: boolean, targetType?: string, targetResult?: IMessageModel | ISearchResultListItem) => {
  461. isResultDetailShow.value = isShow;
  462. if (targetType) {
  463. TUIStore.update(StoreName.SEARCH, 'currentSearchMessageType', {
  464. value: searchMessageTypeList[targetType],
  465. searchType: props.searchType,
  466. });
  467. }
  468. currentSearchConversationID.value = (targetResult as ISearchResultListItem)?.conversation?.conversationID || '';
  469. searchConversationMessageTotalCount.value = (targetResult as ISearchResultListItem)?.messageCount;
  470. if (targetResult) {
  471. isSearchDetailLoading.value = true;
  472. setMessageSearchResultListDebounce({
  473. conversationID: currentSearchConversationID.value,
  474. });
  475. }
  476. };
  477. const generateListItemClass = (item: ISearchResultListItem): string[] => {
  478. return currentSearchConversationID.value === item?.conversation?.conversationID
  479. ? ['list-item', 'list-item-selected']
  480. : ['list-item'];
  481. };
  482. const generateResultItemDisplayType = (): string => {
  483. if (props.searchType === 'conversation' && currentSearchTabKey.value === 'fileMessage') {
  484. return 'file';
  485. } else if (props.searchType === 'conversation' && currentSearchTabKey.value === 'imageMessage') {
  486. return 'image';
  487. } else if (isPC) {
  488. return 'bubble';
  489. } else {
  490. return 'info';
  491. }
  492. };
  493. const groupResultListByDate = (
  494. messageList: IMessageModel[],
  495. ): Array<{ date: string; list: IMessageModel[] }> => {
  496. const result: Array<{ date: string; list: IMessageModel[] }> = [];
  497. if (!messageList?.length) {
  498. return result;
  499. } else if (messageList?.length === 1) {
  500. result.push({ date: generateSearchResultYMD(messageList[0]?.time), list: messageList });
  501. return result;
  502. }
  503. let prevYMD = '';
  504. let currYMD = '';
  505. for (let i = 0; i < messageList?.length; i++) {
  506. currYMD = generateSearchResultYMD(messageList[i]?.time);
  507. if (prevYMD !== currYMD) {
  508. result.push({ date: currYMD, list: [messageList[i]] });
  509. } else {
  510. result[result?.length - 1]?.list?.push(messageList[i]);
  511. }
  512. prevYMD = currYMD;
  513. }
  514. return result;
  515. };
  516. const navigateToChatPosition = (message: IMessageModel) => {
  517. if (props.searchType === 'global') {
  518. // Global search, close the search container
  519. TUIStore.update(StoreName.SEARCH, 'currentSearchingStatus', {
  520. isSearching: false,
  521. searchType: props.searchType,
  522. });
  523. // switch conversation
  524. TUIConversationService.switchConversation(message?.conversationID).then(() => {
  525. TUIStore.update(StoreName.CHAT, 'messageSource', message);
  526. isUniFrameWork && TUIGlobal?.navigateTo({
  527. url: '/TUIKit/components/TUIChat/index',
  528. });
  529. });
  530. } else if (props.searchType === 'conversation') {
  531. // Search in conversation, close the search container
  532. TUIStore.update(StoreName.SEARCH, 'isShowInConversationSearch', false);
  533. TUIStore.update(StoreName.CHAT, 'messageSource', message);
  534. isUniFrameWork && TUIGlobal?.navigateBack();
  535. }
  536. };
  537. const generateVueRenderKey = (value: string): string => {
  538. return `${currentSearchTabKey}-${value}`;
  539. };
  540. </script>
  541. <style lang="scss" scoped src="./style/index.scss"></style>