message-bubble.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. <template>
  2. <div :class="containerClassNameList">
  3. <!-- todo 统一组件处理-->
  4. <div
  5. class="message-bubble-main-content"
  6. :class="[message.flow === 'in' ? '' : 'reverse']"
  7. >
  8. <Avatar
  9. useSkeletonAnimation
  10. :url="message.avatar || ''"
  11. />
  12. <main
  13. class="message-body"
  14. @click.stop
  15. >
  16. <div
  17. v-if="message.flow === 'in' && message.conversationType === 'GROUP'"
  18. class="message-body-nick-name"
  19. >
  20. {{ props.content.showName }}
  21. </div>
  22. <div :class="['message-body-main', message.flow === 'out' && 'message-body-main-reverse']">
  23. <div
  24. :class="[
  25. 'blink',
  26. 'message-body-content',
  27. message.flow === 'out' ? 'content-out' : 'content-in',
  28. message.hasRiskContent && 'content-has-risk',
  29. isNoPadding ? 'content-no-padding' : '',
  30. isNoPadding && isBlink ? 'blink-shadow' : '',
  31. !isNoPadding && isBlink ? 'blink-content' : '',
  32. ]"
  33. >
  34. <div class="content-main">
  35. <img
  36. v-if="
  37. (message.type === TYPES.MSG_IMAGE || message.type === TYPES.MSG_VIDEO) &&
  38. message.hasRiskContent
  39. "
  40. :class="['message-risk-replace', !isPC && 'message-risk-replace-h5']"
  41. :src="riskImageReplaceUrl"
  42. >
  43. <template v-else>
  44. <slot />
  45. </template>
  46. </div>
  47. <!-- 敏感信息失败提示 -->
  48. <div
  49. v-if="message.hasRiskContent"
  50. class="content-has-risk-tips"
  51. >
  52. {{ riskContentText }}
  53. </div>
  54. </div>
  55. <!-- 发送失败 -->
  56. <div
  57. v-if="message.status === 'fail' || message.hasRiskContent"
  58. class="message-label fail"
  59. @click="resendMessage()"
  60. >
  61. !
  62. </div>
  63. <!-- 加载图标 -->
  64. <Icon
  65. v-if="message.status === 'unSend' && needLoadingIconMessageType.includes(message.type)"
  66. class="message-label loading-circle"
  67. :file="loadingIcon"
  68. :width="'15px'"
  69. :height="'15px'"
  70. />
  71. <!-- 已读 & 未读 -->
  72. <ReadStatus
  73. class="message-label align-self-bottom"
  74. :message="shallowCopyMessage(message)"
  75. @openReadUserPanel="openReadUserPanel"
  76. />
  77. </div>
  78. </main>
  79. </div>
  80. <!-- message extra area -->
  81. <div class="message-bubble-extra-content">
  82. <!-- extra: message translation -->
  83. <MessageTranslate
  84. :class="message.flow === 'out' ? 'reverse' : 'flex-row'"
  85. :message="message"
  86. />
  87. <!-- extra: message convert voice to text -->
  88. <MessageConvert
  89. :class="message.flow === 'out' ? 'reverse' : 'flex-row'"
  90. :message="message"
  91. />
  92. <!-- extra: message quote -->
  93. <MessageQuote
  94. :class="message.flow === 'out' ? 'reverse' : 'flex-row'"
  95. :message="message"
  96. @blinkMessage="blinkMessage"
  97. @scrollTo="scrollTo"
  98. />
  99. </div>
  100. </div>
  101. </template>
  102. <script lang="ts" setup>
  103. import { computed, toRefs } from '../../../../adapter-vue';
  104. import TUIChatEngine, { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
  105. import Icon from '../../../common/Icon.vue';
  106. import ReadStatus from './read-status/index.vue';
  107. import MessageQuote from './message-quote/index.vue';
  108. import Avatar from '../../../common/Avatar/index.vue';
  109. import MessageTranslate from './message-translate/index.vue';
  110. import MessageConvert from './message-convert/index.vue';
  111. import loadingIcon from '../../../../assets/icon/loading.png';
  112. import { shallowCopyMessage } from '../../utils/utils';
  113. import { isPC } from '../../../../utils/env';
  114. interface IProps {
  115. messageItem: IMessageModel;
  116. content?: any;
  117. blinkMessageIDList?: string[];
  118. classNameList?: string[];
  119. }
  120. interface IEmits {
  121. (e: 'resendMessage'): void;
  122. (e: 'blinkMessage', messageID: string): void;
  123. (e: 'setReadReceiptPanelVisible', visible: boolean, message?: IMessageModel): void;
  124. // 下面的方法是 uniapp 专属
  125. (e: 'scrollTo', scrollHeight: number): void;
  126. }
  127. const emits = defineEmits<IEmits>();
  128. const props = withDefaults(defineProps<IProps>(), {
  129. messageItem: () => ({} as IMessageModel),
  130. content: () => ({}),
  131. blinkMessageIDList: () => [],
  132. classNameList: () => [],
  133. });
  134. const TYPES = TUIChatEngine.TYPES;
  135. const riskImageReplaceUrl = 'https://web.sdk.qcloud.com/component/TUIKit/assets/has_risk_default.png';
  136. const needLoadingIconMessageType = [
  137. TYPES.MSG_LOCATION,
  138. TYPES.MSG_TEXT,
  139. TYPES.MSG_CUSTOM,
  140. TYPES.MSG_MERGER,
  141. TYPES.MSG_FACE,
  142. ];
  143. const { blinkMessageIDList, messageItem: message } = toRefs(props);
  144. const containerClassNameList = computed(() => {
  145. return ['message-bubble', ...props.classNameList];
  146. });
  147. const isNoPadding = computed(() => {
  148. return [TYPES.MSG_IMAGE, TYPES.MSG_VIDEO].includes(message.value.type);
  149. });
  150. const riskContentText = computed<string>(() => {
  151. let content = TUITranslateService.t('TUIChat.涉及敏感内容') + ', ';
  152. if (message.value.flow === 'out') {
  153. content += TUITranslateService.t('TUIChat.发送失败');
  154. } else {
  155. content += TUITranslateService.t(
  156. message.value.type === TYPES.MSG_AUDIO ? 'TUIChat.无法收听' : 'TUIChat.无法查看',
  157. );
  158. }
  159. return content;
  160. });
  161. const isBlink = computed(() => {
  162. if (message.value?.ID) {
  163. return blinkMessageIDList?.value?.includes(message.value.ID);
  164. }
  165. return false;
  166. });
  167. function resendMessage() {
  168. if (!message.value?.hasRiskContent) {
  169. emits('resendMessage');
  170. }
  171. }
  172. function blinkMessage(messageID: string) {
  173. emits('blinkMessage', messageID);
  174. }
  175. function scrollTo(scrollHeight: number) {
  176. emits('scrollTo', scrollHeight);
  177. }
  178. function openReadUserPanel() {
  179. emits('setReadReceiptPanelVisible', true, message.value);
  180. }
  181. </script>
  182. <style lang="scss" scoped>
  183. .flex-row {
  184. display: flex;
  185. }
  186. .reverse {
  187. display: flex;
  188. flex-direction: row-reverse;
  189. justify-content: flex-start;
  190. }
  191. .message-bubble {
  192. width: 100%;
  193. box-sizing: border-box;
  194. display: flex;
  195. flex-direction: column;
  196. padding: 0 20px 25px;
  197. user-select: none;
  198. -webkit-touch-callout: none; /* 系统默认菜单被禁用 */
  199. -webkit-user-select: none; /* webkit浏览器 */
  200. -khtml-user-select: none; /* 早期浏览器 */
  201. -moz-user-select: none;/* 火狐 */
  202. -ms-user-select: none; /* IE10 */
  203. .message-bubble-main-content {
  204. display: flex;
  205. .message-avatar {
  206. display: block;
  207. width: 36px;
  208. height: 36px;
  209. border-radius: 5px;
  210. flex: 0 0 auto;
  211. }
  212. .message-body {
  213. display: flex;
  214. flex: 0 1 auto;
  215. flex-direction: column;
  216. align-items: flex-start;
  217. margin: 0 8px;
  218. max-width: calc(100% - 54px);
  219. .message-body-nick-name {
  220. margin-bottom: 4px;
  221. font-size: 12px;
  222. color: #999;
  223. max-width: 150px;
  224. overflow: hidden;
  225. text-overflow: ellipsis;
  226. white-space: nowrap;
  227. }
  228. .message-body-main {
  229. max-width: 100%;
  230. display: flex;
  231. flex-direction: row;
  232. min-width: 0;
  233. box-sizing: border-box;
  234. &-reverse {
  235. flex-direction: row-reverse;
  236. }
  237. .message-body-content {
  238. display: flex;
  239. flex-direction: column;
  240. min-width: 0;
  241. box-sizing: border-box;
  242. padding: 12px;
  243. font-size: 14px;
  244. color: #000;
  245. letter-spacing: 0;
  246. word-wrap: break-word;
  247. word-break: break-all;
  248. position: relative;
  249. .content-main {
  250. box-sizing: border-box;
  251. display: flex;
  252. flex-direction: column;
  253. flex-shrink: 0;
  254. align-content: flex-start;
  255. border: 0 solid black;
  256. margin: 0;
  257. padding: 0;
  258. min-width: 0;
  259. .message-risk-replace {
  260. width: 130px;
  261. height: 130px;
  262. }
  263. }
  264. .content-has-risk-tips {
  265. font-size: 12px;
  266. color: #fa5151;
  267. font-family: PingFangSC-Regular;
  268. margin-top: 5px;
  269. border-top: 1px solid #e5c7c7;
  270. padding-top: 5px;
  271. }
  272. }
  273. .content-in {
  274. background: #fbfbfb;
  275. border-radius: 0 10px 10px;
  276. }
  277. .content-out {
  278. background: #fff;
  279. border-radius: 10px 0 10px 10px;
  280. }
  281. .content-no-padding {
  282. padding: 0;
  283. background: transparent;
  284. border-radius: 10px;
  285. overflow: hidden;
  286. }
  287. .content-no-padding.content-has-risk {
  288. padding: 12px;
  289. }
  290. .content-has-risk {
  291. background: rgba(250, 81, 81, 0.16);
  292. }
  293. .blink-shadow {
  294. @keyframes shadow-blink {
  295. 50% {
  296. box-shadow: rgba(255, 156, 25, 1) 0 0 10px 0;
  297. }
  298. }
  299. box-shadow: rgba(255, 156, 25, 0) 0 0 10px 0;
  300. animation: shadow-blink 1s linear 3;
  301. }
  302. .blink-content {
  303. @keyframes reference-blink {
  304. 50% {
  305. background-color: #ff9c19;
  306. }
  307. }
  308. animation: reference-blink 1s linear 3;
  309. }
  310. .message-label {
  311. align-self: flex-end;
  312. font-family: PingFangSC-Regular;
  313. font-size: 12px;
  314. color: #b6b8ba;
  315. word-break: keep-all;
  316. flex: 0 0 auto;
  317. margin: 0 8px;
  318. &.fail {
  319. width: 15px;
  320. height: 15px;
  321. border-radius: 15px;
  322. background: red;
  323. color: #fff;
  324. display: flex;
  325. justify-content: center;
  326. align-items: center;
  327. cursor: pointer;
  328. }
  329. &.loading-circle {
  330. opacity: 0;
  331. animation: circle-loading 2s linear 1s infinite;
  332. }
  333. @keyframes circle-loading {
  334. 0% {
  335. transform: rotate(0);
  336. opacity: 1;
  337. }
  338. 100% {
  339. opacity: 1;
  340. transform: rotate(360deg);
  341. }
  342. }
  343. }
  344. .align-self-bottom {
  345. align-self: flex-end;
  346. }
  347. }
  348. }
  349. }
  350. .message-bubble-extra-content {
  351. display: flex;
  352. flex-direction: column;
  353. }
  354. }
  355. </style>