<template> <div class="message-con d-flex align-items-center" :class="{ 'my-message flex-row-reverse': isMyMessage, 'justify-content-center': isWithdrawMessage, 'offset-bottom': matchKeywords, }" > <div class="msg-content" :class="{ 'algin-left': !isMyMessage }"> <div class="msg-name no-selection" :class="{ 'algin-left': !isMyMessage }" v-if="!isWithdrawMessage" > {{ userName }} </div> <div class="content-avatar d-flex align-items-start" :class="{ 'justify-content-end': isMyMessage }" > <img src="../imgs/default-host-avatar.svg" style="width: 42px" v-if=" !avatar && messageComponent && showHostAvatar && !isWithdrawMessage " class="host-avatar" /> <component :is="messageComponent" :user-name="userName" :class="{ 'my-message': isMyMessage, }" v-if="messageComponent" v-model="data" @open="openFile" /> <avatar v-if="avatar" :src="avatar" shape="circle" /> </div> </div> <i class="el-icon-warning text-danger" v-if="failed" title="发送失败" ></i> <i class="el-icon-loading" v-else-if="isSendingMessage"></i> <template v-if="backend && showReadSummary && !isWithdrawMessage"> <div v-if="isMyMessage" class="msg-read pos-rel"> <span @click="openReaderList" class="pointer" :class="{ all: isAllRead }" > <template v-if="isAllRead">全部已读</template> <template v-else-if="data.read_count > 0" >{{ data.read_count }}人已读</template > <template v-else>未读</template> </span> <who-read-list v-if="readListVisibility" @blur="readListVisibility = false" :msgId="data.id" :class="{ offset: readerListOffset }" /> </div> </template> <span class="withdraw" v-if="isMyMessage && canWithdraw && !isWithdrawMessage" @click="withdraw" >撤回此消息</span > <el-popover :visible-arrow="false" v-if="backend && matchKeywords" placement="right" popper-class="match-keyword-popover" trigger="click" :disabled="!!handled" > <ul class="keyword-action"> <li @click.stop="executeHandled">设为已处理</li> <li @click.stop="ignoredKeyword">忽略</li> <!-- <li>创建工作流</li> --> <!-- <li>更多</li> --> </ul> <span slot="reference" class="match-keyword d-flex align-items-center text-nowrap" :class="{ handled: handled === 1, ignored: handled === 2 }" > <i :class="handled ? 'el-icon-success' : 'el-icon-info'"></i> <span>{{ handled ? handled === 1 ? "已处理" : "已忽略" : "触发敏感词,请处理" }}</span> </span> </el-popover> </div> </template> <script lang="ts"> import { Component, Inject, Prop, Vue } from "vue-property-decorator"; import * as dto from "../model"; import chat from "./../xim"; import { isAudio, isImage, isVideo, MAX_FILE_SIZE, MAX_IMAGE_SIZE, } from "./message-item/file-controller"; import WhoReadList from "./who-read-list.vue"; import avatar from "@/customer-service/components/avatar.vue"; import { chatStore, ChatStore } from "@/customer-service/store/model"; import ximInstance from "../xim/xim"; import { dbController } from "../database"; import ImageMessage from "./message-item/image-message.vue"; import FileMessage from "./message-item/file-message.vue"; import AudioMessage from "./message-item/audio-message.vue"; import VideoMessage from "./message-item/video-message.vue"; import TextMessage from "./message-item/text-message.vue"; import ActionMessage from "./message-item/action-message.vue"; import WithdrawMessage from "./message-item/withdraw-message.vue"; import xim from "./../xim"; const twoMinutes = 2 * 60 * 1000; const messageMapping = new Map<dto.MessageType, string>([ [dto.MessageType.Image, "image-message"], [dto.MessageType.File, "file-message"], [dto.MessageType.Video, "video-message"], [dto.MessageType.Voice, "audio-message"], [dto.MessageType.Text, "text-message"], [dto.MessageType.Withdraw, "withdraw-message"], [dto.MessageType.GeneralOrderMsg, "text-message"], [dto.MessageType.Action, "action-message"], ]); @Component({ components: { WhoReadList, avatar, ImageMessage, FileMessage, AudioMessage, VideoMessage, TextMessage, WithdrawMessage, ActionMessage, }, }) export default class Message extends Vue { @chatStore.State(ChatStore.STATE_CHAT_CURRENT_USER_UID) private readonly chatMyId!: ChatStore.STATE_CHAT_CURRENT_USER_UID; @chatStore.Getter(ChatStore.GETTER_CURRENT_CHAT_PRESENT_MEMBERS) private readonly chatMembers!: ChatStore.GETTER_CURRENT_CHAT_PRESENT_MEMBERS; @chatStore.State(ChatStore.STATE_CHAT_SOURCE) private readonly chatSource!: ChatStore.STATE_CHAT_SOURCE; @chatStore.State(ChatStore.STATE_CHAT_CURRENT_CHAT_ID) private readonly chatId!: ChatStore.STATE_CHAT_CURRENT_CHAT_ID; @chatStore.Mutation(ChatStore.MUTATION_WITHDRAW) private readonly executeWithDraw!: ChatStore.MUTATION_WITHDRAW; @chatStore.Action(ChatStore.ACTION_SET_HANDLED) private readonly setHandled!: ChatStore.ACTION_SET_HANDLED; @Prop({ type: Object, default: () => Object.create(null) }) private readonly data!: dto.Message; @Prop() private readonly isSendingMessage!: boolean; @Prop() private readonly failed!: boolean; @Prop({ default: "circle" }) private readonly shape!: string; @Inject({ default: false }) readonly showReadSummary!: boolean; private readonly backend = chat.isBackend(); private messageComponent = ""; private readListVisibility = false; private org = ""; private readerListOffset = false; private defaultMessageHandledStatus = dto.MessageHandled.Default; private showHostAvatar: boolean = false; private get canWithdraw() { if (this.backend && this.data) { return new Date().valueOf() - this.data.ts * 1000 < twoMinutes; } return false; } private get isWithdrawMessage() { return this.data.type === dto.MessageType.Withdraw; } private get isAllRead() { return this.data.read_count >= this.data.total_read_count; } private get messageBody(): { eid?: string; oid?: string; msg: any } { if (this.data) { const msg = this.data.msg; try { if (msg.startsWith("{")) { return { ...this.data, msg: JSON.parse(this.data.msg) }; } return { ...this.data, msg: this.data.msg }; } catch { return { ...this.data, msg: JSON.parse(this.data.msg.replace(/\n/g, "\\n")), }; } } return { msg: { text: "" } }; } private get handled() { if (this.data) { return this.defaultMessageHandledStatus || this.data.handled; } return dto.MessageHandled.Default; } private get isMyMessage() { if (this.isSendingMessage) { return true; } const senderEid = this.messageBody.eid; return senderEid!.toString() === this.chatMyId!.toString(); } created() { this.messageComponent = messageMapping.get(this.messageType) as string; } private get userName() { if (this.chatMembers) { const t = this.chatMembers.find((i) => i.eid === this.data.eid); if (t) { return t.name; } } return ""; } private get avatar() { const avatar = chat.getUserMapping(); if (this.isSendingMessage) { if (Object.getOwnPropertyNames(avatar).length > 0) { this.showHostAvatar = true; } else { this.showHostAvatar = false; } if (avatar && this.chatMyId) { const user = avatar[this.chatMyId]; if (user && user.avatar) { return user.avatar; } } } if (avatar && this.data) { if (Object.getOwnPropertyNames(avatar).length > 0) { this.showHostAvatar = true; } else { this.showHostAvatar = false; } const value = avatar[this.data.eid]; if (value && value.avatar) { return value.avatar; } } return ""; } private get messageType() { const type = this.data?.type; if (type === "file") { const name = this.messageBody?.msg.name; if (name) { const size = this.messageBody?.msg.size; if (size) { const outImageSize = size > MAX_IMAGE_SIZE; if (!outImageSize && isImage(name)) { return dto.MessageType.Image; } const outSize = size > MAX_FILE_SIZE; if (!outSize) { if (isAudio(name)) { return dto.MessageType.Voice; } if (isVideo(name)) { return dto.MessageType.Video; } } } } } return type; } private get isTextMessage() { return this.messageType === dto.MessageType.Text; } private get matchKeywords() { if (this.isTextMessage && !this.isMyMessage) { const m = this.messageBody.msg as { text: string }; if (m && m.text) { const keywords = xim.getMatchedTextKeywords(); return keywords.find((i) => m.text.includes(i)); } } return false; } private isCustomer() { return !this.showReadSummary; } private openFile(url: string) { if (this.isSendingMessage) { return; } if (this.failed) { return; } const copy = { ...this.messageBody.msg }; copy.url = url; this.$emit("open", { type: this.messageType, msg: copy }); } private withdraw() { ximInstance.withdraw(this.chatId!, this.data.id).finally(() => { dbController .removeMessage(this.chatId!, this.data.id) .finally(() => { this.executeWithDraw(this.data.id); this.$emit("withdraw", this.data.id); }); }); } private openReaderList(e: MouseEvent) { this.readerListOffset = e.x < 450; this.readListVisibility = true; } private executeHandled() { this.setHandled({ id: this.data.id, value: (this.defaultMessageHandledStatus = dto.MessageHandled.Handled), }); this.closeKeywordPopover(); } private ignoredKeyword() { this.setHandled({ id: this.data.id, value: (this.defaultMessageHandledStatus = dto.MessageHandled.Ignored), }); this.closeKeywordPopover(); } private closeKeywordPopover() { document.body.click(); } } </script> <style lang="less" scoped> @import "./css/benefits-plan.less"; .message-con { padding-bottom: 20px; margin-right: 15px; position: relative; &.my-message { .msg-detail { background-color: #dbf2ff; border-radius: 8px 0 8px 8px; } .msg-read { display: inline-block; color: #bfe1ff; margin-right: 15px; user-select: none; flex: none; margin-top: auto; } .download-icon { margin-right: 15px; margin-left: 0; margin-top: 0; } .withdraw { color: #999; position: absolute; bottom: 0; right: 0; font-size: 12px; display: none; cursor: pointer; } &:hover { .withdraw { display: inline-block; } } /deep/ .file-message-info { text-align: initial; word-break: break-all; } } &.offset-bottom { margin-bottom: 30px; } > i { height: 16px; font-size: 16px; margin-right: 10px; } } .msg-name { font-size: 14px; color: #888; text-align: right; margin-bottom: 3px; min-height: 16px; &.algin-left { text-align: left; } } .msg-content { text-align: right; &.algin-left { text-align: left; } } .msg-detail { margin-top: 10px; font-size: 14px; line-height: 20px; background: #f5f6fa; border-radius: 0px 8px 8px; padding: 10px; word-break: break-word; /deep/ img { max-width: 300px; } } .download-icon { cursor: pointer; text-decoration: none; margin-left: 15px; margin-top: 42px; i { color: #fff; font-size: 14px; } } .no-selection { user-select: none; } .pointer { cursor: pointer; } .all { color: #4389f8; } .match-keyword { background-color: #fff3e0; border-radius: 13px; padding: 3px 8px; color: #e87005; position: absolute; bottom: -10px; left: 0; cursor: pointer; font-size: 12px; &.handled { color: #59ba7b; background-color: #f0f0f0; cursor: default; } &.ignored { color: #999; background-color: #f0f0f0; cursor: default; } i { margin-right: 5px; } } .keyword-action { list-style: none; li { list-style: none; padding: 8px 10px; color: #077aec; cursor: pointer; &:hover { background-color: #f5f6fa; } } } </style> <style lang="less"> .match-keyword-popover { padding: 0; } </style>