Commit 020bfda3 by panjiangyi

init

parents
<template>
<div class="chat-list-con h-100">
<div class="chat-list h-100">
<el-input
class="keyword-input"
placeholder="昵称、手机、Email、备注"
prefix-icon="el-icon-search"
v-model="searchKeyword"
v-on:keyup.enter.native="search"
clearable
@clear="search"
></el-input>
<div class="chat-list-scroll">
<el-scrollbar class="h-100 no-bottom-scrollbar">
<div
v-for="item in chatRooms"
:key="item.chat_id"
class="chat-item"
:class="{ selected: isSelected(item) }"
@click="goToChatRoom(item)"
>
<div
class="chat-avatar pos-rel"
:class="{ 'red-dot': item.unread_msg_count > 0 }"
>
<fs-avatar
shape="circle"
:size="36"
:src="item.customer_avatar_url"
/>
</div>
<div class="chat-info">
<div
class="
chat-info-left
d-flex
justify-content-between
align-items-center
"
>
<div
:title="item.customer_name"
class="chat-name flex-fill text-dot-dot-dot"
>
<!-- <span>{{
item.customer_name ||
item.customer_mobile ||
item.customer_eid
}}</span> -->
<span>{{ item.chat_id }}</span>
</div>
<div v-if="item.last_msg_ts" class="chat-time">
{{ formatTimestamp(item.last_msg_ts) }}
</div>
</div>
<div class="chat-msg text-dot-dot-dot">
{{ parseMesage(item) }}
</div>
</div>
</div>
<div
class="empty"
v-if="chatRooms && chatRooms.length <= 0"
>
{{ searchKeyword ? "无相关接待" : "无接待" }}
</div>
</el-scrollbar>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Watch } from "vue-property-decorator"
import { chatStore, ChatStore } from "@/customer-service/store/model"
// import { popupService } from "@/views/common-module/component/element-upgrades/fast-service-popup";
import buttonThrottle from "@/utils/button-throttle"
import Chat from "@/customer-service/xim"
import { formatTime, TimeFormatRule } from "@/customer-service/utils/time"
export function parserMessage(type: string, rawMsg: string) {
if (!type) return ""
if (!rawMsg) return ""
const msg = JSON.parse(rawMsg)
if (type === "text") {
return msg.text
} else if (type === "image") {
return `[图片]`
} else if (type === "file") {
return `[文件]`
} else {
;`[不支持的消息格式]`
}
}
type Chat = ChatStore.STATE_MY_CHAT_ROOM_LIST["list"][number]
@Component({ components: {} })
export default class ChatList extends Vue {
@chatStore.Action(ChatStore.ACTION_GET_MY_CHAT_LIST)
private readonly getMyChatList!: ChatStore.ACTION_GET_MY_CHAT_LIST
@chatStore.State(ChatStore.STATE_CHAT_CURRENT_CHAT_ID)
private readonly chatId!: ChatStore.STATE_CHAT_CURRENT_CHAT_ID
@chatStore.State(ChatStore.STATE_CHAT_CURRENT_CHAT_UNIPLAT_ID)
private readonly currentChatUniplatId!: ChatStore.STATE_CHAT_CURRENT_CHAT_UNIPLAT_ID
@chatStore.State(ChatStore.STATE_CHAT_CURRENT_CHAT_VERSION)
private readonly uniplatVersion!: ChatStore.STATE_CHAT_CURRENT_CHAT_VERSION
@chatStore.State(ChatStore.STATE_MY_CHAT_ROOM_LIST)
private readonly chatList!: ChatStore.STATE_MY_CHAT_ROOM_LIST
@chatStore.Action(ChatStore.ACTION_SAVE_CURRENT_CHAT_ID_VERSION)
private readonly saveChatId!: ChatStore.ACTION_SAVE_CURRENT_CHAT_ID_VERSION
@chatStore.Mutation(ChatStore.MUTATION_SAVE_MYSELF_ID)
private readonly saveMyId!: ChatStore.MUTATION_SAVE_MYSELF_ID
@chatStore.Mutation(ChatStore.MUTATION_SET_CHAT_SOURCE)
private readonly setSource!: ChatStore.MUTATION_SET_CHAT_SOURCE
@chatStore.Mutation(ChatStore.MUTATION_SAVE_CHAT_TITLE)
private readonly saveChatTitle!: ChatStore.MUTATION_SAVE_CHAT_TITLE
@Prop({ type: String, default: "-1" })
private selected!: string
private searchKeyword = ""
private get chatRooms() {
return this.chatList?.list || []
}
private isSelected(item: Chat) {
if (this.currentChatUniplatId) {
return item.uniplatId === this.currentChatUniplatId
}
return this.selected === item.uniplatId
}
async created() {
await this.getMyChatList()
this.setSource(ChatStore.StateChatSourceDirection.Server)
this.selectFirstChat()
}
mounted() {
this.saveMyId()
this.goToOnlyRoom()
}
private goToOnlyRoom() {
if (this.chatRooms.length === 1) {
const wantedChat = this.chatRooms[0]
this.goToChatRoom(wantedChat)
}
}
private selectFirstChat() {
if (this.chatId != null) return
if (!this.chatRooms.length) return
const { chat_id, uniplat_version, uniplatId } = this.chatRooms[0]
this.saveChatId({
chatId: chat_id,
v: uniplat_version,
uniplatId,
})
}
@buttonThrottle()
private async search() {
this.searchKeyword = this.searchKeyword.trim()
if (!this.searchKeyword) {
await this.getMyChatList()
} else {
await this.getMyChatList(this.searchKeyword)
}
}
private goToChatRoom(data: Chat) {
if (this.currentChatUniplatId === data.uniplatId) {
return
}
this.saveChatId({
chatId: this.chatRooms.find((k) => k.uniplatId === data.uniplatId)
.chat_id,
v: data.uniplat_version,
uniplatId: data.uniplatId,
}).finally(this.raiseChatIdChanged)
this.saveChatTitle(data.uniplatId)
}
private raiseChatIdChanged() {
this.$emit("change")
}
private parseMesage(data: Chat) {
return parserMessage(data.msg_type, data.msg)
}
private formatTimestamp(v: number) {
return formatTime(v, { short: true, rule: TimeFormatRule.Hour12 })
}
}
</script>
<style lang="scss" scoped>
.chat-list-con {
.title {
padding-left: 20px;
line-height: 59px;
font-size: 18px;
border-bottom: 1px solid #e1e1e1;
}
}
.chat-list {
text-align: center;
}
.chat-list-scroll {
height: calc(100% - 50px);
.empty {
margin-top: 100%;
}
}
.keyword-input {
width: 200px;
margin: 15px 0;
/deep/ .el-input__inner {
font-size: 13px;
height: 30px;
line-height: 30px;
border-radius: 15px;
padding-right: 15px;
}
/deep/ .el-icon-time {
background: transparent;
}
}
.chat-list {
.chat-item {
cursor: pointer;
padding: 10px 15px;
&:hover {
background: #e4f0ff;
}
&.selected {
background: #f0f0f0;
}
.chat-avatar {
display: inline-block;
vertical-align: middle;
width: 36px;
height: 36px;
margin-right: 15px;
&.red-dot::before {
content: "";
position: absolute;
width: 8px;
height: 8px;
background: #e87005;
border-radius: 50%;
z-index: 1;
right: -4px;
top: -4px;
}
}
.chat-info {
display: inline-block;
vertical-align: middle;
width: calc(100% - 51px);
}
.chat-info-left {
text-align: start;
font-size: 14px;
line-height: 20px;
color: #333333;
margin-bottom: 10px;
.chat-time {
text-align: end;
flex: none;
color: #999999;
margin-left: 10px;
font-size: 13px;
line-height: 1;
}
}
.chat-msg {
color: #888;
text-align: start;
height: 16px;
margin-top: -3px;
}
}
}
</style>
<template>
<div class="chat-room-con h-100">
<div
class="room-title d-flex justify-content-between align-items-center"
>
<div class="title" @click="showMembers">
{{ chatTitle }}
<template v-if="chatMembers.length">
<span class="members-count"
>(成员{{ chatMembers.length }}人)</span
>
<i
v-if="membersPanelVisibility"
class="title-right-arrow fast-service-icon-arrow-up"
/>
<i
v-else
class="title-right-arrow fast-service-icon-arrow-down"
/>
</template>
<template v-if="!notOnlyCheck">
<div
v-if="currentChat.is_finish"
class="chat-status chat-done"
>
已完成
</div>
<div v-else class="chat-status">接待中</div>
</template>
</div>
<i v-if="close" @click="close" class="title-close el-icon-close" />
</div>
<div v-if="membersPanelVisibility" class="chat-members">
<div class="chat-member" v-for="item in chatMembers" :key="item.id">
<fs-avatar shape="circle" :src="item.avatar" :size="40" />
<div class="member-name">
{{ item.name || item.eid }}
</div>
</div>
</div>
<div class="chat-panel">
<div class="chat-area h-100">
<template v-if="notOnlyCheck">
<div class="chat-messages pos-rel">
<div
v-if="getCurrentInputingPeople.length"
class="someone-inputing"
>
{{ getCurrentInputingPeople }}正在输入
</div>
<messages />
</div>
<div class="chat-input">
<message-input @error="onError" />
</div>
</template>
<template v-else>
<messages />
</template>
</div>
<!-- <div class="chat-info h-100 pos-rel">
<div class="info-tabs">
<div
@click="activeTab = 'customer'"
:class="{ active: customerInfoTabShow }"
class="info-tab"
>
资料
</div>
<div
@click="activeTab = 'order'"
:class="{ active: orderInfoTabShow }"
class="info-tab"
>
订单
</div>
</div> -->
<!-- <cusomter-info v-if="customerInfoTabShow" /> -->
<!-- <div v-else class="order-info-con">
<order-info />
</div> -->
<!-- </div> -->
</div>
</div>
</template>
<script lang="ts">
import {
Component,
Watch,
Vue,
Ref,
Provide,
Prop,
} from "vue-property-decorator"
import MessageInput from "@/customer-service/message-input.vue"
import messages from "@/customer-service/message-list.vue"
// import CusomterInfo from "./customer-info.vue"
// import OrderInfo from "./order-info.vue"
import { ChatStore, chatStore } from "@/customer-service/store/model"
type RoomInfoTab = "customer" | "order"
@Component({
components: {
MessageInput,
messages,
// CusomterInfo,
// OrderInfo,
},
})
export default class ChatRoom extends Vue {
@chatStore.Getter(ChatStore.GETTER_CURRENT_CHAT_PRESENT_MEMBERS)
private readonly chatMembers!: ChatStore.GETTER_CURRENT_CHAT_PRESENT_MEMBERS
@chatStore.Mutation(ChatStore.MUTATION_CLEAR_CURRENT_CHAT_MEMBERS)
private readonly clearChatMembers!: ChatStore.MUTATION_CLEAR_CURRENT_CHAT_MEMBERS
@chatStore.State(ChatStore.STATE_CURRENT_CHAT_TITLE)
private readonly chatTitle!: ChatStore.STATE_CURRENT_CHAT_TITLE
@chatStore.State(ChatStore.STATE_CURRENT_CHAT_INPUTING)
private readonly currentInputPeople!: ChatStore.STATE_CURRENT_CHAT_INPUTING
@chatStore.State(ChatStore.STATE_CHAT_CURRENT_CHAT_UNIPLAT_ID)
private readonly currentChatUniplatId!: ChatStore.STATE_CHAT_CURRENT_CHAT_UNIPLAT_ID
@chatStore.State(ChatStore.STATE_MY_CHAT_ROOM_LIST)
private readonly myChatList!: ChatStore.STATE_MY_CHAT_ROOM_LIST
private allChatList = { list: [] }
@Prop({ type: Function })
private close?: () => void
@Provide() showReadSummary = true
@Watch("currentChatUniplatId")
private whenCurrentChatIdChanged(newValue: string, oldValue: string) {
if (Number(oldValue) === Number(newValue)) return
this.hideMembers()
this.clearChatMembers()
}
private activeTab: RoomInfoTab = "customer"
private membersPanelVisibility = false
private get getCurrentInputingPeople() {
return this.currentInputPeople
.map((k) => "" /* this.userInfo[k].name */)
.join("、")
}
private get currentChat() {
const chatId = this.currentChatUniplatId
let result = this.myChatList.list.find((k) => k.uniplatId === chatId)
if (result) return result
result = this.allChatList.list.find((k) => k.uniplatId === chatId)
return result ?? {}
}
private get notOnlyCheck(): boolean {
return true
}
private get customerInfoTabShow() {
return this.activeTab === "customer"
}
private get orderInfoTabShow() {
return this.activeTab === "order"
}
private showMembers() {
this.membersPanelVisibility = !this.membersPanelVisibility
}
private hideMembers() {
this.membersPanelVisibility = false
}
private onError(msg: string) {
console.error(msg)
this.$message.error(msg)
}
}
</script>
<style lang="scss" scoped>
.room-title {
font-size: 16px;
padding: 0 20px;
height: 60px;
border-bottom: 1px solid #e1e1e1;
.title {
cursor: pointer;
}
.members-count {
color: #666666;
}
.title-right-arrow {
font-size: 10px;
margin-left: 10px;
vertical-align: middle;
color: #666;
}
.title-close {
color: #8d959d;
cursor: pointer;
}
}
.chat-status {
display: inline-block;
width: 46px;
height: 20px;
line-height: 20px;
background: #22bd7a;
font-size: 13px;
border-radius: 2px;
color: #ffffff;
text-align: center;
margin-left: 10px;
&.chat-done {
background: #c5d4e5;
}
}
.chat-members {
position: absolute;
width: calc(100% - 350px);
padding: 30px;
padding-bottom: 0;
background: #fff;
z-index: 1;
border-bottom: 1px solid #f0f0f0;
.chat-member {
text-align: center;
display: inline-block;
vertical-align: top;
margin-bottom: 30px;
margin-right: 30px;
}
.member-name {
margin-top: 10px;
}
}
.chat-panel {
height: calc(100% - 60px);
.chat-area,
.chat-info {
display: inline-block;
vertical-align: top;
}
.chat-area {
width: 100%;
}
.chat-info {
width: 349px;
border-left: 1px solid #e1e1e1;
}
}
.info-tabs {
height: 40px;
line-height: 40px;
border-bottom: 1px solid #f0f0f0;
.info-tab {
display: inline-block;
vertical-align: top;
width: 50%;
text-align: center;
font-size: 15px;
color: #333;
cursor: pointer;
&.active {
font-weight: 600;
}
}
}
.chat-area {
$input-height: 130px;
.chat-messages {
height: calc(100% - 130px + 1px);
border-bottom: 1px solid #e1e1e1;
}
.chat-input {
height: $input-height;
}
}
.order-info-con {
height: calc(100% - 40px);
}
.someone-inputing {
position: absolute;
left: 20px;
bottom: 20px;
z-index: 1;
color: #c2c2c2;
}
</style>
<template>
<el-dialog
class="chat-dialog-con"
:close-on-click-modal="false"
:visible="visible"
@close="hide"
>
<div class="chat-con h-100">
<div class="h-100 chat-list">
<chat-list />
</div>
<div v-if="chatId != null" class="h-100 chat-area">
<chat-room :close="hide" />
</div>
<div class="chat-panel h-100">
<el-button @click="terminate">结束</el-button>
</div>
</div>
</el-dialog>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator"
import MessageList from "./message-list.vue"
import ChatRoom from "./chat-room.vue"
import ChatList from "./chat-list.vue"
import { ChatStore, chatStore } from "@/customer-service/store/model"
@Component({ components: { MessageList, ChatRoom, ChatList } })
export default class Chat extends Vue {
@chatStore.State(ChatStore.STATE_CHAT_CURRENT_CHAT_ID)
private readonly chatId!: ChatStore.STATE_CHAT_CURRENT_CHAT_ID
@chatStore.State(ChatStore.STATE_CHAT_DIALOG_VISIBLE)
private readonly visible: ChatStore.STATE_CHAT_DIALOG_VISIBLE
@chatStore.Mutation(ChatStore.MUTATION_HIDE_CHAT)
private readonly hide: ChatStore.MUTATION_HIDE_CHAT
@chatStore.Action(ChatStore.ACTION_TERINATE_CHAT)
private readonly _terminate: ChatStore.ACTION_TERINATE_CHAT
private terminate() {
this._terminate()
}
}
</script>
<style lang="scss" scoped>
.chat-dialog-con {
/deep/ .el-dialog__header {
display: none;
}
/deep/ .el-dialog {
width: 80%;
max-width: 1200px;
}
--chat-side-width: 200px;
}
.chat-con {
height: 500px;
}
.chat-list,
.chat-area,
.chat-panel {
display: inline-block;
vertical-align: top;
}
.chat-list {
width: var(--chat-side-width);
border-right: 1px solid #e1e1e1;
}
.chat-area {
width: calc(100% - 2 * var(--chat-side-width) - 2px);
}
.chat-panel {
width: var(--chat-side-width);
border-left: 1px solid #e1e1e1;
}
</style>
const SVG_AUDIO = `<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1595484227561" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6614" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M132.267 0h518.4l288 288v689.067A46.933 46.933 0 0 1 891.733 1024H132.267a46.933 46.933 0 0 1-46.934-46.933V46.933A46.933 46.933 0 0 1 132.267 0z" fill="#E54C94" p-id="6615"></path><path d="M607.744 434.39c-31.488 19.86-73.472-1.43-73.472-1.43s40.47 178.795 37.483 193.003c0 0 0-1.43 0 0 0 42.56-37.483 78.037-83.968 78.037-46.464 0-82.454-35.477-82.454-78.037 0-42.582 37.483-78.059 82.454-78.059 16.49 0 34.496 5.675 43.477 15.616l-38.976-143.317s-16.49-65.28 41.984-56.768c41.984 7.082 29.995 34.048 104.96 11.349 1.493-1.408 4.48 36.907-31.488 59.605zM650.667 0v241.067A46.933 46.933 0 0 0 697.6 288h241.067l-288-288z" fill="#FFFFFF" p-id="6616"></path></svg>`
const SVG_EXCEL = `<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1595484180583" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5396" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M132.267 0h518.4l288 288v689.067A46.933 46.933 0 0 1 891.733 1024H132.267a46.933 46.933 0 0 1-46.934-46.933V46.933A46.933 46.933 0 0 1 132.267 0z" fill="#3AC877" p-id="5397"></path><path d="M369.707 704L474.09 544.64l-94.592-146.048h72.085l61.248 98.133 60.01-98.133h71.467l-95.018 148.33L653.675 704h-74.39l-67.712-105.621L443.67 704zM650.667 0v241.067A46.933 46.933 0 0 0 697.6 288h241.067l-288-288z" fill="#FFFFFF" p-id="5398"></path></svg>`
const SVG_IMAGE = `<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1595484188280" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5531" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M132.267 0h518.4l288 288v689.067A46.933 46.933 0 0 1 891.733 1024H132.267a46.933 46.933 0 0 1-46.934-46.933V46.933A46.933 46.933 0 0 1 132.267 0z" fill="#F6AD00" p-id="5532"></path><path d="M294.485 684.95l78.379-115.2a12.181 12.181 0 0 1 18.624-1.835l51.797 50.752 113.643-178.134a12.181 12.181 0 0 1 20.843 0.47l141.205 244.714A12.181 12.181 0 0 1 708.416 704h-403.84a12.181 12.181 0 0 1-10.09-19.05z" fill="#FFF7F7" p-id="5533"></path><path d="M443.307 423.616c0 32.512-29.014 60.95-62.187 60.95-33.152 0-62.165-28.438-62.165-60.95-2.07-32.512 26.944-60.95 62.165-60.95 33.152 0 62.165 28.438 62.165 60.95zM650.667 0v241.067A46.933 46.933 0 0 0 697.6 288h241.067l-288-288z" fill="#FFFFFF" p-id="5534"></path></svg>`
const SVG_OTHERS = `<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1595484218091" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6343" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M132.267 0h518.4l288 288v689.067A46.933 46.933 0 0 1 891.733 1024H132.267a46.933 46.933 0 0 1-46.934-46.933V46.933A46.933 46.933 0 0 1 132.267 0z" fill="#F2F2F2" p-id="6344"></path><path d="M512 693.333c-11.243-0.085-20.267-9.685-20.203-21.461 0-11.883 9.046-21.483 20.203-21.483 11.136 0 20.203 9.6 20.203 21.483 0 11.819-9.067 21.461-20.203 21.461z m86.89-195.712c-6.058 11.648-18.005 25.728-35.69 42.411-11.904 11.285-19.67 19.499-23.317 24.768-3.67 5.397-6.4 11.456-8.107 18.325a132.267 132.267 0 0 0-2.773 25.195 19.84 19.84 0 0 1-3.264 16.363 17.43 17.43 0 0 1-14.187 7.21c-8.533 0.043-15.872-6.293-17.621-15.146h-0.32a380.757 380.757 0 0 1-0.214-11.606c0-15.274 2.006-28.33 6.08-39.36 3.435-9.216 8.299-17.749 14.379-25.237 4.907-6.187 13.653-15.275 26.261-27.093 12.63-11.798 20.907-21.27 24.683-28.267 3.797-7.083 5.675-14.763 5.675-23.125 0-15.04-5.59-28.288-16.64-39.68a54.272 54.272 0 0 0-40.747-17.152c-15.573 0-28.544 5.12-38.912 15.53-9.984 9.878-16.619 25.067-20.053 45.59-0.299 10.944-8.768 19.626-19.072 19.562a18.603 18.603 0 0 1-15.232-8.021 21.163 21.163 0 0 1-3.051-17.77c3.776-27.35 13.653-48.427 29.525-63.297 16.704-15.637 38.784-23.488 66.176-23.488 29.142 0 52.288 8.406 69.59 25.238C599.38 415.317 608 435.69 608 459.477c0 13.782-3.03 26.454-9.11 38.102v0.042z" fill="#AAAAAA" p-id="6345"></path><path d="M650.667 0v241.067A46.933 46.933 0 0 0 697.6 288h241.067l-288-288z" fill-opacity=".1" p-id="6346"></path></svg>`
const SVG_PDF = `<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1595484212640" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6208" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M132.267 0h518.4l288 288v689.067A46.933 46.933 0 0 1 891.733 1024H132.267a46.933 46.933 0 0 1-46.934-46.933V46.933A46.933 46.933 0 0 1 132.267 0z" fill="#EF5E5E" p-id="6209"></path><path d="M274.624 640V456.747h59.37c22.507 0 37.163 0.917 44.011 2.773 10.496 2.73 19.286 8.704 26.368 17.92 7.083 9.216 10.624 21.12 10.624 35.69 0 11.243-2.048 20.694-6.122 28.374-4.075 7.68-9.28 13.675-15.574 18.048-6.272 4.395-12.672 7.275-19.178 8.704-8.832 1.75-21.632 2.624-38.379 2.624h-24.107V640h-37.034z m36.992-152.256v52.01h20.267c14.57 0 24.32-0.96 29.226-2.88 4.928-1.92 8.79-4.906 11.584-9.002 2.774-4.075 4.182-8.832 4.182-14.25 0-6.657-1.963-12.16-5.867-16.491a25.6 25.6 0 0 0-14.89-8.128c-4.417-0.854-13.291-1.259-26.625-1.259h-17.877z m133.632-30.997h67.627c15.253 0 26.88 1.173 34.88 3.498 10.752 3.179 19.946 8.79 27.626 16.875 7.68 8.107 13.504 17.984 17.494 29.696 4.01 11.712 5.994 26.133 5.994 43.307 0 15.082-1.877 28.096-5.61 38.997-4.587 13.333-11.136 24.128-19.627 32.384-6.421 6.25-15.083 11.115-26.027 14.613C539.456 638.7 528.533 640 514.88 640h-69.653V456.747z m36.992 30.997v121.387h27.627c10.346 0 17.792-0.598 22.4-1.75a34.133 34.133 0 0 0 14.933-7.637c3.947-3.584 7.168-9.472 9.685-17.685 2.496-8.214 3.734-19.392 3.734-33.558 0-14.165-1.238-25.045-3.734-32.64-2.517-7.573-6.016-13.482-10.517-17.728a35.2 35.2 0 0 0-17.11-8.64c-5.183-1.152-15.295-1.749-30.378-1.749h-16.64zM630.507 640V456.747h125.61v30.997H667.52v43.37h76.501v31.02H667.52V640h-36.992zM650.667 0v241.067A46.933 46.933 0 0 0 697.6 288h241.067l-288-288z" fill="#FFFFFF" p-id="6210"></path></svg>`
const SVG_PPT = `<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1595484168359" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5125" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M132.267 0h518.4l288 288v689.067A46.933 46.933 0 0 1 891.733 1024H132.267a46.933 46.933 0 0 1-46.934-46.933V46.933A46.933 46.933 0 0 1 132.267 0z" fill="#F57B47" p-id="5126"></path><path d="M400.747 704V398.592h98.965c37.504 0 61.93 1.515 73.323 4.565 17.493 4.587 32.149 14.55 43.946 29.91 11.819 15.36 17.728 35.178 17.728 59.477 0 18.752-3.413 34.517-10.218 47.296-6.806 12.779-15.446 22.805-25.92 30.08a89.387 89.387 0 0 1-32 14.507c-14.72 2.901-36.032 4.373-63.936 4.373H462.42V704h-61.653zM462.4 450.24v86.677h33.77c24.32 0 40.534-1.6 48.747-4.8 8.192-3.2 14.614-8.192 19.264-14.997 4.651-6.805 6.976-14.72 6.976-23.744 0-11.093-3.264-20.267-9.792-27.52a42.645 42.645 0 0 0-24.789-13.525c-7.36-1.387-22.144-2.091-44.373-2.091H462.42zM650.667 0v241.067A46.933 46.933 0 0 0 697.6 288h241.067l-288-288z" fill="#FFFFFF" p-id="5127"></path></svg>`
const SVG_RP = `<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1595484207818" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6072" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M640 32l277.333 277.333H682.667A42.667 42.667 0 0 1 640 266.667V32z" fill="#FFFFFF" p-id="6073"></path><path d="M132.267 0h518.4l288 288v689.067A46.933 46.933 0 0 1 891.733 1024H132.267a46.933 46.933 0 0 1-46.934-46.933V46.933A46.933 46.933 0 0 1 132.267 0z" fill="#BC6CD5" p-id="6074"></path><path d="M299.925 661.333v-244.33h103.83c26.112 0 45.077 2.197 56.917 6.57 11.819 4.395 21.29 12.203 28.416 23.424 7.104 11.222 10.667 24.064 10.667 38.507 0 18.347-5.398 33.472-16.171 45.419s-26.88 19.456-48.341 22.57c10.666 6.23 19.477 13.056 26.432 20.502 6.933 7.466 16.298 20.672 28.074 39.68l29.824 47.658h-58.986l-35.67-53.162c-12.672-18.987-21.333-30.976-26.005-35.926a37.867 37.867 0 0 0-14.827-10.154c-5.226-1.835-13.504-2.774-24.832-2.774h-10.005v102.016h-49.301z m49.323-141.013h36.501c23.68 0 38.443-0.981 44.331-2.987 5.888-2.005 10.496-5.44 13.845-10.325 3.328-4.907 4.992-11.008 4.992-18.347 0-8.213-2.197-14.848-6.592-19.904-4.373-5.056-10.581-8.256-18.56-9.6-4.01-0.554-16-0.832-36.01-0.832h-38.528v62.016zM546.24 661.333v-244.33h79.168c30.016 0 49.557 1.216 58.667 3.669 14.016 3.67 25.728 11.627 35.178 23.893 9.451 12.288 14.166 28.16 14.166 47.595 0 14.997-2.731 27.627-8.171 37.845a66.987 66.987 0 0 1-20.757 24.086 71.51 71.51 0 0 1-25.579 11.584c-11.776 2.325-28.821 3.498-51.157 3.498h-32.171v92.16H546.24z m49.344-203.008v69.334h27.008c19.435 0 32.427-1.28 38.997-3.84 6.55-2.56 11.691-6.55 15.403-11.99a32.853 32.853 0 0 0 5.59-18.986c0-8.896-2.603-16.214-7.83-22.016a34.133 34.133 0 0 0-19.84-10.838c-5.888-1.109-17.707-1.664-35.499-1.664h-23.829zM650.667 0v241.067A46.933 46.933 0 0 0 697.6 288h241.067l-288-288z" fill="#FFFFFF" p-id="6075"></path></svg>`
const SVG_TEXT = `<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1595484222751" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6479" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M132.267 0h518.4l288 288v689.067A46.933 46.933 0 0 1 891.733 1024H132.267a46.933 46.933 0 0 1-46.934-46.933V46.933A46.933 46.933 0 0 1 132.267 0z" fill="#5098F1" p-id="6480"></path><path d="M481.493 704V450.24h-90.666v-51.648h242.709v51.648h-90.39V704zM650.667 0v241.067A46.933 46.933 0 0 0 697.6 288h241.067l-288-288z" fill="#FFFFFF" p-id="6481"></path></svg>`
const SVG_VIDEO = `<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1595484193398" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5667" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M132.267 0h518.4l288 288v689.067A46.933 46.933 0 0 1 891.733 1024H132.267a46.933 46.933 0 0 1-46.934-46.933V46.933A46.933 46.933 0 0 1 132.267 0z" fill="#8183F1" p-id="5668"></path><path d="M512 341.333c106.325 0 192 86.187 192 192s-86.187 192-192 192-192-86.186-192-192 86.187-192 192-192z m-31.296 102.87a12.8 12.8 0 0 0-19.904 10.666v156.95a12.8 12.8 0 0 0 19.904 10.666l117.717-78.506a12.8 12.8 0 0 0 0-21.291l-117.717-78.507zM650.667 0v241.067A46.933 46.933 0 0 0 697.6 288h241.067l-288-288z" fill="#FFFFFF" p-id="5669"></path></svg>`
const SVG_WORD = `<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1595484175478" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5260" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M132.267 0H646.74l291.926 295.83v681.237A46.933 46.933 0 0 1 891.733 1024H132.267a46.933 46.933 0 0 1-46.934-46.933V46.933A46.933 46.933 0 0 1 132.267 0z" fill="#66C7FC" p-id="5261"></path><path d="M132.267 0h518.4l288 288v689.067A46.933 46.933 0 0 1 891.733 1024H132.267a46.933 46.933 0 0 1-46.934-46.933V46.933A46.933 46.933 0 0 1 132.267 0z" fill="#3FB0EE" p-id="5262"></path><path d="M385.024 704l-72.917-305.408h63.125l46.037 209.792 55.83-209.792h73.344l53.546 213.333 46.87-213.333h62.08L638.763 704h-65.408l-60.843-228.33L451.904 704zM650.667 0v241.067A46.933 46.933 0 0 0 697.6 288h241.067l-288-288z" fill="#FFFFFF" p-id="5263"></path></svg>`
const SVG_XMIND = `<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1595484203239" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5937" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M132.267 0h518.4l288 288v689.067A46.933 46.933 0 0 1 891.733 1024H132.267a46.933 46.933 0 0 1-46.934-46.933V46.933A46.933 46.933 0 0 1 132.267 0z" fill="#EB4E53" p-id="5938"></path><path d="M412.267 394.667L277.333 424.66s0.726 78.294 148.139 114.902c0 0-163.541 40.234-134.933 132.437l117.333-19.755s0-98.773 84.33-84.885c84.331 13.163 101.206 73.173 107.073 98.048l147.392-22.677s-11.008-91.478-155.478-130.262c0 0 130.56-25.6 84.352-110.485L555.99 424.661s27.136 57.814-25.664 65.131-94.592-18.283-118.058-95.125zM650.667 0v241.067A46.933 46.933 0 0 0 697.6 288h241.067l-288-288z" fill="#FFFFFF" p-id="5939"></path></svg>`
const SVG_ZIP = `<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1595484198283" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5802" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M132.267 0h518.4l288 288v689.067A46.933 46.933 0 0 1 891.733 1024H132.267a46.933 46.933 0 0 1-46.934-46.933V46.933A46.933 46.933 0 0 1 132.267 0z" fill="#F38C12" p-id="5803"></path><path d="M426.667 0v85.333H512V0h-85.333z m0 170.667V256H512v-85.333h-85.333z m0 170.666v85.334H512v-85.334h-85.333z m85.333-256v85.334h85.333V85.333H512zM512 256v85.333h85.333V256H512z m0 170.667V512h85.333v-85.333H512zM426.667 512v170.667h170.666V512H426.667z m28.437 85.333h113.792v56.896H455.104v-56.896zM650.667 0v241.067A46.933 46.933 0 0 0 697.6 288h241.067l-288-288z" fill="#FFFFFF" p-id="5804"></path></svg>`
const IMAGE_404 = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25px" height="23px" viewBox="0 0 25 23" version="1.1"><!-- Generator: Sketch 59 (86127) - https://sketch.com --><title>形状</title><desc>Created with Sketch.</desc><g id="页面-" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"><g id="聊天记录-全部" transform="translate(-630.000000, -504.000000)" fill="#C5D4E5" fill-rule="nonzero"><g id="输入区" transform="translate(460.500000, 65.000000)"><g id="聊天" transform="translate(21.000000, 57.000000)"><g id="Group-4" transform="translate(0.000000, 306.000000)"><g id="Group-6" transform="translate(50.500000, 22.000000)"><path d="M115.343089,60.4857235 C115.343089,61.4880836 116.155662,62.300657 117.158022,62.300657 C118.160382,62.300657 118.972956,61.4880836 118.972956,60.4857235 C118.972956,59.4833634 118.160382,58.67079 117.158022,58.67079 C116.155662,58.67079 115.343089,59.4833634 115.343089,60.4857235 L115.343089,60.4857235 Z M122.802062,55.1570787 C122.661436,54.9955729 122.46236,54.8966151 122.248709,54.8820154 L110.74929,54.0777982 C110.48502,54.0573212 110.227916,54.1698422 110.063649,54.3778672 L107.739727,57.2728878 C107.496372,57.5757527 107.503492,58.0090641 107.756667,58.3037701 L111.440578,62.5878198 L107.723595,67.4945933 L108.643161,63.1242335 C108.705387,62.8284556 108.597037,62.5227098 108.362451,62.3321158 L103.536341,58.406213 L105.543255,55.2377424 C105.705781,54.9807089 105.709936,54.6541148 105.554001,54.39303 C105.398066,54.1319451 105.108547,53.9807499 104.805182,54.0019743 L98.7505634,54.4246522 C98.5370214,54.4395331 98.3381511,54.5386828 98.1977496,54.7002656 C98.057348,54.8618484 97.9869302,55.0726122 98.0020041,55.2861406 L99.4087793,75.402057 C99.4240595,75.616038 99.5237815,75.8151664 99.6859762,75.9555738 C99.8481709,76.0959812 100.059531,76.1661479 100.273494,76.1506162 L104.722905,75.8182817 C105.167362,75.7852096 105.500503,75.3980238 105.466624,74.9543734 C105.432745,74.510723 105.048786,74.1719354 104.601909,74.2098473 L100.960749,74.481684 L100.798615,72.1496961 L103.957406,71.8326877 C104.170417,71.8114964 104.366269,71.7065053 104.501829,71.5408371 C104.63739,71.3751689 104.70154,71.1624111 104.680153,70.9494201 C104.633914,70.5069599 104.239744,70.1844247 103.796885,70.2266732 L100.686492,70.5396484 L99.6677098,55.9782353 L103.326616,55.7225313 L101.789972,58.1480891 C101.571262,58.4934105 101.645424,58.9477426 101.962592,59.2055904 L106.963742,63.2726547 L105.482757,70.3129834 C105.458801,70.4366147 105.465167,70.5642102 105.501309,70.6848431 C105.496625,70.778974 105.508642,70.8731908 105.536801,70.9631329 L106.835487,74.8591902 C106.936546,75.1634142 107.208317,75.3788038 107.527582,75.4077034 L120.713679,76.6063662 C120.929641,76.6257531 121.144336,76.5575538 121.309522,76.417093 C121.474707,76.2766322 121.576529,76.0756905 121.592107,75.8594202 L122.998075,55.7426972 C123.012827,55.5292412 122.942339,55.3186449 122.802062,55.1570787 Z M120.308747,71.0591227 L109.908775,70.0596994 C109.695255,70.0358706 109.481187,70.0995268 109.315381,70.2361533 C109.149575,70.3727799 109.046166,70.5707294 109.028733,70.7848661 C109.008287,70.99784 109.07329,71.2102124 109.20944,71.3752556 C109.345589,71.5402989 109.54173,71.6444911 109.754707,71.6649072 L120.196624,72.6683638 L120.038523,74.9253344 L108.197897,73.8484739 L107.200087,70.8558502 L113.11919,63.0411498 C113.349319,62.7383982 113.3362,62.3159043 113.087731,62.0280136 L109.416726,57.75929 L111.057426,55.7160782 L121.331563,56.4339852 L120.308747,71.0591227 L120.308747,71.0591227 Z" id="形状"/></g></g></g></g></g></g></svg>`
export const enum FileType {
Audio = "音频文件",
Excel = "Excel文件",
Image = "图片文件",
Others = "未知类型文件",
Pdf = "PDF 文件",
Ppt = "PPT 文件",
Rp = "RP 文件",
Txt = "文本文件",
Video = "视频文件",
Word = "Word 文件",
Xmind = "Xmind 文件",
Zip = "压缩文件",
Image_404 = "图片加载失败",
}
const FILE_EXTENSION_VIDEO = [
"mp4",
"avi",
"wmv",
"swf",
"mov",
"ram",
"rmvb",
"mkv",
"m4v",
"flv",
"f4v",
"3gp",
"mpeg",
"asf",
"navi",
"ts",
]
export const FILE_EXTENSION_IMAGE = [
"png",
"jpg",
"jpeg",
"jpeg2000",
"gif",
"bmp",
"tga",
"tps",
"psd",
"tiff",
"eps",
"svg",
"dxf",
"pcx",
"wmf",
"emf",
"lic",
"exif",
"fpx",
"cdr",
"pcd",
"ai",
"raw",
"webp",
]
const FILE_EXTENSION_PPT = ["ppt", "pptx", "key"]
const FILE_EXTENSION_TEXT = ["txt", "md", "rtf"]
const FILE_EXTENSION_WORD = ["doc", "docx", "page"]
const FILE_EXTENSION_EXCEL = ["xls", "xlsx", "numbers"]
const FILE_EXTENSION_AUDIO = [
"mp3",
"cda",
"wav",
"wma",
"ogg",
"ape",
"flac",
"aac",
"ra",
"rm",
"rmx",
"midi",
]
const FILE_EXTENSION_XMIND = [
"xmap",
"xmind",
"mmapmindly",
"mindnode",
"mindmaster",
]
const FILE_EXTENSION_COMPRESS = [
"zip",
"rar",
"rars",
"7z",
"iso",
"tar",
"cab",
"jar",
"uue",
]
export function getFileType(name: string) {
if (!name) {
return FileType.Others
}
const splits = name.split(".")
const extension = splits[splits.length - 1].toLowerCase()
if (FILE_EXTENSION_VIDEO.some((i) => i === extension)) {
return FileType.Video
}
if (FILE_EXTENSION_IMAGE.some((i) => i === extension)) {
return FileType.Image
}
if (FILE_EXTENSION_AUDIO.some((i) => i === extension)) {
return FileType.Audio
}
if (FILE_EXTENSION_PPT.some((i) => i === extension)) {
return FileType.Ppt
}
if (FILE_EXTENSION_TEXT.some((i) => i === extension)) {
return FileType.Txt
}
if (FILE_EXTENSION_WORD.some((i) => i === extension)) {
return FileType.Word
}
if (FILE_EXTENSION_EXCEL.some((i) => i === extension)) {
return FileType.Excel
}
if (FILE_EXTENSION_XMIND.some((i) => i === extension)) {
return FileType.Xmind
}
if (FILE_EXTENSION_COMPRESS.some((i) => i === extension)) {
return FileType.Zip
}
if (extension === "rp") {
return FileType.Rp
}
if (extension === "pdf") {
return FileType.Pdf
}
return FileType.Others
}
const type2SvgMapping = new Map<FileType, () => string>([
[FileType.Audio, () => SVG_AUDIO],
[FileType.Excel, () => SVG_EXCEL],
[FileType.Image, () => SVG_IMAGE],
[FileType.Others, () => SVG_OTHERS],
[FileType.Pdf, () => SVG_PDF],
[FileType.Ppt, () => SVG_PPT],
[FileType.Rp, () => SVG_RP],
[FileType.Txt, () => SVG_TEXT],
[FileType.Xmind, () => SVG_XMIND],
[FileType.Zip, () => SVG_ZIP],
[FileType.Video, () => SVG_VIDEO],
[FileType.Word, () => SVG_WORD],
[FileType.Image_404, () => IMAGE_404],
])
export function getSvg(type: FileType) {
const action = type2SvgMapping.get(type)
return (action && action()) || ""
}
export function isAudio(name: string) {
return name && FILE_EXTENSION_AUDIO.some((i) => name.endsWith(i))
}
export function isVideo(name: string) {
return name && FILE_EXTENSION_VIDEO.some((i) => name.endsWith(i))
}
export function isImage(name: string) {
return name && FILE_EXTENSION_IMAGE.some((i) => name.endsWith(i))
}
/**
* 最大图片文件大小
*/
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024
export const MAX_IMAGE_SIZE_STRING = "5MB"
export const MESSAGE_IMAGE_TOO_LARGE = `您发送的图片大小超过 ${MAX_IMAGE_SIZE_STRING}。`
export const MESSAGE_FILE_EMPTY = "不能发送空文件。"
/**
* 最大文件大小
*/
export const MAX_FILE_SIZE = 20 * 1024 * 1024
export const MAX_FILE_SIZE_STRING = "20MB"
export const MESSAGE_FILE_TOO_LARGE = `您发送的文件大小超过 ${MAX_FILE_SIZE_STRING}。`
<template>
<span class="file-icon" :title="value" v-html="html"></span>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator"
import { Model } from "vue-property-decorator"
import { FileType, getSvg } from "./file-controller"
@Component({ components: {} })
export default class FileIcon extends Vue {
@Model("update")
private value!: FileType
private get audio() {
return this.value === FileType.Audio
}
private get excel() {
return this.value === FileType.Excel
}
private get image() {
return this.value === FileType.Image
}
private get others() {
return this.value === FileType.Others
}
private get pdf() {
return this.value === FileType.Pdf
}
private get ppt() {
return this.value === FileType.Ppt
}
private get rp() {
return this.value === FileType.Rp
}
private get txt() {
return this.value === FileType.Txt
}
private get video() {
return this.value === FileType.Video
}
private get word() {
return this.value === FileType.Word
}
private get xmid() {
return this.value === FileType.Xmind
}
private get zip() {
return this.value === FileType.Zip
}
private get html() {
return getSvg(this.value)
}
}
</script>
<style lang="scss">
.file-icon {
margin-left: 10px;
svg {
max-width: 36px;
max-height: 36px;
}
}
</style>
<template>
<el-dialog
:modal="false"
:before-close="close"
:visible="value"
custom-class="hide-header show-close padding-0 width-auto"
>
<div class="d-flex flex-column">
<div class="preview-title text-center">图片预览</div>
<div class="d-flex justify-content-center" style="min-width: 300px">
<img v-if="file" :src="file.url" :style="style" />
</div>
<div class="d-flex justify-content-center actions">
<span
class="d-flex align-items-center justify-content-center"
@click="set2Default"
>1:1</span
>
<a
class="d-flex align-items-center justify-content-center"
:href="file.url | downloadUrl(getAttachment)"
:download="getAttachment"
>
<i class="el-icon-download"></i>
</a>
</div>
</div>
</el-dialog>
</template>
<script lang="ts">
import { Component, Vue, Model, Prop } from "vue-property-decorator"
@Component({ components: {} })
export default class ImagePreview extends Vue {
@Model("update")
private value!: boolean
@Prop()
private file!: { name: string; url: string }
private style: {
"max-height": number | string
"max-width": number | string
} = {
"max-height": "300px",
"max-width": "600px",
}
private close() {
setTimeout(
() =>
(this.style = { "max-height": "300px", "max-width": "600px" }),
300
)
this.$emit("update", false)
}
private set2Default() {
this.style = { "max-height": "1600px", "max-width": "1600px" }
}
private get getAttachment() {
if (this.file) {
return this.file.name
}
return "文件下载"
}
}
</script>
<style lang="scss" scoped>
.preview-title {
font-size: 18px;
color: #333;
margin-bottom: 15px;
}
.actions {
margin: 15px 0;
> span,
a {
width: 30px;
height: 30px;
background-color: #7a7b7d;
color: #fff;
border-radius: 50%;
cursor: pointer;
i {
color: #fff;
font-size: 20px;
}
& + span {
margin-left: 15px;
}
}
> a {
margin-left: 15px;
}
}
</style>
<template>
<div
class="message-con d-flex"
:class="
isMyMessage
? 'my-message align-items-start flex-row-reverse'
: 'align-items-start'
"
>
<span class="no-selection">
<fs-avatar
:size="40"
class="msg-avatar"
:shape="shape"
:src="avatar"
/>
</span>
<div class="msg-content">
<div class="msg-name no-selection">{{ username }}</div>
<!-- Image -->
<div
class="msg-detail image-message"
:class="{ 'image-404': fileFailed2Load }"
v-if="messageType === 'image'"
@dblclick="openFile"
>
<img
v-if="messageRealUrl"
:src="messageRealUrl"
:title="messageBody.msg.name"
:alt="messageBody.msg.name"
/>
<file-icon
v-else-if="fileFailed2Load"
:value="image404"
></file-icon>
</div>
<!-- File -->
<div
class="msg-detail file-message d-flex"
v-else-if="messageType === 'file'"
@dblclick="openFile"
>
<div class="file-message-info">
<div
class="text-nowrap text-truncate file-message-name"
:title="messageBody.msg.name"
>
{{ messageBody.msg.name }}
</div>
<div class="text-hint">
{{ messageBody.msg.size | formatSize }}
</div>
</div>
<file-icon :value="fileIcon"></file-icon>
</div>
<!-- Audio -->
<div
class="msg-detail voice-message d-flex align-items-center"
:class="{ playing: playing, 'can-play': messageRealUrl }"
v-else-if="messageType === 'voice'"
@click.stop="play"
:style="{ width: getVoiceMessageWidth + 'px' }"
>
<div class="d-flex align-items-center" v-if="messageRealUrl">
<voice-icon :loading="playing"></voice-icon>
<audio ref="audio" @play="onPlay" @pause="onPause">
<source type="audio/aac" :src="messageRealUrl" />
</audio>
<span v-if="duration" class="duration text-nowrap text-hint"
>{{ durationInSecond }}s</span
>
</div>
<i
class="el-icon-warning-outline"
v-else-if="fileFailed2Load"
title="[语音加载失败]"
></i>
</div>
<!-- Video -->
<div
class="
msg-detail
video-message
d-flex
align-items-center
justify-content-center
"
v-else-if="messageType === 'video'"
>
<video-player-icon @click.native="openFile"></video-player-icon>
</div>
<!-- Text -->
<div
class="msg-detail inline-text"
v-else
v-html="format2Link(messageBody.msg.text || emptyText)"
></div>
</div>
<a
v-if="!isSendingMessage && messageType === 'file'"
class="
d-flex
align-items-center
justify-content-center
download-icon
"
:href="messageRealUrl | downloadUrl(getAttachment)"
:download="getAttachment"
title="下载文件"
>
<img src="~@/customer-service/imgs/download.png" alt="Download" />
</a>
<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="showReadSummary">
<div v-if="isMyMessage" class="msg-read pos-rel">
<span @click="readListVisibility = true" class="pointer">
<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"
/>
</div>
</template>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Ref, Inject } from "vue-property-decorator"
import * as dto from "../model"
import chat from "./../xim"
import { chatStore, ChatStore } from "@/customer-service/store/model"
import FileIcon from "./file-icon.vue"
import {
FileType,
getFileType,
isAudio,
isVideo,
MAX_FILE_SIZE,
MAX_IMAGE_SIZE,
isImage,
} from "./file-controller"
import VoiceIcon from "./voice.vue"
import WhoReadList from "./who-read-list.vue"
import VideoPlayerIcon from "./video-player-icon.vue"
import { replaceText2Link } from "../utils"
import { isAccessibleUrl } from "../service/tools"
@Component({
components: { FileIcon, VoiceIcon, WhoReadList, VideoPlayerIcon },
})
export default class Message extends Vue {
@chatStore.State(ChatStore.STATE_CHAT_MY_ID)
private readonly chatMyId!: ChatStore.STATE_CHAT_MY_ID
@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
/**
* tbd: 文件消息所在的域名的url,逻辑需要补充
*/
private messageRealUrl = ""
private fileFailed2Load = false
private image404 = FileType.Image_404
private loadingRealUrl = false
@Prop({ type: Object, default: () => Object.create(null) })
private data!: dto.Message
@Prop()
private isSendingMessage!: boolean
@Prop()
private failed!: boolean
@Prop({ default: "circle" })
private shape!: string
@Ref("audio")
private readonly audioRef!: HTMLAudioElement
private playing = false
@Inject({ default: false }) readonly showReadSummary!: boolean
private emptyText = " "
private readListVisibility = false
private org = ""
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) {
try {
return { ...this.data, msg: JSON.parse(this.data.msg) }
} catch {
return {
...this.data,
msg: JSON.parse(this.data.msg.replace(/\n/g, "\\n")),
}
}
}
return { msg: { text: "" } }
}
private get getAttachment() {
if (this.messageBody) {
return this.messageBody.msg.name
}
return "文件下载"
}
private get isMyMessage() {
if (this.isSendingMessage) {
return true
}
if (this.messageBody) {
const msg = this.messageBody
if (this.chatSource) {
const source = msg.msg.source
if (source) {
return (
source === this.chatSource &&
this.messageBody.eid === this.chatMyId
)
}
if (this.org && this.messageBody.oid) {
return (
this.messageBody.oid === this.org &&
this.messageBody.eid === this.chatMyId
)
}
return false
}
return this.messageBody.eid === this.chatMyId
}
return false
}
private get username() {
const avatar = chat.getUserMapping()
if (this.data == null) return ""
if (avatar == null) return this.data.id
const value = avatar[this.data.eid]
if (value == null) return ""
if (value.name == null) return ""
return value.name
}
private get avatar() {
const avatar = chat.getUserMapping()
if (this.isSendingMessage) {
if (avatar && this.chatMyId) {
const user = avatar[this.chatMyId]
if (user && user.avatar) {
return user.avatar
}
}
}
if (avatar && this.data) {
const value = avatar[this.data.eid]
if (value && value.avatar) {
return value.avatar
}
}
return ""
}
private get fileIcon() {
if (this.data) {
return getFileType(this.messageBody.msg.name)
}
return FileType.Others
}
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 "image"
}
const outSize = size > MAX_FILE_SIZE
if (!outSize) {
if (isAudio(name)) {
return "voice"
}
if (isVideo(name)) {
return "video"
}
}
}
}
}
return this.data?.type
}
// 图片的样式设置,通过js偶尔会有高度计算不准确, 直接通过css的处理目前看可以达到对应效果
// private get imageStyle() {
// if (this.messageBody == null) return {};
// if (this.messageBody.msg.w == null) return {};
// if (this.messageBody.msg.h == null) return {};
// const w = this.messageBody.msg.w;
// const h = this.messageBody.msg.h;
// const maxWidth = 300;
// const maxHeight = maxWidth * (h / w);
// return { width: `${w}px`, height: `${h}px`, maxHeight: `${maxHeight}px` };
// }
private get duration() {
const v = this.messageBody.msg.duration as number
return v || 0
}
private get durationInSecond() {
return Math.round(this.duration / 1000)
}
private get getVoiceMessageWidth() {
if (this.fileFailed2Load) {
return 35
}
const d = this.duration / 1000
if (d <= 3) {
return 60
}
if (d >= 60) {
return 200
}
return 60 + d
}
private isCustomer() {
return !this.showReadSummary
}
private buildMessageUrl() {
if (this.messageRealUrl || this.loadingRealUrl) {
return
}
const url = this.messageBody.msg.url as string
if (url) {
// const service = XimService.getInstance()
if (isAccessibleUrl(url)) {
return (this.messageRealUrl = url)
}
this.loadingRealUrl = true
// service
// .getFileUrlById(url, this.chatId || 0, this.isCustomer())
// .then((data) => {
// if (data && data.itemList) {
// this.messageRealUrl = data.itemList[0].url
// } else {
// this.fileFailed2Load = true
// }
// })
// .catch(() => (this.fileFailed2Load = true))
// .finally(() => (this.loadingRealUrl = false))
} else {
this.fileFailed2Load = true
}
}
private openFile() {
if (this.isSendingMessage) {
return
}
if (this.failed || this.fileFailed2Load) {
return
}
const copy = { ...this.messageBody.msg }
if (this.messageRealUrl) {
copy.url = this.messageRealUrl
}
this.$emit("open", { type: this.messageType, msg: copy })
}
private play() {
if (this.audioRef?.paused) {
this.audioRef?.load()
this.audioRef?.play()
} else {
this.audioRef?.pause()
}
}
private onPlay() {
this.playing = true
}
private onPause() {
this.playing = false
}
private format2Link(text: string) {
return replaceText2Link(text)
}
}
</script>
<style lang="scss" scoped>
.message-con {
margin: 20px 0;
&.my-message {
.msg-avatar {
margin-right: 0;
margin-left: 10px;
}
.msg-name {
display: none;
}
.msg-detail {
margin-top: 0;
background-color: #dbf2ff;
border-radius: 8px 0 8px 8px;
&.image-message:not(.image-404),
&.file-message {
background-color: transparent;
border-radius: 4px;
border: 1px solid #c5d4e5;
}
&.voice-message {
> div {
flex-flow: row-reverse;
}
svg {
transform: rotateY(180deg);
}
}
&.video-message {
background-color: #000;
border-radius: 0;
}
}
.msg-read {
display: inline-block;
color: #bfe1ff;
margin-right: 15px;
user-select: none;
flex: none;
}
.download-icon {
margin-right: 15px;
margin-left: 0;
margin-top: 0;
}
}
> i {
height: 16px;
font-size: 16px;
margin-right: 10px;
}
}
.msg-avatar {
margin-right: 10px;
flex: 40px 0 0;
}
i.msg-avatar {
font-size: 30px;
background-color: #c0c4cc;
border-radius: 4px;
width: 40px;
height: 40px;
&:before {
position: relative;
left: 5px;
top: 5px;
color: #fff;
}
}
.msg-name {
font-size: 12px;
color: #888;
}
.msg-detail {
margin-top: 10px;
font-size: 14px;
line-height: 20px;
background: #f5f6fa;
border-radius: 0px 8px 8px;
padding: 10px;
word-break: break-word;
&.image-message,
&.file-message {
background-color: transparent;
border-radius: 4px;
border: 1px solid #c5d4e5;
}
&.image-message {
line-height: 1;
&.image-404 {
background: #f7f8fa;
display: flex;
align-items: center;
justify-content: center;
.file-icon {
margin: 0;
}
}
}
&.voice-message {
height: 40px;
width: 200px;
&.can-play {
cursor: pointer;
}
i {
font-size: 16px;
}
}
&.video-message {
height: 160px;
width: 200px;
background-color: #000;
border-radius: 0;
svg {
cursor: pointer;
}
}
&.inline-text {
display: inline-block;
}
.file-message-name {
max-width: 130px;
}
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;
}
.image-message {
max-width: 300px;
box-sizing: content-box;
img {
width: 100%;
}
}
</style>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
t="1595844164348"
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
p-id="7080"
width="60"
height="60"
>
<path
d="M512 0c282.76736 0 512 229.23264 512 512S794.76736 1024 512 1024 0 794.76736 0 512 229.23264 0 512 0z"
fill="#000000"
fill-opacity="0.5"
p-id="7081"
/>
<path
d="M512 0c282.76736 0 512 229.23264 512 512S794.76736 1024 512 1024 0 794.76736 0 512 229.23264 0 512 0z m0 30.72C246.19008 30.72 30.72 246.19008 30.72 512S246.19008 993.28 512 993.28 993.28 777.80992 993.28 512 777.80992 30.72 512 30.72z"
fill="#FFFFFF"
p-id="7082"
/>
<path
d="M712.41728 546.73408L421.0688 728.84224a40.96 40.96 0 0 1-62.6688-34.73408V329.89184a40.96 40.96 0 0 1 62.6688-34.73408l291.34848 182.10816a40.96 40.96 0 0 1 0 69.46816z"
fill="#FFFFFF"
p-id="7083"
/>
</svg>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator"
@Component({ components: {} })
export default class VideoPlayerIcon extends Vue {}
</script>
<template>
<el-dialog
:modal="false"
:before-close="close"
:visible="value"
custom-class="hide-header show-close padding-0 width-auto"
>
<div class="d-flex flex-column">
<div class="preview-title text-center">视频预览</div>
<div class="d-flex justify-content-center" style="min-width: 300px">
<video
ref="video"
v-if="file"
:src="file.url"
controls
:style="style"
></video>
</div>
<div class="d-flex justify-content-center actions">
<span
class="d-flex align-items-center justify-content-center"
@click="set2Default"
>1:1</span
>
<a
class="d-flex align-items-center justify-content-center"
:href="file.url | downloadUrl(getAttachment)"
:download="getAttachment"
>
<i class="el-icon-download"></i>
</a>
</div>
</div>
</el-dialog>
</template>
<script lang="ts">
import { Component, Vue, Model, Prop, Ref, Watch } from "vue-property-decorator"
@Component({ components: {} })
export default class VideoPreview extends Vue {
@Model("update")
private value!: boolean
@Prop()
private file!: { name: string; url: string }
@Ref("video")
private video!: HTMLVideoElement
private style: {
"max-height": number | string
"max-width": number | string
} = {
"max-height": "800px",
"max-width": "800px",
}
private close() {
setTimeout(
() =>
(this.style = { "max-height": "300px", "max-width": "600px" }),
300
)
this.$emit("update", false)
}
private set2Default() {
this.style = { "max-height": "1600px", "max-width": "1600px" }
}
private get getAttachment() {
if (this.file) {
return this.file.name
}
return "视频下载"
}
@Watch("value")
private onOpen() {
if (this.value) {
this.video?.load()
setTimeout(() => this.video?.play(), 100)
} else {
this.video?.pause()
}
}
}
</script>
<style lang="scss" scoped>
.preview-title {
font-size: 18px;
color: #333;
margin-bottom: 15px;
}
.actions {
margin: 15px 0;
> span,
a {
width: 30px;
height: 30px;
background-color: #7a7b7d;
color: #fff;
border-radius: 50%;
cursor: pointer;
i {
color: #fff;
font-size: 20px;
}
& + span {
margin-left: 15px;
}
}
> a {
margin-left: 15px;
}
}
</style>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
t="1595840909244"
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
p-id="11656"
:width="size"
:height="size"
>
<path
d="M56.888889 512a105.016889 102.4 90 1 0 204.8 0 105.016889 102.4 90 1 0-204.8 0Z"
fill="#8D959D"
p-id="11657"
v-if="!status || status >= 1"
/>
<path
d="M425.415111 782.449778a68.266667 68.266667 0 0 1-97.792-95.288889A249.912889 249.912889 0 0 0 398.222222 512c0-66.787556-25.713778-129.137778-70.542222-175.160889a68.266667 68.266667 0 0 1 97.735111-95.288889A386.389333 386.389333 0 0 1 534.755556 512c0 102.627556-39.822222 199.111111-109.340445 270.449778z"
fill="#8D959D"
p-id="11658"
v-if="!status || status >= 2"
/>
<path
d="M618.496 980.48a68.266667 68.266667 0 0 1-97.792-95.288889A532.707556 532.707556 0 0 0 671.288889 512a532.707556 532.707556 0 0 0-150.584889-373.191111A68.266667 68.266667 0 0 1 618.496 43.52 669.184 669.184 0 0 1 807.822222 512c0 177.891556-68.835556 344.917333-189.326222 468.48z"
fill="#8D959D"
p-id="11659"
v-if="!status || status >= 3"
/>
</svg>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator"
import { Prop } from "vue-property-decorator"
import { Watch } from "vue-property-decorator"
@Component({ components: {} })
export default class VoiceIcon extends Vue {
@Prop({ default: 25 })
private size!: number
@Prop()
private loading!: boolean
private status = 0
private interval = 0
@Watch("loading")
private onLoadingChanged() {
if (this.loading) {
this.interval = window.setInterval(() => {
const v = this.status + 1
if (v > 3) {
this.status = 0
} else {
this.status = v
}
}, 500)
} else {
clearInterval(this.interval)
this.status = 0
}
}
beforeDestroy() {
clearInterval(this.interval)
}
}
</script>
<template>
<div
v-loading="loading"
ref="list-con"
@blur="$emit('blur')"
class="who-read-list pos-rel"
:style="`left:${left}px;top:${top}px`"
>
<template v-if="!loading">
<div class="list-left">
<div class="number-count">已读 {{ readlist.length }}</div>
<div
class="member-item"
v-for="item in readlist"
:key="item.eid"
>
<fs-avatar
class="member-avatar"
:src="item.avatar"
:size="30"
/>
<span class="member-name">{{ item.name }}</span>
</div>
</div>
<div class="list-right">
<div class="number-count">未读 {{ unreadlist.length }}</div>
<div
class="member-item"
v-for="item in unreadlist"
:key="item.eid"
>
<fs-avatar
class="member-avatar"
:src="item.avatar"
:size="30"
/>
<span class="member-name">{{ item.name }}</span>
</div>
</div>
</template>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Ref } from "vue-property-decorator"
import { namespace } from "vuex-class"
import { ChatStore } from "@/customer-service/store/model"
import xim from "@/customer-service/xim/xim"
import { unique } from "../utils"
import chat from "../xim"
import * as dto from "../model"
const chatStoreNamespace = namespace("chatStore")
@Component({ components: {} })
export default class WhoReadList extends Vue {
@chatStoreNamespace.State(ChatStore.STATE_CHAT_CURRENT_CHAT_ID)
private readonly chatId!: ChatStore.STATE_CHAT_CURRENT_CHAT_ID
@chatStoreNamespace.State(ChatStore.STATE_CHAT_MY_ID)
private readonly chatMyId!: ChatStore.STATE_CHAT_MY_ID
@Prop({
type: Number,
})
private msgId!: number
@Ref("list-con")
private listCon!: HTMLElement
private top = 0
private left = 0
private readlist: { name: string; avatar: string }[] = []
private unreadlist: { name: string; avatar: string }[] = []
private loading = false
private startLoading() {
this.loading = true
}
private endLoading() {
this.loading = false
}
public async created() {
this.startLoading()
await this.getReader()
this.endLoading()
}
public mounted() {
this.enableBlur()
const { top, left } = (
this.listCon.parentNode as HTMLElement
).getBoundingClientRect()
this.top = top
this.left = left
}
private enableBlur() {
this.listCon.setAttribute("tabindex", "-1")
this.listCon.focus()
}
private async getUserNameByid(eid: string) {
const data = await this.sdk.model("user").detail(eid).query()
return data.row.first_name.value as string
}
private async getReader() {
if (this.chatId == null) return
if (this.msgId == null) return
const userInfo = chat.getUserMapping()
const data = await xim.fetchMsgInBox(this.chatId, this.msgId)
const readerlist = this.uniqueReaderList(
data.args[0] as dto.OneWhoReadMessage[]
)
this.readlist = await Promise.all(
readerlist
.filter((k) => k.is_read)
.filter((k) => k.eid !== this.chatMyId)
.map(async (k) => {
const eid = k.eid
const name = await this.getUserNameByid(eid)
return {
eid,
name,
avatar: "",
}
})
)
this.unreadlist = await Promise.all(
readerlist
.filter((k) => !k.is_read)
.filter((k) => k.eid !== this.chatMyId)
.map(async (k) => {
const eid = k.eid
const name = await this.getUserNameByid(eid)
return {
eid,
name,
avatar: "",
}
})
)
}
private uniqueReaderList(data: dto.OneWhoReadMessage[]) {
return unique(data, function (item, all) {
return all.findIndex((k) => k.eid === item.eid)
})
}
}
</script>
<style lang="scss" scoped>
.who-read-list {
::before {
content: "";
position: absolute;
width: 1px;
top: 15px;
bottom: 15px;
left: 0;
right: 0;
margin: auto;
background: #e1e2e2;
}
&:focus {
outline: unset;
}
padding: 15px 30px;
background: rgba(249, 249, 249, 1);
box-shadow: 0px 0px 6px 0px rgba(0, 0, 0, 0.2);
border-radius: 4px;
border: 1px solid rgba(183, 191, 199, 1);
width: 455px;
min-height: 100px;
position: fixed;
margin-left: -200px;
margin-top: 20px;
color: #000;
z-index: 2;
.list-left,
.list-right {
display: inline-block;
vertical-align: top;
width: calc(50% - 30px);
}
.list-left {
padding-right: 30px;
}
.list-right {
padding-left: 30px;
}
.number-count {
font-size: 14px;
color: #333333;
margin-bottom: 15px;
}
}
.member-item {
margin-top: 10px;
.member-avatar,
.member-name {
display: inline-block;
vertical-align: middle;
}
.member-avatar {
margin-right: 10px;
}
}
</style>
<template>
<el-dialog title="创建会话" :visible="visible" @close="hide">
选择聊天对象
{{ userList.length }}
<el-table
v-loading="loading"
:data="userList"
style="width: 100%"
height="250"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55"> </el-table-column>
<el-table-column prop="id" label="id" width="150">
</el-table-column>
<el-table-column prop="name" label="name" width="150">
</el-table-column>
</el-table>
<el-button @click="nextPage">加载下一页</el-button>
<span slot="footer" class="dialog-footer">
<el-button @click="hide">取 消</el-button>
<el-button type="primary" @click="createChat">确 定</el-button>
</span>
</el-dialog>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator"
import { ChatStore, chatStore } from "@/customer-service/store/model"
import { List, ListEasy, ListTypes } from "uniplat-sdk"
type User = {
id: string
name: string
}
type ThenArg<T> = T extends PromiseLike<infer U> ? U : T
@Component({ components: {} })
export default class ChatCreator extends Vue {
@chatStore.State(ChatStore.STATE_CHAT_CREATOR_VISIBLE)
private readonly visible: ChatStore.STATE_CHAT_CREATOR_VISIBLE
@chatStore.Mutation(ChatStore.MUTATION_HIDE_CHAT_CREATOR)
private readonly hide: ChatStore.MUTATION_HIDE_CHAT_CREATOR
@chatStore.Action(ChatStore.ACTION_CREATE_NEW_CHAT_BY_SERVICE_MAN)
private readonly _createChat: ChatStore.ACTION_CREATE_NEW_CHAT_BY_SERVICE_MAN
private currentPage = 1
private userList = []
private getList: ThenArg<ReturnType<ListEasy["query"]>>["getList"]
public async created() {
const list = this.sdk.model("user").list()
const { pageData, getList } = await list.query({
pageIndex: this.currentPage,
item_size: 50,
})
this.getList = getList
this.userList = this.exactUserList(pageData.rows)
}
private exactUserList(rows: any[]) {
return rows.map((k) => {
return {
id: k.id.value,
name: k.first_name.value,
}
})
}
private loading = false
private async nextPage() {
this.loading = true
this.currentPage++
const data = await this.getList(this.currentPage)
this.loading = false
this.userList = [...this.userList, ...this.exactUserList(data.rows)]
}
private selectedRows: string[] = []
private handleSelectionChange(selectedRows: User[]) {
this.selectedRows = selectedRows.map((k) => String(k.id))
}
private createChat() {
const { keyvalue, model_name } = this.$route.params
this._createChat({
modelName: model_name,
selectedListId: keyvalue,
uids: this.selectedRows,
})
}
}
</script>
<style lang="scss" scoped></style>
<template>
<div class="input-wrap h-100">
<div class="tool-bar">
<img
class="tool-bar-icon"
title="发送表情"
@click.stop="toggleEmoji"
src="@/customer-service/imgs/emoji.png"
/>
<label
for="chat-upload-file"
:title="tip4Image"
@click="allowLoadImg"
class="offset"
>
<img
class="tool-bar-icon"
src="@/customer-service/imgs/pic.png"
/>
</label>
<label
for="chat-upload-file"
:title="tip4File"
@click="allowLoadFile"
>
<img
class="tool-bar-icon"
src="@/customer-service/imgs/file.png"
/>
</label>
<input
@change="onChange"
:value="file"
id="chat-upload-file"
type="file"
:accept="acceptType"
multiple
/>
</div>
<el-scrollbar class="input-el-scrollbar">
<!-- contenteditable 只能设置为true,需要考虑浏览器兼容性问题,不能只考虑chrome -->
<div
class="input-container"
ref="input"
id="chat-input-box"
contenteditable="true"
@input="$emit('input')"
@keypress.enter="handleSendMsg"
></div>
</el-scrollbar>
<div class="emoji-picker" v-show="emojiPanelVisibility">
<el-scrollbar class="overflow-x-hidden">
<span
class="emoji-item"
v-for="item in emoji"
:key="item.code"
:title="item.name"
@click.stop="selectEmoji(item)"
>{{ item.emoji_chars }}</span
>
</el-scrollbar>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Ref } from "vue-property-decorator"
import { formatFileSize } from "../utils"
import {
getFileType,
getSvg,
MAX_IMAGE_SIZE_STRING,
MAX_FILE_SIZE_STRING,
MESSAGE_FILE_EMPTY,
MAX_IMAGE_SIZE,
MESSAGE_IMAGE_TOO_LARGE,
MAX_FILE_SIZE,
MESSAGE_FILE_TOO_LARGE,
} from "../components/file-controller"
import { ChatStore } from "../store/model"
import { namespace } from "vuex-class"
import { Watch } from "vue-property-decorator"
import { EmojiService } from "../service/emoji"
export const enum InputMessageType {
Text = "text",
Image = "image",
File = "file",
}
export interface InputMessageBody {
text?: string
url?: string
name?: string
size?: number
}
export interface InputMessage {
type: InputMessageType
body: InputMessageBody
file?: File | null
}
const chatStoreNamespace = namespace("chatStore")
const chatCache: { [key: number]: any } = {}
export const IMAGE_INFO_CLASS = "img-info"
export const FILE_INFO_CLASS = "file-info"
export function isImageOrFile(node: ChildNode) {
const e = node as HTMLElement
return (
e.classList &&
(e.classList.contains(IMAGE_INFO_CLASS) ||
e.classList.contains(FILE_INFO_CLASS))
)
}
@Component({ components: {} })
export default class Input extends Vue {
@chatStoreNamespace.State(ChatStore.STATE_CHAT_CURRENT_CHAT_ID)
private readonly chatId!: ChatStore.STATE_CHAT_CURRENT_CHAT_ID
@Ref("input")
private readonly messageInputBox!: HTMLDivElement
private file = ""
private acceptType = "image/*"
private emojiPanelVisibility = false
private tip4Image = `发送图片(最大${MAX_IMAGE_SIZE_STRING})`
private tip4File = `发送文件(最大${MAX_FILE_SIZE_STRING})`
private emoji: { name: string; emoji_chars: string; code: string }[] = []
@Watch("chatId")
private onChatIdChanged(v: number, old: number) {
if (old) {
const current = this.getNodeListFromInputBox()
if (current && current.length) {
chatCache[old] = current
}
}
this.clearInput()
if (v) {
const cache = chatCache[v]
if (cache) {
const e = document.querySelector(
"#chat-input-box"
) as HTMLElement
if (e) {
for (const node of cache as ChildNode[]) {
e.appendChild(node)
}
}
}
}
}
public mounted() {
this.messageInputBox.addEventListener("paste", this.handlePasteEvent)
document.addEventListener("click", this.hideEmoji)
document.addEventListener("selectionchange", this.handleSaveRange)
this.$onBeforeDestroy(() => {
document.removeEventListener("click", this.hideEmoji)
document.removeEventListener(
"selectionchange",
this.handleSaveRange
)
})
this.setupEmoji()
this.focus()
}
public focus() {
this.messageInputBox.focus()
}
private clearInput() {
this.messageInputBox.innerHTML = ""
const input = document.getElementById(
"chat-upload-file"
) as HTMLInputElement
if (input) {
input.value = ""
}
}
private allowLoadImg() {
this.acceptType = "image/*"
}
private allowLoadFile() {
this.acceptType = "*"
}
private async handlePasteEvent(event: ClipboardEvent) {
/*
* 组织默认行为原因
* 1、浏览器自带复制粘贴图片到输入框的功能,与js加工后的图片重复了,
* 2、默认复制粘贴功能会粘贴dom结构
* */
event.preventDefault()
const items = event.clipboardData && event.clipboardData.items
let html = ""
const promiseArr = []
if (items && items.length) {
// 检索剪切板items中类型带有image的
for (let i = 0; i < items.length; i++) {
if (items[i].kind === "file") {
const file = items[i].getAsFile()
if (file) {
if (file.size <= 0) {
this.$emit("error", MESSAGE_FILE_EMPTY)
return
}
if (this.isImage(file)) {
if (file.size >= MAX_IMAGE_SIZE) {
this.$emit("error", MESSAGE_IMAGE_TOO_LARGE)
return
}
html += this.buildImageHtml(file)
} else {
if (file.size >= MAX_FILE_SIZE) {
this.$emit("error", MESSAGE_FILE_TOO_LARGE)
return
}
html += this.buildFileHtml(file)
}
break
}
} else {
promiseArr.push(
new Promise<void>((done) => {
const contentType = items[i].type
items[i].getAsString((k) => {
/*
* items第一项是文本
* 第二项是带有dom结构的文本(包含格式)
* 第三项写明了数据是从哪里复制来的
*/
if (i === 0) {
if (contentType === "text/plain") {
html += k
}
} else if (i === 1) {
const srcRegex =
/<img[^>]+src="([^">]+)"\s+title="([^">]+)"/g
let result
do {
result = srcRegex.exec(k)
if (result) {
const [, src, name] = result
html += `<img tabindex="-1" src="${src}" data-image='${JSON.stringify(
{
url: src,
name,
}
)}'>`
html = html.replace(name, "")
}
} while (result)
}
done()
})
})
)
}
}
}
await Promise.all(promiseArr)
if (html) {
this.insertHtmlAtCaret(html)
}
}
private async handleSendMsg(e: Event) {
// 防止换行
e.preventDefault()
return new Promise((resolve, reject) => {
try {
const data = this.getNodeListFromInputBox()
this.$emit("send", data, resolve)
} catch (e) {
this.$emit("error", e)
reject(e)
}
}).then(() => {
this.clearInput()
if (this.chatId) {
chatCache[this.chatId] = []
}
})
}
/**
* 获取输入框中的内容
* @returns 返回的是节点数组
*/
private getNodeListFromInputBox() {
this.messageInputBox.normalize()
const nodes = Array.from(this.messageInputBox.childNodes)
return this.combine(nodes)
}
/**
* 文本,链接等需要合并成纯文本发送
*/
private combine(nodes: ChildNode[]) {
const sendingNodes: ChildNode[] = []
let needCreateNewNode = false
let text = ""
for (const item of nodes) {
if (!isImageOrFile(item) && item.textContent) {
if (needCreateNewNode) {
text = ""
needCreateNewNode = false
}
text += item.textContent
} else {
needCreateNewNode = true
if (text) {
this.checkTextLength(text)
const node = document.createTextNode(text)
sendingNodes.push(node)
}
sendingNodes.push(item)
}
}
if (text) {
this.checkTextLength(text)
const node = document.createTextNode(text)
sendingNodes.push(node)
}
return sendingNodes
}
private checkTextLength(text: string) {
if (text.length >= 4000) {
throw new Error("消息不能超过4000个字")
}
}
private handleSaveRange() {
const sel = window.getSelection()
if (sel && sel.rangeCount) {
const range = sel.getRangeAt(0)
const oldRange = this.getRange && this.getRange()
if (
this.messageInputBox &&
this.messageInputBox.contains(range.endContainer)
) {
if (
oldRange &&
range.collapsed &&
range.endContainer === oldRange.endContainer &&
range.endOffset === oldRange.endOffset
) {
return
}
this.saveRange && this.saveRange(range)
}
}
}
private saveRange?: (param: Range) => void
private getRange?: () => Range | null
/**
* 在光标处插入元素
*/
public insertHtmlAtCaret = (function () {
let range: Range | null = null
return function (this: Input, html: string) {
if (this.saveRange == null) {
this.saveRange = (alternate) => (range = alternate)
}
if (this.getRange == null) {
this.getRange = () => range
}
const sel = window.getSelection()
if (sel) {
if (range) {
range.deleteContents()
sel.removeAllRanges()
sel.addRange(range)
} else {
this.focus()
range = sel.getRangeAt(0)
document.execCommand("selectAll")
window.getSelection()?.collapseToEnd()
}
}
const el = document.createElement("div")
el.innerHTML = html
const frag = document.createDocumentFragment()
let node = null
let lastNode = null
while ((node = el.firstChild)) {
lastNode = frag.appendChild(node)
}
range?.insertNode(frag)
// Preserve the selection
if (lastNode) {
if (range) {
range = range.cloneRange()
range.setStartAfter(lastNode)
range.collapse(true)
sel?.removeAllRanges()
sel?.addRange(range)
}
}
}
})()
private selectEmoji(emoji: any) {
this.insertHtmlAtCaret(emoji.emoji_chars)
this.hideEmoji()
}
private isImage(file: File) {
return file.type.startsWith("image")
}
private buildImageHtml(file: File) {
const url = URL.createObjectURL(file)
return `<img class="${IMAGE_INFO_CLASS}" tabindex="-1" src="${url}" data-image='${JSON.stringify(
{
url,
name: file.name,
size: file.size,
}
)}'>`
}
private buildFileHtml(file: File) {
const extension = file.name.split(".")
const type = getFileType(file.name)
return `<div class="${FILE_INFO_CLASS}" tabindex="-1" title="${
extension[extension.length - 1]
}" data-file='${JSON.stringify({
url: URL.createObjectURL(file),
name: file.name,
size: file.size,
})}'><div style="display: inline-block"><div class="file-name text-truncate text-nowrap">${
file.name
}</div><div class="file-size">${formatFileSize(
file.size
)}</div></div><span class="file-icon" title="${type}">${getSvg(
type
)}</span></div> ` // 注意</div>最后面有个空
}
private onChange(e: Event) {
const target = e.target as HTMLElement
if (target) {
const input = document.getElementById(
target.id as string
) as HTMLInputElement
if (input) {
const files = input.files
if (files && files.length) {
let html = ""
for (let index = 0; index < files.length; index++) {
const file = files[index]
if (file.size <= 0) {
this.$emit("error", MESSAGE_FILE_EMPTY)
return
}
if (this.isImage(file)) {
if (file.size >= MAX_IMAGE_SIZE) {
this.$emit("error", MESSAGE_IMAGE_TOO_LARGE)
return
}
html += this.buildImageHtml(file)
} else {
if (file.size >= MAX_FILE_SIZE) {
this.$emit("error", MESSAGE_FILE_TOO_LARGE)
return
}
html += this.buildFileHtml(file)
}
}
this.insertHtmlAtCaret(html)
}
}
}
}
private toggleEmoji() {
this.emojiPanelVisibility = !this.emojiPanelVisibility
}
private hideEmoji(e?: Event) {
if (e && e.target) {
const target = e.target as HTMLElement
if (target.closest(".emoji-picker")) {
return
}
}
this.emojiPanelVisibility = false
}
private noop() {
return
}
private setupEmoji() {
EmojiService.onReady(() => {
const service = new EmojiService()
service.getEmoji().then((r) => {
if (r) {
this.emoji = r.list
}
})
})
}
}
</script>
<style lang="scss" scoped>
.input-wrap {
position: relative;
padding-left: 20px;
/deep/.input-el-scrollbar.el-scrollbar {
// 28px : tool-bar的高度
height: calc(100% - 28px);
> .el-scrollbar__wrap {
overflow-x: hidden;
}
> .el-scrollbar__wrap > .el-scrollbar__view {
min-height: 100%;
display: flex;
// 输入框
> .input-container {
width: 100%;
font-size: 14px;
padding: 10px 20px 10px 0;
outline: 0;
white-space: pre-wrap;
user-select: text;
&::selection {
background-color: #cce6fc;
}
img {
max-width: 300px;
font-size: 50px;
vertical-align: bottom;
border: 1px solid transparent;
&:focus {
border-color: var(--main-color);
}
&::selection {
background-color: #cce6fc;
}
}
.file-info {
padding: 10px;
border: 1px solid #c5d4e5;
border-radius: 4px;
display: inline-block;
margin-right: 10px;
-webkit-user-modify: read-only;
user-select: none;
.file-name {
color: #000;
font-size: 14px;
max-width: 130px;
}
.file-size {
margin-top: 10px;
}
}
}
}
}
.input-emoji {
position: absolute;
left: 0;
top: -225px;
outline: 0;
}
}
.tool-bar {
padding-top: 10px;
user-select: none;
.tool-bar-icon {
height: 16px;
width: 16px;
cursor: pointer;
}
.offset {
margin: 0 22px;
}
}
.emoji-picker {
position: absolute;
z-index: 2;
bottom: 99px;
left: -1px;
background-color: #fff;
padding: 20px;
padding-right: 0;
padding-bottom: 10px;
border: 1px solid #f0f0f0;
right: 45px;
overflow: hidden;
.el-scrollbar {
height: 200px;
}
.emoji-item {
display: inline-flex;
cursor: pointer;
min-width: 35px;
min-height: 25px;
font-size: 20px;
vertical-align: top;
margin-top: 5px;
justify-content: center;
align-items: center;
}
}
#chat-upload-file {
visibility: hidden;
position: absolute;
}
</style>
imgs/card.png

655 Bytes

<template>
<div class="h-100">
<chat-input
ref="chat-input"
@input="onInput"
@send="sendMessage"
@error="onError"
/>
</div>
</template>
<script lang="ts">
import { Component, Ref, Vue, Watch } from "vue-property-decorator"
import ChatInput, {
FILE_INFO_CLASS,
isImageOrFile,
} from "./hybrid-input/index.vue"
import { ChatStore, chatStore } from "@/customer-service/store/model"
import { ChatLoggerService } from "./xim/logger"
import xim from "./xim/xim"
import { Message } from "./model"
import { uploadFile } from "./service/upload"
let sendingMessageIndex = 1
@Component({ components: { ChatInput } })
export default class MessageInput extends Vue {
@chatStore.State(ChatStore.STATE_CHAT_DIALOG_VISIBLE)
private readonly chatRoomVisible: ChatStore.STATE_CHAT_DIALOG_VISIBLE
@chatStore.Action(ChatStore.ACTION_SEND_MESSAGE)
private readonly sendMsg!: ChatStore.ACTION_SEND_MESSAGE
@chatStore.State(ChatStore.STATE_CHAT_CURRENT_CHAT_ID)
private readonly chatId!: ChatStore.STATE_CHAT_CURRENT_CHAT_ID
@chatStore.State(ChatStore.STATE_CHAT_MY_ID)
private readonly chatMyId!: ChatStore.STATE_CHAT_MY_ID
@chatStore.State(ChatStore.STATE_CURRENT_CHAT_INITING)
private readonly chatIniting!: ChatStore.STATE_CURRENT_CHAT_INITING
@chatStore.Getter(ChatStore.STATE_CHAT_SOURCE)
private readonly source!: ChatStore.STATE_CHAT_SOURCE
@chatStore.Mutation(ChatStore.MUTATION_APPEND_SENDING_MESSAGE)
private readonly appendSendingMessages!: ChatStore.MUTATION_APPEND_SENDING_MESSAGE
@chatStore.Mutation(ChatStore.MUTATION_FAILED_SENDING_MESSAGE)
private readonly failedSendingMessage!: ChatStore.MUTATION_FAILED_SENDING_MESSAGE
@chatStore.Mutation(ChatStore.MUTATION_REMOVE_SENDING_MESSAGE)
private readonly removeSendingMessages!: ChatStore.MUTATION_REMOVE_SENDING_MESSAGE
@Ref("chat-input")
private readonly chatInput!: ChatInput
@Watch("chatRoomVisible")
private whenChatRoomShow() {
if (!this.chatRoomVisible) return
this.chatInput.focus()
}
private async sendMessage(msg: ChildNode[], done: () => void) {
if (this.chatIniting) {
return
}
for (const item of msg) {
if (isImageOrFile(item)) {
if ((item as Element).classList.contains(FILE_INFO_CLASS)) {
this.sendFile(item, "file")
} else {
this.sendFile(item, "image")
}
continue
}
if (item.textContent) {
this.sendText(item.textContent)
}
}
ChatLoggerService.logger?.debug("all messages sent")
done()
this.$emit("sent")
}
private async onInput() {
if (this.chatId == null) return
await xim.inputing(this.chatId)
}
private sendText(text: string) {
if (text && text.trim()) {
const msg = { text: text.trim() }
if (this.source) {
Object.assign(msg, { source: this.source })
}
this.sendMsg({ msgType: "text", msg: JSON.stringify(msg) })
}
}
private async sendFile(file: any, type: "image" | "file") {
const src = JSON.parse(
file.attributes[`data-${type}`]?.value || ""
) as {
url: string
name: string
size: number
}
if (src) {
const index = this.sendSendingMessage(type, src)
const file = await this.readBlobUrl2Base64(src.url, src.name)
if (file) {
let w = 0
let h = 0
if (type === "image") {
const img = new Image()
img.src = src.url
img.onload = function () {
w = img.naturalWidth
h = img.naturalHeight
}
img.remove()
}
uploadFile(file, this.chatId || 0, w, h)
.then((r) => {
if (r) {
const msg = {
url: r,
name: file.name,
size: file.size,
}
if (this.source) {
Object.assign(msg, { source: this.source })
}
if (w && h) {
Object.assign(msg, { w, h })
}
this.sendMsg({
msgType: type,
msg: JSON.stringify(msg),
})
this.removeSendingMessages(index)
URL.revokeObjectURL(src.url)
} else {
this.setMsg2Failed(index)
}
})
.catch((e) => {
console.error(e)
this.setMsg2Failed(index)
})
}
}
}
private setMsg2Failed(index: number) {
this.failedSendingMessage(index)
}
private sendSendingMessage(type: string, msg: any) {
const index = sendingMessageIndex++
if (this.source) {
Object.assign(msg, { source: this.source, eid: this.chatMyId })
}
if (this.chatId) {
this.appendSendingMessages({
id: -index,
chat_id: this.chatId,
ts: Date.now(),
type,
msg: JSON.stringify(msg),
} as Message)
return -index
}
return 0
}
private readBlobUrl2Base64(url: string, name: string) {
return fetch(url)
.then((r) => r.blob())
.then((blob) => new File([blob], name))
}
private onError(e: any) {
this.$emit("error", e.message || e)
}
}
</script>
<template>
<div v-loading="chatIniting" class="message-list h-100">
<!-- chatId:{{ chatId }} -->
<el-scrollbar
ref="message-scrollbar"
class="
message-list-scrollbar
no-bottom-scrollbar
adjust-el-scroll-right-bar
h-100
"
>
<template v-for="item in messages">
<div :key="item.id" class="message-template">
<div
v-if="
item.id > 0 &&
messageTimestampDictionary[item.id] &&
item.msg
"
class="text-center text-hint timestamp"
>
{{ format2Time(item.ts) }}
</div>
<message
:is-sending-message="item.id < 0"
:failed="item.status === -1"
:key="item.id"
:data="item"
:shape="shape"
@open="open"
/>
</div>
</template>
</el-scrollbar>
<image-preview v-model="preview" :file="imagePreview"></image-preview>
<video-preview
v-model="previewVideo"
:file="videoPreview"
></video-preview>
</div>
</template>
<script lang="ts">
import { Component, Vue, Ref, Prop, Watch } from "vue-property-decorator"
import message from "./components/message.vue"
import { ChatStore, chatStore } from "@/customer-service/store/model"
import { throttle } from "./utils"
import { formatTime } from "./utils/time"
import { Message } from "./model"
import ImagePreview from "./components/image-preview.vue"
import VideoPreview from "./components/video-preview.vue"
@Component({ components: { message, ImagePreview, VideoPreview } })
export default class MessageList extends Vue {
@chatStore.Getter(ChatStore.STATE_CHAT_MSG_HISTORY)
private readonly historyMessage!: ChatStore.STATE_CHAT_MSG_HISTORY
@chatStore.Getter(ChatStore.STATE_CHAT_SENDING_MESSAGES)
private readonly sendingMessages!: ChatStore.STATE_CHAT_SENDING_MESSAGES
@chatStore.State(ChatStore.STATE_CHAT_CURRENT_CHAT_ID)
private readonly chatId!: ChatStore.STATE_CHAT_CURRENT_CHAT_ID
@chatStore.State(ChatStore.STATE_CURRENT_CHAT_INITING)
private readonly chatIniting!: ChatStore.STATE_CURRENT_CHAT_INITING
@chatStore.Action(ChatStore.ACTION_GET_CHAT_MESSAGES_BEFORE_SPECIFIC_ID)
private readonly getLastPageMsg!: ChatStore.ACTION_GET_CHAT_MESSAGES_BEFORE_SPECIFIC_ID
@chatStore.Action(ChatStore.ACTION_GET_CHAT_MESSAGES_AFTER_SPECIFIC_ID)
private readonly getNextPageMsg!: ChatStore.ACTION_GET_CHAT_MESSAGES_AFTER_SPECIFIC_ID
@chatStore.Action(ChatStore.ACTION_CLEAR_CURRENT_CHAT_DATA)
private readonly clearChatId!: ChatStore.ACTION_CLEAR_CURRENT_CHAT_DATA
@chatStore.Mutation(ChatStore.MUTATION_SAVE_FUNC_SCROLL_TO_BOTTOM)
private readonly saveScrollToBottomFunc!: ChatStore.MUTATION_SAVE_FUNC_SCROLL_TO_BOTTOM
@chatStore.Mutation(ChatStore.MUTATION_CLEAR_FUNC_SCROLL_TO_BOTTOM)
private readonly clearScrollToBottomFunc!: ChatStore.MUTATION_CLEAR_FUNC_SCROLL_TO_BOTTOM
@Prop({ default: "circle" })
private shape!: string
private get messages() {
if (this.historyMessage) {
if (this.sendingMessages) {
return [...this.historyMessage, ...this.sendingMessages].filter(
(i) => i.chat_id === this.chatId
)
}
return this.historyMessage
}
if (this.sendingMessages) {
return this.sendingMessages.filter((i) => i.chat_id === this.chatId)
}
return []
}
// 添加时间戳的最大间隔消息数
private readonly timeLimit = 48
private scroll2EndWhenMessageLoaded = false
private preview = false
private imagePreview = {}
private previewVideo = false
private videoPreview = {}
@Ref("message-scrollbar")
private scollbarElement!: Vue & {
update: () => void
}
private get scollWrapper(): HTMLElement | null {
return this.scollbarElement?.$el?.firstChild as HTMLElement
}
@Watch("messages")
private whenHasMessages() {
this.$nextTick(() => this.scollbarElement.update())
}
@Watch("preview")
private onPreviewChanged() {
if (!this.preview) {
this.raiseFileOpen(false)
}
}
@Watch("previewVideo")
private onVideoPreviewChanged() {
if (!this.previewVideo) {
this.raiseFileOpen(false)
}
}
private raiseFileOpen(value: boolean) {
this.$emit("file-open", value)
}
private get messageTimestampDictionary() {
const dic = {} as { [prop: number]: boolean }
let count = 0
if (this.historyMessage) {
this.historyMessage.forEach((message, index, array) => {
if (
index === 0 ||
this.whetherShowTime(array[index - 1], message) ||
count === this.timeLimit - 1
) {
dic[message.id] = true
count = 0
} else {
count++
}
})
}
return dic
}
private loading = false
private loadingOld = false
private loadingNew = false
public created() {
this.handleScrollWrapper()
}
public mounted() {
this.scollWrapper &&
this.scollWrapper.addEventListener("scroll", this.handleScroll)
this.saveScrollToBottomFunc(this.scrollToNewMsg)
}
public beforeDestroy() {
this.scollWrapper &&
this.scollWrapper.removeEventListener("scroll", this.handleScroll)
this.clearScrollToBottomFunc()
this.clearChatId()
}
public scroll2End(delay?: number) {
this.$nextTick(() => {
const wrap = this.scollbarElement?.$el.querySelector(
".el-scrollbar__wrap"
) as HTMLElement
if (wrap) {
if (delay) {
setTimeout(() => {
wrap.scrollTop = 10000
}, delay)
return
}
wrap.scrollTop = 10000
}
})
}
private startLoading() {
this.loading = true
}
private endLoading() {
this.loading = false
}
private startLoadingOld() {
this.startLoading()
this.loadingOld = true
}
private endLoadingOld() {
this.endLoading()
this.loadingOld = false
}
private startLoadingNew() {
this.startLoading()
this.loadingNew = true
}
private endLoadingNew() {
this.endLoading()
this.loadingNew = false
}
private handleScroll!: () => void
private handleScrollWrapper() {
let oldScrollTop = 0
this.handleScroll = () => {
const wrapper = this.scollWrapper
const gap = 150
if (wrapper == null) return
const view = wrapper.firstChild as HTMLElement
const wrapperH = wrapper.getBoundingClientRect().height
const viewH = view.getBoundingClientRect().height
let scrollUp = false
let scrollDown = false
if (oldScrollTop > wrapper.scrollTop) {
scrollUp = true
scrollDown = false
} else if (oldScrollTop < wrapper.scrollTop) {
scrollUp = false
scrollDown = true
}
this.forbidScrollTopToZero(wrapper)
if (wrapper.scrollTop <= gap) {
scrollUp && this.fetchOldMsg()
}
if (wrapper.scrollTop - 40 - (viewH - wrapperH) >= -gap) {
scrollDown && this.fetchNewMsg()
}
oldScrollTop = wrapper.scrollTop
}
}
/* scrollTop为0时,新加载的消息后,滚动条也会保持在0的位置 */
private forbidScrollTopToZero(ele: HTMLElement) {
if (ele.scrollTop <= 10) {
ele.scrollTop = 10
}
}
private scrollToNewMsg() {
this.$nextTick(() => {
if (this.loading) return
this.scroll2End()
})
}
@throttle()
private async fetchOldMsg() {
if (this.loading) return
const msg = this.historyMessage
if (msg == null) return
if (msg.length === 0) return
this.startLoadingOld()
const msgId = msg[0].id
const data = await this.getLastPageMsg(msgId)
if (data.length === 0) {
console.log("没有更多老消息了")
}
this.$emit("last-page", msgId)
this.endLoadingOld()
}
@throttle()
private async fetchNewMsg() {
if (this.loading) return
const msg = this.historyMessage
if (msg == null) return
if (msg.length === 0) return
this.startLoadingNew()
const msgId = msg[msg.length - 1].id
const data = await this.getNextPageMsg(msgId)
if (data.length === 0) {
console.log("没有更多新消息了")
}
this.$emit("next-page", msgId)
this.endLoadingNew()
}
private format2Time(time: number) {
return formatTime(time)
}
private whetherShowTime(previous: Message, current: Message) {
return current.ts - previous.ts > 180
}
private open(file: {
type: string
msg: { url: string; name: string; size: number }
}) {
if (file.type === "image") {
this.imagePreview = file.msg
this.preview = true
return this.raiseFileOpen(true)
}
if (file.type === "video") {
this.videoPreview = file.msg
this.previewVideo = true
return this.raiseFileOpen(true)
}
}
/**
* 获取当期消息列表头尾消息的id
*/
public getStart2EndMessageIds() {
const v: { start: number; end: number } = { start: 0, end: 0 }
if (this.historyMessage && this.historyMessage.length) {
const start = this.historyMessage[0]
v.start = start.id
const end = this.historyMessage[this.historyMessage.length - 1]
v.end = end.id
}
return v
}
}
</script>
<style lang="scss" scoped>
.message-list {
padding: 0 20px;
}
.loading-mask {
height: 50px;
line-height: 50px;
text-align: center;
}
.message-template {
&:first-child {
.timestamp {
margin-top: 20px;
}
}
.timestamp {
font-size: 12px;
user-select: none;
}
}
</style>
export interface Chat {
chat_id: number
title: string
service_id: number
create_time: number
update_time: number
start_time: number
end_time: number
is_finish: boolean
msg_id: number
last_cs_eid: string
first_cs_eid: string
last_msg_eid: string
last_msg_name: string
last_msg_ts: number
last_online_time: number
msg_type: string
msg: string
customer_name: string
customer_mobile: string
customer_avatar_url: string
customer_online: boolean
customer_eid: string
origin: string
unread_msg_count: number
}
export type TokenStringGetter = () => Promise<string>
export interface ChatOption {
/**
* 企业token [商户端使用,用户端和移动端不需要]
*/
enterpriseTokenString?: TokenStringGetter
/**
* 个人token
*/
userTokenString: TokenStringGetter
/**
* 长链接chat sdk地址
*/
webSocketUri: string
/**
* 基础服务Api地址,开发环境可传空值
*/
serviceBaseUri: string
/**
* 通信服务Api地址,开发环境可传空值
*/
chatServiceBaseUri: string
/**
* 小站id
*/
stationId: number
orgId?: number | string
logger?: ChatServiceLogger
/**
* 用户映射, key 是 eid,value 是用户名+头像地址
*/
userMapping?: { [key: string]: { name: string; avatar: string } }
/**
* 是否来源于桌面用户端
*/
webHost?: boolean
}
export interface ChatServiceLogger {
enabled: boolean
debug(message?: any, ...optionalParams: any[]): void
info(message?: any, ...optionalParams: any[]): void
error(message?: any, ...optionalParams: any[]): void
}
export type ChatListRequestList = {
list: Chat[]
total: number
}
export interface Message {
at_id: string
chat_id: number
create_time: number
eid: string
id: number
is_open: boolean
is_read: boolean
like: boolean
like_count: number
msg: string
oid: string
read_count: number
ref_id: number
status: number
total_read_count: number
ts: number
type: "text" | "image" | "file" | "video" | "voice"
update_time: number
url: string
}
export type MessageRequestResult = readonly Message[]
export interface CreateChatByServicemanRequestResult {
id: number
org_id: string
uid: string
oid: string
eid: string
type: string
title: string
app_id: string
tag: string
msg_id: number
ext: string
exit_msg_id: number
is_exited: boolean
dnd: number
is_top: boolean
label: string
join_msg_id: number
last_read_msg_id: number
biz_id: string
business_data: string
is_finish: boolean
is_deleted: boolean
is_remove: boolean
member_type: number
ref_id: number
unread_msg_count: number
at_me: boolean
at_all: boolean
last_login_oid: string
owner_oid: string
owner_eid: string
is_act: boolean
create_time: number
update_time: number
last_msg_ts: number
members_updated: number
user_updated: number
}
export type ChatMemberExtraInfo = {
name?: string
phone?: string
}
export interface ChatMember {
at_all: boolean
at_me: boolean
chat_id: number
create_time: number
dnd: number
eid: string
exit_msg_id: number
id: number
is_act: boolean
is_exited: boolean
is_remove: boolean
is_top: boolean
join_msg_id: number
label: string
nickname: string
oid: string
org_id: string
type: number
uid: string
unread_msg_count: number
update_time: number
}
export type ChatMembers = readonly ChatMember[]
export interface ServiceMan {
id: number
oid: string
eid: string
is_deleted: boolean
delete_time: number
delete_eid: string
service_id: number
type: number
name: string
mobile: string
email: string
description: string
avatar_url: string
create_time: number
update_time: number
chat_count: number
status: number
}
export type AllServiceMan = ServiceMan[]
export interface OneWhoReadMessage {
create_time: number
eid: string
id: number
is_read: boolean
like: boolean
like_time: number
oid: string
owner_id: number
read_time: number
target_id: number
type: number
uid: string
}
export interface GetAllChatListParams {
service_id: number
user_info?: string
origin?: string
user_id?: string
is_finish?: boolean | -1 | number
chat_update_time_start?: string
chat_update_time_end?: string
page?: number
page_size?: number
last_cs_eid?: string | number
}
import { TokenStringGetter } from "../model"
import { invokeGet } from "./request"
export class EmojiService {
private static ready = false
private static token: TokenStringGetter
private static beforeReadyCacheAction: Function[] = []
private url = ""
public constructor() {
this.url =
process.env.NODE_ENV === "production"
? "https://file.teammix.com"
: ""
}
public async getEmoji() {
const token = await EmojiService.token()
return invokeGet<{
type: string
list: {
code: string
name: string
emoji_chars: string
}[]
}>(`${this.url}/v1/emoji/list?type=chat`, token)
}
public static onReady(callback: () => void) {
if (EmojiService.ready) {
callback()
} else {
EmojiService.beforeReadyCacheAction.push(callback)
}
}
private static fireBeforeReadyAction() {
for (const item of EmojiService.beforeReadyCacheAction) {
item()
}
EmojiService.beforeReadyCacheAction = []
}
public static raiseOnReady(token: TokenStringGetter) {
EmojiService.ready = true
EmojiService.token = token
EmojiService.fireBeforeReadyAction()
}
}
import Axios from "axios"
import qs from "qs"
export function buildConfig(token: string, url: string) {
if (url && url.includes("/general")) {
return { headers: { Authorization: token, CurrentOrg: this.station } }
}
return { headers: { Authorization: token } }
}
export function invokeGet<T>(url: string, token: string) {
return new Promise<T>((resolve, reject) => {
Axios.get(url, buildConfig(token, url))
.then((r) => {
if (r?.data?.data !== undefined) {
return resolve(r.data.data as T)
}
reject(r)
})
.catch(reject)
})
}
export const enum DataType {
Json,
Qs,
}
export function invokePost<T>(
url: string,
token: string,
data: any,
dataType = DataType.Json
): Promise<T> {
let postData: any = null
if (dataType === DataType.Qs) {
postData = qs.stringify(data)
} else {
postData = data
}
return new Promise<T>((resolve, reject) => {
Axios.post(url, postData, buildConfig(token, url))
.then((r) => {
if (r?.data?.data !== undefined) {
return resolve(r.data.data as T)
}
reject(r)
})
.catch(reject)
})
}
export function isAccessibleUrl(url: string) {
return url && (url.startsWith("blob") || url.startsWith("http"))
}
import Vue from "vue"
import { invokePost } from "./request"
import Axios from "axios"
import tokenManager from "../xim/token"
const orgId = () => Vue.prototype.global.org?.id ?? "0"
export async function uploadFile(
file: File,
chatId: number,
width?: number,
height?: number
) {
const splits = file.name.split(".")
const parameter = {
file_name: file.name,
size: file.size,
introduction: file.name,
owner_type: "FILE_TYPE_CS",
owner_id: chatId,
org_id: orgId(),
ext_type: splits[splits.length - 1],
}
if (width && height) {
Object.assign(parameter, { width, height })
}
const token = await invokePost<{
url_id: string
sign: string
domain: string
file_name: string
id: number
}>("/xchat/personal/upload_token", await tokenManager.getToken(), parameter)
const uri = `${token.domain}/gw/file/upload`
const form = new FormData()
form.append("file", file)
form.append("fileId", token.url_id)
form.append("fileSize", file.size.toString())
form.append("orgId", orgId())
form.append("sign", token.sign)
const result = await Axios.post(uri, form, {
headers: { "Content-Type": "multipart/form-data" },
})
if (result && result.data && result.data.errcode === 0) {
return token.url_id
}
return ""
}
import { Module } from "vuex"
import { RootStoreState } from "../../store/model"
import { ChatMember } from "../model"
import { ChatStore, ChatStoreState, ChatStatus, ChatMemberType } from "./model"
import xim, { ChatNotifyListener } from "../xim/xim"
import { unique } from "../utils"
import Chat from "../xim"
import { decode } from "../utils/jwt"
import Vue from "vue"
import type { UniplatSdk } from "uniplat-sdk"
import { Global } from "@/common/global"
import chatType from "../xim/chat-type"
import { isAccessibleUrl } from "../service/tools"
export const ns = ChatStore.ns
const sdk = () => Vue.prototype.sdk as UniplatSdk
const global = () => Vue.prototype.global as Global
const UniplatChatModelName = "UniplatChat"
const model = () => sdk().model(UniplatChatModelName)
const orgId = () => global().org?.id ?? "0"
const getMyinfo = (function () {
let data
return async () => {
if (data != null) return data
data = sdk().getUserInfo()
return data
}
})()
function uniqueMessages(
messages: NonNullable<ChatStore.STATE_CHAT_MSG_HISTORY>
) {
const arr = [...messages]
return unique(arr, function (item, all) {
return all.findIndex((k) => k.id === item.id)
})
}
function filterMessages(
messages: NonNullable<ChatStore.STATE_CHAT_MSG_HISTORY>,
chatid: number
) {
return uniqueMessages(Array.from(messages)).filter(
(k) => k.chat_id === chatid
)
}
const removeRegisterChatEvents: (() => void)[] = []
async function preCacheImgs(msgs: any[]) {
await Promise.all(
msgs.map((k) => {
return new Promise((done: (p: void) => void) => {
if (k.type === "image") {
const msg = JSON.parse(k.msg)
const url = msg.url
if (!isAccessibleUrl(url)) {
done()
}
if (url && isAccessibleUrl(url)) {
const preCache = new Image()
preCache.src = url
preCache.onload = () => done()
setTimeout(done, 2000)
} else {
done()
}
} else {
done()
}
})
})
)
}
export default {
namespaced: true,
state: () => ({
[ChatStore.STATE_CHAT_CREATOR_VISIBLE]: false,
[ChatStore.STATE_CHAT_DIALOG_VISIBLE]: false,
[ChatStore.STATE_CHAT_CURRENT_CHAT_UNIPLAT_ID]: null,
[ChatStore.STATE_CHAT_MSG_HISTORY]: null,
[ChatStore.STATE_CHAT_SENDING_MESSAGES]: [],
[ChatStore.STATE_MY_CHAT_ROOM_LIST]: null,
[ChatStore.STATE_CHAT_CURRENT_CHAT_VERSION]: null,
[ChatStore.STATE_CHAT_CURRENT_CHAT_ID]: null,
[ChatStore.STATE_CHAT_MY_ID]: null,
[ChatStore.STATE_CHAT_MY_UID]: null,
[ChatStore.STATE_CHAT_SOURCE]: 0,
[ChatStore.STATE_CURRENT_CHAT_MEMBERS]: null,
[ChatStore.STATE_CURRENT_CHAT_TITLE]: "",
[ChatStore.STATE_FUNC_SCROLL_TO_BOTTOM]: () => true,
[ChatStore.STATE_CURRENT_CHAT_INPUTING]: [],
[ChatStore.STATE_CURRENT_CHAT_INITING]: false,
}),
mutations: {
[ChatStore.MUTATION_SHOW_CHAT](state) {
state[ChatStore.STATE_CHAT_DIALOG_VISIBLE] = true
},
[ChatStore.MUTATION_HIDE_CHAT](state) {
state[ChatStore.STATE_CHAT_DIALOG_VISIBLE] = false
},
[ChatStore.MUTATION_SHOW_CHAT_CREATOR](state) {
state[ChatStore.STATE_CHAT_CREATOR_VISIBLE] = true
},
[ChatStore.MUTATION_HIDE_CHAT_CREATOR](state) {
state[ChatStore.STATE_CHAT_CREATOR_VISIBLE] = false
},
[ChatStore.MUTATION_SAVE_CHAT_LIST](
state,
data: ChatStore.STATE_MY_CHAT_ROOM_LIST
) {
state[ChatStore.STATE_MY_CHAT_ROOM_LIST] = data
},
[ChatStore.MUTATION_CLEAR_CHAT_MSG_HISTORY](state) {
state[ChatStore.STATE_CHAT_MSG_HISTORY] = null
},
[ChatStore.MUTATION_CLEAR_CURRENT_CHAT_ID](state) {
state[ChatStore.STATE_CHAT_CURRENT_CHAT_ID] = null
},
[ChatStore.MUTATION_SAVE_CURRENT_CHAT_ID](
state,
id: ChatStore.STATE_CHAT_CURRENT_CHAT_ID
) {
state[ChatStore.STATE_CHAT_CURRENT_CHAT_ID] = Number(id)
},
[ChatStore.MUTATION_CLEAR_CURRENT_CHAT_VERSION](state) {
state[ChatStore.STATE_CHAT_CURRENT_CHAT_VERSION] = null
},
[ChatStore.MUTATION_SAVE_CURRENT_CHAT_VERSION](
state,
v: ChatStore.STATE_CHAT_CURRENT_CHAT_VERSION
) {
state[ChatStore.STATE_CHAT_CURRENT_CHAT_VERSION] = v
},
[ChatStore.MUTATION_CLEAR_CURRENT_CHAT_UNIPLAT_ID](state) {
state[ChatStore.STATE_CHAT_CURRENT_CHAT_UNIPLAT_ID] = null
},
[ChatStore.MUTATION_SAVE_CURRENT_CHAT_UNIPLAT_ID](
state,
id: ChatStore.STATE_CHAT_CURRENT_CHAT_UNIPLAT_ID
) {
state[ChatStore.STATE_CHAT_CURRENT_CHAT_UNIPLAT_ID] = id
},
[ChatStore.MUTATION_PUSH_CHAT_MSG_HISTORY](
state,
data: ChatStore.STATE_CHAT_MSG_HISTORY
) {
const old = state[ChatStore.STATE_CHAT_MSG_HISTORY] || []
const chatid = state[ChatStore.STATE_CHAT_CURRENT_CHAT_ID]
if (chatid == null) return
state[ChatStore.STATE_CHAT_MSG_HISTORY] = Object.freeze(
filterMessages([...old, ...(data || [])], chatid)
)
},
[ChatStore.MUTATION_SAVE_MYSELF_ID](state) {
Chat.getToken().then((token) => {
const eid = decode(token)
state[ChatStore.STATE_CHAT_MY_ID] = eid.eid || eid.sub
state[ChatStore.STATE_CHAT_MY_UID] = eid.sub
})
},
[ChatStore.MUTATION_CLEAR_MYSELF_ID](state) {
state[ChatStore.STATE_CHAT_MY_ID] = null
state[ChatStore.STATE_CHAT_MY_UID] = null
},
[ChatStore.MUTATION_SET_CHAT_SOURCE](
state,
data: ChatStore.STATE_CHAT_SOURCE
) {
state[ChatStore.STATE_CHAT_SOURCE] = data
},
[ChatStore.MUTATION_UNSHIFT_CHAT_MSG_HISTORY](
state,
data: ChatStore.STATE_CHAT_MSG_HISTORY
) {
const old = state[ChatStore.STATE_CHAT_MSG_HISTORY] || []
const chatid = state[ChatStore.STATE_CHAT_CURRENT_CHAT_ID]
if (chatid == null) return
state[ChatStore.STATE_CHAT_MSG_HISTORY] = Object.freeze(
filterMessages([...(data || []), ...old], chatid)
)
},
[ChatStore.MUTATION_SAVE_CURRENT_CHAT_MEMBERS](
state,
data: ChatStore.STATE_CURRENT_CHAT_MEMBERS
) {
state[ChatStore.STATE_CURRENT_CHAT_MEMBERS] = Object.freeze(data)
},
[ChatStore.MUTATION_CLEAR_CURRENT_CHAT_MEMBERS](state) {
state[ChatStore.STATE_CURRENT_CHAT_MEMBERS] = null
},
[ChatStore.MUTATION_SAVE_CHAT_TITLE](
state,
data: ChatStore.STATE_CURRENT_CHAT_TITLE
) {
state[ChatStore.STATE_CURRENT_CHAT_TITLE] = data
},
[ChatStore.MUTATION_CLEAR_CHAT_TITLE](state) {
state[ChatStore.STATE_CURRENT_CHAT_TITLE] = ""
},
[ChatStore.MUTATION_SCROLL_TO_BOTTOM](state) {
state[ChatStore.STATE_FUNC_SCROLL_TO_BOTTOM]()
},
[ChatStore.MUTATION_SAVE_FUNC_SCROLL_TO_BOTTOM](
state,
data: ChatStore.STATE_FUNC_SCROLL_TO_BOTTOM
) {
state[ChatStore.STATE_FUNC_SCROLL_TO_BOTTOM] = data
},
[ChatStore.MUTATION_CLEAR_FUNC_SCROLL_TO_BOTTOM](state) {
state[ChatStore.STATE_FUNC_SCROLL_TO_BOTTOM] = () => true
},
[ChatStore.MUTATION_APPEND_SENDING_MESSAGE]: (
state,
payload: ChatStore.STATE_CHAT_SENDING_MESSAGE
) => {
const current = state[
ChatStore.STATE_CHAT_SENDING_MESSAGES
] as ChatStore.STATE_CHAT_SENDING_MESSAGE[]
if (current) {
current.push(payload)
}
preCacheImgs([payload]).then(() => {
setTimeout(() => {
state[ChatStore.STATE_FUNC_SCROLL_TO_BOTTOM]()
}, 100)
})
},
[ChatStore.MUTATION_REMOVE_SENDING_MESSAGE]: (
state,
payload: number
) => {
const current = state[
ChatStore.STATE_CHAT_SENDING_MESSAGES
] as ChatStore.STATE_CHAT_SENDING_MESSAGE[]
if (current) {
const target = current.find((i) => i.id === payload)
if (target) {
const index = current.indexOf(target)
current.splice(index, 1)
}
}
},
[ChatStore.MUTATION_FAILED_SENDING_MESSAGE]: (
state,
payload: number
) => {
const current = state[
ChatStore.STATE_CHAT_SENDING_MESSAGES
] as ChatStore.STATE_CHAT_SENDING_MESSAGE[]
if (current) {
const target = current.find((i) => i.id === payload)
if (target) {
target.status = -1
}
state[ChatStore.STATE_CHAT_SENDING_MESSAGES] = [...current]
}
},
[ChatStore.MUTATION_SAVE_CURRENT_CHAT_INPUTING]: (function () {
const setTimeoutId: { [key: string]: number } = {}
return (
state: ChatStoreState,
payload: Parameters<ChatStore.MUTATION_SAVE_CURRENT_CHAT_INPUTING>[0]
) => {
const chatId = state[ChatStore.STATE_CHAT_CURRENT_CHAT_ID]
if (chatId == null) return
if (payload.chat_id !== chatId) return
const arr = state[ChatStore.STATE_CURRENT_CHAT_INPUTING]
const eid = payload.eid
if (eid === state[ChatStore.STATE_CHAT_MY_ID]) return
if (arr.includes(eid)) {
window.clearTimeout(setTimeoutId[eid])
} else {
arr.push(eid)
}
setTimeoutId[eid] = window.setTimeout(() => {
arr.splice(arr.indexOf(eid), 1)
}, 4000)
}
})(),
[ChatStore.MUTATION_CLEAR_CURRENT_CHAT_INPUTING]: (state) => {
state[ChatStore.STATE_CURRENT_CHAT_INPUTING] = []
},
[ChatStore.MUTATION_INITING_CHAT]: (state) => {
state[ChatStore.STATE_CURRENT_CHAT_INITING] = true
},
[ChatStore.MUTATION_INITING_CHAT_DONE]: (state) => {
state[ChatStore.STATE_CURRENT_CHAT_INITING] = false
},
},
actions: {
async [ChatStore.ACTION_GET_MY_CHAT_LIST](
{ commit },
...params: Parameters<ChatStore.ACTION_GET_MY_CHAT_LIST>
) {
const { pageData } = await model().list().query({
pageIndex: 1,
item_size: 50,
})
const result = pageData.rows
.map((row) => {
return {
uniplatId: row.id.value,
chat_id: Number(row.ImChatId.value),
msg: row.LastMsgContent.value,
customer_name: row.id.value,
customer_avatar_url: "",
uniplat_version: Number(row.uniplat_version.value),
msg_type: row.LastMsgType.value,
is_finish: row.Status.value,
}
})
.filter((k) => !k.is_finish)
commit(ChatStore.MUTATION_SAVE_CHAT_LIST, {
list: result,
total: pageData.record_count,
})
},
async [ChatStore.ACTION_JOIN_CHAT](
{ commit },
chatId: Parameters<ChatStore.ACTION_JOIN_CHAT>[0]
) {
// return await XimService.getInstance().joinChat(chatId)
},
async [ChatStore.ACTION_GET_CHAT_MESSAGES]({ state, commit }) {
const chatId = state[ChatStore.STATE_CHAT_CURRENT_CHAT_ID]
if (chatId == null) return
try {
const data = await xim.queryLastPageMsg(chatType, chatId, 20)
commit(ChatStore.MUTATION_PUSH_CHAT_MSG_HISTORY, data)
await preCacheImgs(data)
commit(ChatStore.MUTATION_SCROLL_TO_BOTTOM)
return data
} catch (error) {
console.error(error)
}
},
async [ChatStore.ACTION_GET_CHAT_MESSAGES_BEFORE_SPECIFIC_ID](
{ state, commit },
msgId: Parameters<ChatStore.ACTION_GET_CHAT_MESSAGES_BEFORE_SPECIFIC_ID>[0]
) {
const chatId = state[ChatStore.STATE_CHAT_CURRENT_CHAT_ID]
if (chatId == null) return
const data = await xim.queryPrevPageMsg(chatType, chatId, msgId, 10)
commit(ChatStore.MUTATION_UNSHIFT_CHAT_MSG_HISTORY, data)
return data
},
async [ChatStore.ACTION_GET_CHAT_MESSAGES_AFTER_SPECIFIC_ID](
{ state, commit },
msgId: Parameters<ChatStore.ACTION_GET_CHAT_MESSAGES_AFTER_SPECIFIC_ID>[0]
) {
const chatId = state[ChatStore.STATE_CHAT_CURRENT_CHAT_ID]
if (chatId == null) return
const data = await xim.queryNextPageMsg(chatType, chatId, msgId, 10)
commit(ChatStore.MUTATION_PUSH_CHAT_MSG_HISTORY, data)
return data
},
async [ChatStore.ACTION_SEND_MESSAGE](
{ state, dispatch },
params: Parameters<ChatStore.ACTION_SEND_MESSAGE>[0]
) {
const uniplatId =
state[ChatStore.STATE_CHAT_CURRENT_CHAT_UNIPLAT_ID]
const version = state[ChatStore.STATE_CHAT_CURRENT_CHAT_VERSION]
if (uniplatId == null) return
try {
const data = await model()
.action("sendMsg")
.updateInitialParams({
selected_list: [
{ v: Number(version), id: Number(uniplatId) },
],
})
.addInputs_parameter({
LastMsgType: params.msgType,
LastMsgContent: params.msg,
})
.dryExecute()
await dispatch(ChatStore.ACTION_GET_FRESH_MESSAGE)
return data
} catch (error) {
console.error("testing 信息发送失败", error)
}
},
async [ChatStore.ACTION_GET_FRESH_MESSAGE]({
state,
dispatch,
commit,
}) {
const msgs = state[ChatStore.STATE_CHAT_MSG_HISTORY]
let newMsgsArr
if (msgs == null || msgs.length === 0) {
newMsgsArr = await dispatch(ChatStore.ACTION_GET_CHAT_MESSAGES)
} else {
newMsgsArr = await dispatch(
ChatStore.ACTION_GET_CHAT_MESSAGES_AFTER_SPECIFIC_ID,
msgs[msgs.length - 1].id
)
}
const lastMsg = newMsgsArr[newMsgsArr.length - 1]
await preCacheImgs(newMsgsArr)
commit(ChatStore.MUTATION_SCROLL_TO_BOTTOM)
},
async [ChatStore.ACTION_CREATE_NEW_CHAT_BY_SERVICE_MAN](
{ state, commit, dispatch },
params: Parameters<ChatStore.ACTION_CREATE_NEW_CHAT_BY_SERVICE_MAN>[0]
) {
const myId = "1" /* (await getMyinfo()).id */
const action = sdk().model(UniplatChatModelName).action("insert")
action.addInputs_parameter({
OrgId: orgId(),
ModelName: params.modelName,
ObjId: +params.selectedListId,
Creator: myId,
Status: ChatStatus.opening,
})
const detailManager = await action.getDetailParametersManagerByName(
"UniplatChatMember"
)
detailManager.add({
OrgId: orgId(),
Uid: myId,
type: ChatMemberType.member,
})
params.uids.forEach((id) => {
detailManager.add({
OrgId: orgId(),
Uid: id,
type: ChatMemberType.member,
})
})
detailManager.done()
const { id } = await action.dryExecute()
//无法得到chat id
const data = await sdk()
.model(UniplatChatModelName)
.action("createXimChat")
.updateInitialParams({
selected_list: [{ v: 0, id }],
})
.dryExecute()
commit(ChatStore.MUTATION_HIDE_CHAT_CREATOR)
await dispatch(ChatStore.ACTION_GET_MY_CHAT_LIST)
const newChat = state[ChatStore.STATE_MY_CHAT_ROOM_LIST].list.find(
(k) => k.uniplatId === id
)
commit(ChatStore.MUTATION_SHOW_CHAT)
await dispatch(ChatStore.ACTION_SAVE_CURRENT_CHAT_ID_VERSION, {
chatId: newChat.chat_id,
v: newChat.uniplat_version,
uniplatId: newChat.uniplatId,
})
},
async [ChatStore.ACTION_CREATE_NEW_CHAT_BY_CLIENT_SIDE](
{ commit, dispatch },
option: {
customerServiceId?: number | string
customerServiceGroupId?: number
}
) {},
async [ChatStore.ACTION_REGISTER_EVENT]({ dispatch, commit, state }) {
const chatId = state[ChatStore.STATE_CHAT_CURRENT_CHAT_ID]
if (chatId == null) return
const onNewMsg = () => {
dispatch(ChatStore.ACTION_GET_FRESH_MESSAGE)
}
const onMsgRead: ChatNotifyListener = async (e) => {
if (
process.env.VUE_APP_API_CLIENT_ID !==
"teamix-fast-service-merchant"
) {
return
}
console.log("事件消息已读", e)
if (chatId !== e.chat_id) return
const msgs = state[ChatStore.STATE_CHAT_MSG_HISTORY]
if (msgs == null) return
const oldestMsgId = msgs[0].id - 1
const lastMsgId = msgs[msgs.length - 1].id + 1
const freshMsgs = await xim.queryMsgs(
chatType,
chatId,
oldestMsgId < 1 ? 1 : oldestMsgId,
lastMsgId
)
commit(ChatStore.MUTATION_CLEAR_CHAT_MSG_HISTORY)
commit(
ChatStore.MUTATION_PUSH_CHAT_MSG_HISTORY,
msgs.map((msg) => {
msg = { ...msg }
const newMsg = freshMsgs.find(
(freshMsg) => freshMsg.id === msg.id
)
if (newMsg != null) {
msg.read_count = newMsg.read_count
}
return msg
})
)
}
const onInputing: ChatNotifyListener = (e) => {
commit(ChatStore.MUTATION_SAVE_CURRENT_CHAT_INPUTING, e)
}
removeRegisterChatEvents.push(() => {
xim.off("msg", chatId, onNewMsg)
xim.off("chat_notify", "read", onMsgRead)
xim.off("chat_notify", "user.input", onInputing)
})
xim.on("msg", chatId, onNewMsg)
xim.on("chat_notify", "read", onMsgRead)
xim.on("chat_notify", "user.input", onInputing)
},
async [ChatStore.ACTION_SAVE_CURRENT_CHAT_ID_VERSION](
{ commit, dispatch },
params: Parameters<ChatStore.ACTION_SAVE_CURRENT_CHAT_ID_VERSION>[0]
) {
const { chatId, v, uniplatId } = params
commit(ChatStore.MUTATION_CLEAR_CHAT_MSG_HISTORY)
commit(ChatStore.MUTATION_SAVE_CURRENT_CHAT_ID, chatId)
commit(ChatStore.MUTATION_SAVE_CURRENT_CHAT_VERSION, v)
commit(ChatStore.MUTATION_SAVE_CURRENT_CHAT_UNIPLAT_ID, uniplatId)
commit(ChatStore.MUTATION_INITING_CHAT)
removeRegisterChatEvents.forEach((k) => k())
await Promise.all([
dispatch(ChatStore.ACTION_REGISTER_EVENT),
dispatch(ChatStore.ACTION_GET_CHAT_MESSAGES),
dispatch(ChatStore.ACTION_GET_CHAT_MEMBERS),
])
commit(ChatStore.MUTATION_INITING_CHAT_DONE)
},
async [ChatStore.ACTION_CLEAR_CURRENT_CHAT_DATA]({ commit }) {
commit(ChatStore.MUTATION_CLEAR_CURRENT_CHAT_ID)
commit(ChatStore.MUTATION_CLEAR_MYSELF_ID)
commit(ChatStore.MUTATION_CLEAR_CHAT_MSG_HISTORY)
commit(ChatStore.MUTATION_CLEAR_CHAT_TITLE)
commit(ChatStore.MUTATION_CLEAR_CURRENT_CHAT_MEMBERS)
},
async [ChatStore.ACTION_GET_CHAT_MEMBERS]({ commit, state }) {
const chatId = state[ChatStore.STATE_CHAT_CURRENT_CHAT_ID]
if (chatId == null) return
const getChatMembersResult = await xim.fetchChatMembers(chatId)
const chatMembers = getChatMembersResult.args[0] as ChatMember[]
const newChatMembers = await Promise.all(
chatMembers.map(async (member) => {
let result: ChatStore.STATE_CURRENT_CHAT_MEMBERS[number]
try {
let info = await sdk()
.model("user")
.detail(member.eid)
.query()
result = {
...member,
name: info.row.first_name.value as string,
phone: info.row.last_name.value as string,
}
} catch (error) {
console.error(error)
result = member
}
return result
})
)
commit(
ChatStore.MUTATION_SAVE_CURRENT_CHAT_MEMBERS,
unique(newChatMembers, function (item, all) {
return all.findIndex((k) => k.eid === item.eid)
})
)
},
async [ChatStore.ACTION_TERINATE_CHAT]({ state, dispatch }) {
const v = state[ChatStore.STATE_CHAT_CURRENT_CHAT_VERSION]
const id = Number(
state[ChatStore.STATE_CHAT_CURRENT_CHAT_UNIPLAT_ID]
)
await sdk()
.model(UniplatChatModelName)
.action("update")
.updateInitialParams({
selected_list: [{ v, id }],
})
.addInputs_parameter({
Status: ChatStatus.terminated,
})
.execute()
await dispatch(ChatStore.ACTION_GET_MY_CHAT_LIST)
},
},
getters: {
[ChatStore.STATE_CHAT_MSG_HISTORY](state) {
return state[ChatStore.STATE_CHAT_MSG_HISTORY] ?? []
},
[ChatStore.STATE_CHAT_SENDING_MESSAGES](state) {
return state[ChatStore.STATE_CHAT_SENDING_MESSAGES] || []
},
[ChatStore.STATE_CURRENT_CHAT_MEMBERS](state) {
return state[ChatStore.STATE_CURRENT_CHAT_MEMBERS] ?? []
},
[ChatStore.GETTER_CURRENT_CHAT_PRESENT_MEMBERS](state) {
return (state[ChatStore.STATE_CURRENT_CHAT_MEMBERS] ?? []).filter(
(member) => !member.is_exited
)
},
[ChatStore.STATE_CURRENT_CHAT_TITLE](state) {
return state[ChatStore.STATE_CURRENT_CHAT_TITLE] || ""
},
[ChatStore.STATE_CHAT_SOURCE](state) {
return state[ChatStore.STATE_CHAT_SOURCE] || 0
},
},
} as Module<ChatStoreState, RootStoreState>
import * as dto from "../model"
import * as chatDto from "../xim/models/chat"
import { namespace } from "vuex-class"
export enum ChatStatus {
opening = 0,
terminated = 1,
}
export enum ChatMemberType {
member = "25",
admin = "85",
}
export namespace ChatStore {
export const ns = "chatStore"
/* state */
export const STATE_CHAT_CREATOR_VISIBLE = "创建会话弹窗显示状态"
export type STATE_CHAT_CREATOR_VISIBLE = boolean
export const STATE_CHAT_DIALOG_VISIBLE = "会话模块弹窗显示状态"
export type STATE_CHAT_DIALOG_VISIBLE = boolean
export const STATE_MY_CHAT_ROOM_LIST = "我的会话列表"
export type STATE_MY_CHAT_ROOM_LIST = {
list: {
uniplatId: string
chat_id: number
msg: string
customer_name: string
customer_avatar_url: string
uniplat_version: number
msg_type: string
is_finish: 0 | 1
}[]
total: number
}
export const STATE_CHAT_MSG_HISTORY = "某个会话聊天记录"
export type STATE_CHAT_MSG_HISTORY = dto.MessageRequestResult | null
export const STATE_CHAT_SENDING_MESSAGES = "sendingMessages"
export type STATE_CHAT_SENDING_MESSAGES = dto.MessageRequestResult | null
export type STATE_CHAT_SENDING_MESSAGE = dto.Message
export const STATE_CHAT_CURRENT_CHAT_ID = "当前chat-id"
export type STATE_CHAT_CURRENT_CHAT_ID = number | null
export const STATE_CHAT_CURRENT_CHAT_VERSION = "当前chat的Uniplat version"
export type STATE_CHAT_CURRENT_CHAT_VERSION = number | null
export const STATE_CHAT_CURRENT_CHAT_UNIPLAT_ID = "当前chat的Uniplat id"
export type STATE_CHAT_CURRENT_CHAT_UNIPLAT_ID = string | null
export const STATE_CHAT_MY_ID = "聊天窗口显示在右边那个人的id"
export type STATE_CHAT_MY_ID = string | null
export const STATE_CHAT_MY_UID = "聊天窗口显示在右边那个人的uid"
export type STATE_CHAT_MY_UID = string | null
export const STATE_CHAT_SOURCE = "stateChatSource"
export type STATE_CHAT_SOURCE = StateChatSourceDirection
export const STATE_CURRENT_CHAT_INPUTING = "当前会话正在输入的人"
export type STATE_CURRENT_CHAT_INPUTING = string[]
export const STATE_CURRENT_CHAT_INITING = "当前会是否正在初始化"
export type STATE_CURRENT_CHAT_INITING = boolean
/**
* 消息来源,是来自客服端(Server),还是来自于顾客端(Client)
*/
export const enum StateChatSourceDirection {
Server = 1,
Client = 0,
}
export const STATE_CURRENT_CHAT_MEMBERS = "当前会话参与者"
export type STATE_CURRENT_CHAT_MEMBERS =
| readonly (dto.ChatMember & dto.ChatMemberExtraInfo)[]
| null
export const STATE_CURRENT_CHAT_TITLE = "会话标题"
export type STATE_CURRENT_CHAT_TITLE = string
export const STATE_FUNC_SCROLL_TO_BOTTOM = "收到消息后滚动到底部的方法"
export type STATE_FUNC_SCROLL_TO_BOTTOM = () => void
/* getter */
export const GETTER_CURRENT_CHAT_PRESENT_MEMBERS = "当前会话未退出的参与者"
export type GETTER_CURRENT_CHAT_PRESENT_MEMBERS = dto.ChatMembers | null
/* mutation */
export const MUTATION_SHOW_CHAT_CREATOR = "打开创建会话弹窗"
export type MUTATION_SHOW_CHAT_CREATOR = () => void
export const MUTATION_HIDE_CHAT_CREATOR = "关闭创建会话弹窗"
export type MUTATION_HIDE_CHAT_CREATOR = () => void
export const MUTATION_SHOW_CHAT = "打开会话弹窗"
export type MUTATION_SHOW_CHAT = () => void
export const MUTATION_HIDE_CHAT = "关闭会话弹窗"
export type MUTATION_HIDE_CHAT = () => void
export const MUTATION_SAVE_CHAT_LIST = "保存我的会话列表"
export type MUTATION_SAVE_CHAT_LIST = (
list: STATE_MY_CHAT_ROOM_LIST
) => void
export const MUTATION_PUSH_CHAT_MSG_HISTORY = "新增下一页聊天记录"
export type MUTATION_PUSH_CHAT_MSG_HISTORY = (
list: ChatStore.STATE_CHAT_MSG_HISTORY
) => void
export const MUTATION_INITING_CHAT = "当前会话正在初始化"
export type MUTATION_INITING_CHAT = () => void
export const MUTATION_INITING_CHAT_DONE = "当前会话初始完成"
export type MUTATION_INITING_CHAT_DONE = () => void
export const MUTATION_UNSHIFT_CHAT_MSG_HISTORY = "新增上一页聊天记录"
export type MUTATION_UNSHIFT_CHAT_MSG_HISTORY = (
list: ChatStore.STATE_CHAT_MSG_HISTORY
) => void
export const MUTATION_CLEAR_CHAT_MSG_HISTORY = "清空聊天记录"
export type MUTATION_CLEAR_CHAT_MSG_HISTORY = () => void
export const MUTATION_SAVE_CURRENT_CHAT_ID = "保存当前chat-id"
export type MUTATION_SAVE_CURRENT_CHAT_ID = (
chatId: ChatStore.STATE_CHAT_CURRENT_CHAT_ID
) => void
export const MUTATION_CLEAR_CURRENT_CHAT_ID = "清空chat-id"
export type MUTATION_CLEAR_CURRENT_CHAT_ID = () => void
export const MUTATION_SAVE_CURRENT_CHAT_VERSION =
"保存当前chat uniplat version"
export type MUTATION_SAVE_CURRENT_CHAT_VERSION = (
v: ChatStore.STATE_CHAT_CURRENT_CHAT_VERSION
) => void
export const MUTATION_CLEAR_CURRENT_CHAT_VERSION =
"清空chat uniplat version"
export type MUTATION_CLEAR_CURRENT_CHAT_VERSION = () => void
export const MUTATION_SAVE_CURRENT_CHAT_UNIPLAT_ID =
"保存当前chat uniplat id"
export type MUTATION_SAVE_CURRENT_CHAT_UNIPLAT_ID = (
v: ChatStore.STATE_CHAT_CURRENT_CHAT_UNIPLAT_ID
) => void
export const MUTATION_CLEAR_CURRENT_CHAT_UNIPLAT_ID = "清空chat uniplat id"
export type MUTATION_CLEAR_CURRENT_CHAT_UNIPLAT_ID = () => void
export const MUTATION_SAVE_MYSELF_ID =
"保存我的id:聊天窗口显示在右边那个人的id"
export type MUTATION_SAVE_MYSELF_ID = () => void
export const MUTATION_CLEAR_MYSELF_ID =
"清空我的id:聊天窗口显示在右边那个人的id"
export type MUTATION_CLEAR_MYSELF_ID = () => void
export const MUTATION_SET_CHAT_SOURCE = "setChatSource"
export type MUTATION_SET_CHAT_SOURCE = (
payload: StateChatSourceDirection
) => void
export const MUTATION_SAVE_CURRENT_CHAT_MEMBERS = "保存当前会话参与者"
export type MUTATION_SAVE_CURRENT_CHAT_MEMBERS = (
params: ChatStore.STATE_CURRENT_CHAT_MEMBERS
) => void
export const MUTATION_CLEAR_CURRENT_CHAT_MEMBERS = "清空当前会话参与者"
export type MUTATION_CLEAR_CURRENT_CHAT_MEMBERS = () => void
export const MUTATION_SAVE_CHAT_TITLE = "保存会话标题"
export type MUTATION_SAVE_CHAT_TITLE = (
title: ChatStore.STATE_CURRENT_CHAT_TITLE
) => void
export const MUTATION_CLEAR_CHAT_TITLE = "清空会话标题"
export type MUTATION_CLEAR_CHAT_TITLE = () => void
export const MUTATION_SCROLL_TO_BOTTOM = "收到新消息后滚动到底部"
export type MUTATION_SCROLL_TO_BOTTOM = () => void
export const MUTATION_SAVE_FUNC_SCROLL_TO_BOTTOM =
"保存收到新消息后滚动到底部的方法"
export type MUTATION_SAVE_FUNC_SCROLL_TO_BOTTOM = (func: () => void) => void
export const MUTATION_CLEAR_FUNC_SCROLL_TO_BOTTOM =
"删除收到新消息后滚动到底部的方法"
export type MUTATION_CLEAR_FUNC_SCROLL_TO_BOTTOM = () => void
export const MUTATION_APPEND_SENDING_MESSAGE = "appendSendingMessage"
export type MUTATION_APPEND_SENDING_MESSAGE = (payload: dto.Message) => void
export const MUTATION_REMOVE_SENDING_MESSAGE = "removeSendingMessage"
export type MUTATION_REMOVE_SENDING_MESSAGE = (id: number) => void
export const MUTATION_FAILED_SENDING_MESSAGE = "failedSendingMessage"
export type MUTATION_FAILED_SENDING_MESSAGE = (id: number) => void
export const MUTATION_SAVE_CURRENT_CHAT_INPUTING = "保存正在输入"
export type MUTATION_SAVE_CURRENT_CHAT_INPUTING = (
params: chatDto.NotifyMessage
) => void
export const MUTATION_CLEAR_CURRENT_CHAT_INPUTING = "清空正在输入"
export type MUTATION_CLEAR_CURRENT_CHAT_INPUTING = () => void
/* action */
export const ACTION_GET_MY_CHAT_LIST = "获取我的会话列表"
export type ACTION_GET_MY_CHAT_LIST = (keyword?: string) => void
export const ACTION_JOIN_CHAT = "加入某个会话"
export type ACTION_JOIN_CHAT = (chatId: number) => void
export const ACTION_GET_CHAT_MESSAGES = "打开某个会话时获取他的聊天记录"
export type ACTION_GET_CHAT_MESSAGES =
() => Promise<dto.MessageRequestResult>
export const ACTION_GET_CHAT_MESSAGES_BEFORE_SPECIFIC_ID =
"获取某个消息之前的10条消息"
export type ACTION_GET_CHAT_MESSAGES_BEFORE_SPECIFIC_ID = (
msgId: number
) => Promise<dto.MessageRequestResult>
export const ACTION_GET_CHAT_MESSAGES_AFTER_SPECIFIC_ID =
"获取某个消息之后的10条消息"
export type ACTION_GET_CHAT_MESSAGES_AFTER_SPECIFIC_ID = (
msgId: number
) => Promise<dto.MessageRequestResult>
export const ACTION_GET_FRESH_MESSAGE = "获取最新的消息"
export type ACTION_GET_FRESH_MESSAGE =
() => Promise<dto.MessageRequestResult>
export const ACTION_CREATE_NEW_CHAT_BY_SERVICE_MAN = "客服向顾客发起新会话"
export type ACTION_CREATE_NEW_CHAT_BY_SERVICE_MAN = (params: {
modelName: string
selectedListId: string
uids: string[]
}) => Promise<void>
export const ACTION_CREATE_NEW_CHAT_BY_CLIENT_SIDE =
"startNewConversationByCustomerSide"
export type ACTION_CREATE_NEW_CHAT_BY_CLIENT_SIDE = (option: {
customerServiceId?: number | string
customerServiceGroupId?: number | string
}) => Promise<number>
export const ACTION_SAVE_CURRENT_CHAT_ID_VERSION = "action:保存当前chat-id"
export type ACTION_SAVE_CURRENT_CHAT_ID_VERSION = (params: {
chatId: ChatStore.STATE_CHAT_CURRENT_CHAT_ID
v: number
uniplatId: STATE_CHAT_CURRENT_CHAT_UNIPLAT_ID
}) => Promise<void>
export const ACTION_CLEAR_CURRENT_CHAT_DATA = "action:清空当前会话数据"
export type ACTION_CLEAR_CURRENT_CHAT_DATA = () => Promise<void>
export const ACTION_REGISTER_EVENT = "给当前会话注册事件通知"
export type ACTION_REGISTER_EVENT = () => Promise<void>
export const ACTION_GET_CHAT_MEMBERS = "获取会话成员列表"
export type ACTION_GET_CHAT_MEMBERS = () => Promise<void>
export const ACTION_SEND_MESSAGE = "发送消息"
export type ACTION_SEND_MESSAGE = (params: {
msgType: "text" | "image" | "file" | "voice" | "video"
msg: string
}) => void
export const ACTION_TERINATE_CHAT = "结束会话"
export type ACTION_TERINATE_CHAT = () => Promise<void>
}
export interface ChatStoreState {
[ChatStore.STATE_CHAT_CREATOR_VISIBLE]: ChatStore.STATE_CHAT_CREATOR_VISIBLE
[ChatStore.STATE_CHAT_MSG_HISTORY]: ChatStore.STATE_CHAT_MSG_HISTORY
[ChatStore.STATE_CHAT_SENDING_MESSAGES]: ChatStore.STATE_CHAT_SENDING_MESSAGES
[ChatStore.STATE_CHAT_CURRENT_CHAT_ID]: ChatStore.STATE_CHAT_CURRENT_CHAT_ID
[ChatStore.STATE_CHAT_CURRENT_CHAT_UNIPLAT_ID]: ChatStore.STATE_CHAT_CURRENT_CHAT_UNIPLAT_ID
[ChatStore.STATE_CHAT_CURRENT_CHAT_VERSION]: ChatStore.STATE_CHAT_CURRENT_CHAT_VERSION
[ChatStore.STATE_CHAT_MY_UID]: ChatStore.STATE_CHAT_MY_UID
[ChatStore.STATE_CHAT_MY_ID]: ChatStore.STATE_CHAT_MY_ID
[ChatStore.STATE_CHAT_SOURCE]: ChatStore.STATE_CHAT_SOURCE
[ChatStore.STATE_CURRENT_CHAT_MEMBERS]: ChatStore.STATE_CURRENT_CHAT_MEMBERS
[ChatStore.STATE_CURRENT_CHAT_TITLE]: ChatStore.STATE_CURRENT_CHAT_TITLE
[ChatStore.STATE_FUNC_SCROLL_TO_BOTTOM]: ChatStore.STATE_FUNC_SCROLL_TO_BOTTOM
[ChatStore.STATE_CURRENT_CHAT_INPUTING]: ChatStore.STATE_CURRENT_CHAT_INPUTING
[ChatStore.STATE_CURRENT_CHAT_INITING]: ChatStore.STATE_CURRENT_CHAT_INITING
[ChatStore.STATE_MY_CHAT_ROOM_LIST]: ChatStore.STATE_MY_CHAT_ROOM_LIST
[ChatStore.STATE_CHAT_DIALOG_VISIBLE]: ChatStore.STATE_CHAT_DIALOG_VISIBLE
}
export const chatStore = namespace(ChatStore.ns)
export class ImageCompresser {
public static dataURL2File(data: string, name: string) {
const arr = data.split(",")
if (arr) {
if (arr[0]) {
const match = arr[0].match(/:(.*?);/)
if (match) {
const mime = match[1]
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
// eslint-disable-next-line no-plusplus
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}
return new File([u8arr], name, { type: mime })
}
}
}
return null
}
public static readFile2Image(
file: File,
maxWidth: number,
maxHeight: number
): Promise<File | null> {
return new Promise((resolve, reject) => {
const img = new Image()
const reader = new FileReader()
reader.onload = (e) => {
if (e && e.target && e.target.result) {
img.src = e.target.result as string
} else {
reject()
}
}
reader.readAsDataURL(file)
img.onload = () => {
const originWidth = img.naturalWidth
const originHeight = img.naturalHeight
if (originWidth > maxWidth || originHeight > maxHeight) {
ImageCompresser.compressImg(img, maxWidth, maxHeight)
.then((b) => {
if (b) {
const newFile = new File([b], file.name)
// 如果压缩完还不如原始图片size小,直接返回原始图片
resolve(
newFile.size <= file.size ? newFile : file
)
} else {
reject()
}
})
.catch(reject)
} else {
resolve(file)
}
}
})
}
/**
* 压缩图片
* @param img 被压缩的img对象
* @param type 压缩后转换的文件类型
* @param mx 触发压缩的图片最大宽度限制
* @param mh 触发压缩的图片最大高度限制
*/
public static compressImg(
img: HTMLImageElement,
mx: number,
mh: number,
type?: string
): Promise<Blob | null> {
return new Promise((resolve, reject) => {
const canvas = document.createElement("canvas")
const context = canvas.getContext("2d")
if (!context) {
reject()
return
}
const { width: originWidth, height: originHeight } = img // 最大尺寸限制
const maxWidth = mx
const maxHeight = mh // 目标尺寸
let targetWidth = originWidth
let targetHeight = originHeight
if (originWidth > maxWidth || originHeight > maxHeight) {
if (originWidth / originHeight > 1) {
// 宽图片
targetWidth = maxWidth
targetHeight = Math.round(
maxWidth * (originHeight / originWidth)
)
} else {
// 高图片
targetHeight = maxHeight
targetWidth = Math.round(
maxHeight * (originWidth / originHeight)
)
}
}
canvas.width = targetWidth
canvas.height = targetHeight
context.clearRect(0, 0, targetWidth, targetHeight)
context.drawImage(img, 0, 0, targetWidth, targetHeight)
canvas.toBlob((blob) => {
resolve(blob)
canvas.remove()
}, type || "image/png")
})
}
}
import { UserAgentHelper } from "../user-agent"
export enum TransformTarget {
Mobile,
Desktop,
}
export enum TransformDirection {
Home,
Search,
Favorite,
Followed,
HomeCategory,
Orders,
Order,
StationIndex,
StationSearch,
Articles,
Article,
Services,
Service,
Questions,
Question,
Documents,
Document,
Website,
}
export interface PcMobileTransformerOption {
/**
* 手机端根host,如 https://www.teammix.com/ms
*/
mobileHost?: string
/**
* 桌面端根host,如 https://www.teammix.com/s
*/
desktopHost?: string
}
/**
* 提供桌面端和手机端URL转换的方法
*/
export class PcMobileTransformer {
private static target = TransformTarget.Mobile
private static host = ""
private static readonly desktop2MobileMapping = new Map<
TransformDirection,
string
>([
[TransformDirection.Home, "/"],
[TransformDirection.Search, "/search"],
[TransformDirection.Favorite, "/mine/favorite"],
[TransformDirection.Followed, "/mine/followed"],
])
private static readonly rebuildMapping = new Map<
TransformDirection,
string
>([
[TransformDirection.StationIndex, "/s/{0}"],
[TransformDirection.Articles, "/s/{0}/articles/{1}"],
[TransformDirection.Article, "/s/{0}/article/{1}"],
[TransformDirection.Services, "/s/{0}/services/{1}"],
[TransformDirection.Service, "/s/{0}/service/{1}"],
[TransformDirection.Questions, "/s/{0}/questions/{1}"],
[TransformDirection.Question, "/s/{0}/question/{1}"],
[TransformDirection.Documents, "/s/{0}/documents/{1}"],
[TransformDirection.Document, "/s/{0}/document/{1}"],
[TransformDirection.Website, "/s/{0}/website/{1}"],
])
public static setup(option: PcMobileTransformerOption) {
if (option.desktopHost) {
PcMobileTransformer.target = TransformTarget.Desktop
PcMobileTransformer.host = PcMobileTransformer.trimEnd(
option.desktopHost
)
}
if (option.mobileHost) {
PcMobileTransformer.target = TransformTarget.Mobile
PcMobileTransformer.host = PcMobileTransformer.trimEnd(
option.mobileHost
)
}
}
public static isNeed2Redirect() {
if (PcMobileTransformer.target === TransformTarget.Mobile) {
return PcMobileTransformer.isMobileDevice()
}
return false
}
private static trimEnd(input: string) {
if (input && input.endsWith("/")) {
return input.substring(0, input.length - 1)
}
return input
}
private static isMobileDevice() {
const ua = window.navigator.userAgent
return UserAgentHelper.isMobile(ua)
}
public static transform(
parameters: (string | number)[],
target: TransformDirection
) {
let v = ""
if (PcMobileTransformer.target === TransformTarget.Desktop) {
v = PcMobileTransformer.transform2Desktop(parameters, target)
}
if (PcMobileTransformer.target === TransformTarget.Mobile) {
v = PcMobileTransformer.transform2Mobile(parameters, target)
}
return v.replace(/\.html/gi, "")
}
private static transform2Mobile(
parameters: (string | number)[],
target: TransformDirection
) {
const url = PcMobileTransformer.desktop2MobileMapping.get(target)
if (url) {
let targetUrl = `${PcMobileTransformer.host}${url}`
for (let i = 0; i < parameters.length; i++) {
targetUrl = targetUrl.replace(`{${i}}`, parameters[i] + "")
}
return targetUrl
}
const base = PcMobileTransformer.rebuildMapping.get(target)
if (base) {
let rebuildUrl = base
for (let i = 0; i < parameters.length; i++) {
rebuildUrl = rebuildUrl.replace(`{${i}}`, parameters[i] + "")
}
return `${PcMobileTransformer.host}${rebuildUrl}`
}
return PcMobileTransformer.host
}
private static transform2Desktop(
parameters: (string | number)[],
target: TransformDirection
) {
return target + ""
}
}
export class SeoHelper {
public static formatTitle(title?: string) {
if (
title &&
(title.indexOf("TeamMix") > -1 || title.indexOf("亲亲小站") > -1)
) {
return title
}
return `${title || ""}-亲亲小站`
}
public static updateFavicon(path: string) {
const link = document.querySelector(
"link[rel*='icon']"
) as HTMLLinkElement
if (link) {
link.href = path
} else {
const l = document.createElement("link")
l.type = "image/x-icon"
l.rel = "shortcut icon"
l.href = path
document.getElementsByTagName("head")[0].appendChild(l)
}
}
}
export const enum UserAgentType {
None = 0,
/**
* 安卓平板设备
*/
Tablet = 1 << 1,
DesktopOthers = 1 << 2,
DesktopSafari = 1 << 3,
DesktopChrome = 1 << 4,
DesktopFirefox = 1 << 5,
DesktopIE = 1 << 6,
DesktopEdge = 1 << 7,
Desktop = DesktopOthers |
DesktopSafari |
DesktopChrome |
DesktopFirefox |
DesktopIE |
DesktopEdge,
IPhone = 1 << 8,
IPad = 1 << 9,
/**
* 安卓手机设备
*/
Andriod = 1 << 10,
/**
* 移动设备(不含包平板设备)
*/
MobilePhone = IPhone | Andriod,
/**
* 所有移动设备,包含手机+平板
*/
Mobile = MobilePhone | Tablet | IPhone,
}
export class UserAgentHelper {
private static contains(source: string, match: string) {
return source.indexOf(match) > -1
}
private static containsAll(source: string, matches: string[]) {
for (const item of matches) {
if (!UserAgentHelper.contains(source, item)) {
return false
}
}
return true
}
private static containsAny(source: string, matches: string[]) {
for (const item of matches) {
if (UserAgentHelper.contains(source, item)) {
return true
}
}
return false
}
public static getType(ua: string) {
const lower = ua.toLowerCase()
if (UserAgentHelper.contains(lower, "ipad")) {
return UserAgentType.IPad
}
if (UserAgentHelper.contains(lower, "android_tablet")) {
return UserAgentType.Tablet
}
if (UserAgentHelper.contains(lower, "iphone")) {
return UserAgentType.IPhone
}
if (UserAgentHelper.contains(lower, "ipod")) {
return UserAgentType.IPhone
}
if (UserAgentHelper.containsAny(lower, ["android", "mobile"])) {
return UserAgentType.Andriod
}
if (UserAgentHelper.contains(lower, "safari")) {
return UserAgentType.DesktopSafari
}
if (UserAgentHelper.containsAny(lower, ["edge", "edg"])) {
return UserAgentType.DesktopEdge
}
if (UserAgentHelper.contains(lower, "trident")) {
return UserAgentType.DesktopIE
}
if (UserAgentHelper.contains(lower, "firefox")) {
return UserAgentType.DesktopFirefox
}
if (UserAgentHelper.contains(lower, "chrome")) {
return UserAgentType.DesktopChrome
}
if (UserAgentHelper.containsAny(lower, ["360", "baidu", "qq"])) {
return UserAgentType.DesktopOthers
}
return UserAgentType.None
}
public static isMobile(ua: string) {
const type = UserAgentHelper.getType(ua)
return type === UserAgentType.Andriod || type === UserAgentType.IPhone
}
}
/* eslint-disable */
export function throttle(time: number = 100) {
let pending = false
return function (target: any, name: string): any {
const originFunc = target[name]
const newFunc = function (this: Vue, ...params: any[]) {
if (pending) {
return
}
pending = true
new Promise(async (res) => {
try {
await originFunc.apply(this, params)
} finally {
setTimeout(res, time)
}
}).finally(() => (pending = false))
}
target[name] = newFunc
return target
}
}
export function unique<T>(arr: T[], existed: (item: T, all: T[]) => number) {
return arr.filter(function (item, index, arr) {
return index === existed(item, arr)
})
}
const k = 1024,
m = 1024 * k
export function formatFileSize(size: number) {
if (size === undefined || size === null) {
return ""
}
if (size < k) {
return size + "B"
}
if (size < m) {
return Number((size / k).toFixed(2)) + "K"
}
return Number((size / m).toFixed(2)) + "M"
}
export function uuid() {
const s: (number | string)[] = []
const hexDigits = "0123456789abcdef"
for (let i = 0; i < 36; i++) {
s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1)
}
s[14] = "4"
s[19] = hexDigits.substr((+s[19] & 0x3) | 0x8, 1)
s[8] = s[13] = s[18] = s[23] = "-"
return s.join("")
}
const URL_REGEX =
/((?:(?:https?|ftp|file):\/\/|www\.|ftp\.)(?:\([-A-Z0-9+&@#\/%=~_|$?!:,.]*\)|[-A-Z0-9+&@#\/%=~_|$?!:,.])*(?:\([-A-Z0-9+&@#\/%=~_|$?!:,.]*\)|[A-Z0-9+&@#\/%=~_|$]))/gim
const LINE_URL_REGEX =
/((?:(?:https?|ftp|file):\/\/|www\.|ftp\.)(?:\([-A-Z0-9+&@#\/%=~_|$?!:,.]*\)|[-A-Z0-9+&@#\/%=~_|$?!:,.])*(?:\([-A-Z0-9+&@#\/%=~_|$?!:,.]*\)|[A-Z0-9+&@#\/%=~_|$]))/i
export function replaceText2Link(text: string) {
return text.replace(URL_REGEX, '<a href="$1" target="_blank">$1</a>')
}
export function isUrl(text: string) {
return LINE_URL_REGEX.test(text)
}
function b64DecodeUnicode(str: string) {
return decodeURIComponent(
atob(str).replace(/(.)/g, function (m, p) {
let code = p.charCodeAt(0).toString(16).toUpperCase()
if (code.length < 2) {
code = "0" + code
}
return "%" + code
})
)
}
function base64_url_decode(str: string) {
let output = str.replace(/-/g, "+").replace(/_/g, "/")
switch (output.length % 4) {
case 0:
break
case 2:
output += "=="
break
case 3:
output += "="
break
default:
throw "Illegal base64url string!"
}
try {
return b64DecodeUnicode(output)
} catch {
return atob(output)
}
}
export function decode(token: string) {
return JSON.parse(base64_url_decode(token.split(".")[1]))
}
const STANDARD = Math.pow(10, 12)
const ONE_DAY_TICKS = 86400000
const ONE_MINUTE_TICKS = 60 * 1000
const ONE_HOUR_TICKS = 60 * 60 * 1000
export const enum TimeFormatRule {
/**
* 12小时制
*/
Hour12,
/**
* 24小时制
*/
Hour24,
}
function formatHour2Friendly(hour: number) {
if (hour <= 1) {
return "凌晨"
}
if (hour < 12) {
return "上午"
}
if (hour === 12) {
return "中午"
}
if (hour < 20) {
return "下午"
}
return "晚上"
}
function formatHour12Unit(hour: number, rule: TimeFormatRule) {
if (rule === TimeFormatRule.Hour12) {
return hour > 12 ? hour - 12 : hour
}
return hour
}
function format2DetailTime(hour: number, time: Date, rule: TimeFormatRule) {
let hourString = ""
const h = formatHour12Unit(hour, rule)
if (h < 10) {
hourString = "0" + h
} else {
hourString = h + ""
}
let minuteString = ""
const m = time.getMinutes()
if (m < 10) {
minuteString = "0" + m
} else {
minuteString = "" + m
}
return `${formatHour2Friendly(hour)} ${hourString}:${minuteString}`
}
function isSameDay(d1: Date, d2: Date) {
return (
d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate()
)
}
function isInMinute(d1: Date, d2: Date) {
return Math.abs(d1.valueOf() - d2.valueOf()) <= ONE_MINUTE_TICKS
}
function isInHour(d1: Date, d2: Date) {
return Math.abs(d1.valueOf() - d2.valueOf()) < ONE_HOUR_TICKS
}
function isYesterday(date: Date) {
const now = new Date()
now.setHours(0)
now.setMinutes(0)
now.setSeconds(0)
now.setMilliseconds(0)
const ticks = now.valueOf()
const yesterday = ticks - ONE_DAY_TICKS
const v = date.valueOf()
return v >= yesterday && v < ticks
}
function isIn6Days(date: Date) {
const now = new Date()
now.setHours(0)
now.setMinutes(0)
now.setSeconds(0)
now.setMilliseconds(0)
const ticks = now.valueOf()
const sixDaysAgo = ticks - ONE_DAY_TICKS * 6
const v = date.valueOf()
return v >= sixDaysAgo && v < ticks
}
function isSameYear(d1: Date, d2: Date) {
return d1.getFullYear() === d2.getFullYear()
}
const DAY_MAPPING: { [key: number]: string } = {
0: "日",
1: "一",
2: "二",
3: "三",
4: "四",
5: "五",
6: "六",
}
function getDayInWeek(time: Date) {
const d = time.getDay()
return DAY_MAPPING[d]
}
function formatWithTwoNumber(number: number) {
return number < 10 ? `0${number}` : `${number}`
}
function formatTime2MonthDate(time: Date) {
return `${formatWithTwoNumber(time.getMonth() + 1)}${formatWithTwoNumber(
time.getDate()
)}日`
}
function formatTime2ShortMonthDate(time: Date) {
return `${formatWithTwoNumber(time.getMonth() + 1)}/${formatWithTwoNumber(
time.getDate()
)}`
}
function formatTime2YearMonthDate(time: Date) {
return `${time.getFullYear()}${formatWithTwoNumber(
time.getMonth() + 1
)}${formatWithTwoNumber(time.getDate())}日`
}
function formatTime2ShortYearMonthDate(time: Date) {
return `${time.getFullYear()}/${formatWithTwoNumber(
time.getMonth() + 1
)}/${formatWithTwoNumber(time.getDate())}`
}
/**
* 格式化时间。 short模式下格式
* * 刚刚(1分钟内) [在short模式下不生效]
* * {x}分钟前 [在short模式下不生效]
* * 今天,格式化为:[上午|中午|下午] hh:mm | [两者一致]
* 昨天,格式化为:昨天 [上午|中午|下午] hh:mm | 昨天
* 2~6天前,格式化为:星期几 [上午|中午|下午] hh:mm | 星期几
* 同年时间,格式化为:07月08日 [上午|中午|下午] hh:mm | 07/12
* 非同年时间,格式化为:2019年07月08日 [上午|中午|下午] hh:mm | 2019/01/02
* @param time Javascript标准时间
* @param option { rule: 选择是12小时制还是24小时制,short:是否以短时间形式显示 }
*/
export function formatTime(
time: number,
option = { rule: TimeFormatRule.Hour12, short: false }
) {
if (time < STANDARD) {
time *= 1000
}
const t = new Date(time)
const now = new Date()
if (!option.short) {
if (isInMinute(t, now)) {
return "刚刚"
}
if (isInHour(t, now)) {
return `${Math.round(
Math.abs(t.valueOf() - now.valueOf()) / 1000 / 60
)}分钟前`
}
}
const hour = t.getHours()
if (isSameDay(t, now)) {
return format2DetailTime(hour, t, option.rule)
}
if (isYesterday(t)) {
if (option.short) {
return "昨天"
}
return "昨天 " + format2DetailTime(hour, t, option.rule)
}
if (isIn6Days(t)) {
if (option.short) {
return `星期${getDayInWeek(t)}`
}
return (
`星期${getDayInWeek(t)} ` + format2DetailTime(hour, t, option.rule)
)
}
if (isSameYear(t, now)) {
if (option.short) {
return formatTime2ShortMonthDate(t)
}
return (
formatTime2MonthDate(t) +
" " +
format2DetailTime(hour, t, option.rule)
)
}
if (option.short) {
return formatTime2ShortYearMonthDate(t)
}
return (
formatTime2YearMonthDate(t) +
" " +
format2DetailTime(hour, t, option.rule)
)
}
export default "group"
import xim from "./xim"
import { ChatOption, TokenStringGetter } from "./../model"
import { ChatLoggerService } from "./logger"
import { EmojiService } from "../service/emoji"
import tokenManager from "./token"
class Chat {
private token!: TokenStringGetter
private userMapping: { [key: string]: { name: string; avatar: string } } =
{}
private webHost = false
public async setup(option: ChatOption) {
if (!option) {
throw new Error(`You must specify a chat option for chat service`)
}
// if (!option.userTokenString) {
// throw new Error(`You must specify a user token for chat service`)
// }
if (!option.webSocketUri) {
throw new Error(
`You must specify a web socket address for chat service`
)
}
// if (option.enterpriseTokenString) {
// this.token = option.enterpriseTokenString
// } else {
this.token = option.userTokenString
tokenManager.save(this.token)
// }
this.userMapping = option.userMapping || {}
// if (option.webHost) {
// this.webHost = option.webHost
// }
// XimService.buildInstance(new XimService(option));
EmojiService.raiseOnReady(this.token)
return this.initChatSdk(option.webSocketUri)
}
public isWebHost() {
return this.webHost
}
private trimToken(token: string) {
return token.replace(/^Bearer\s/, "")
}
public async getToken() {
return this.trimToken(await this.token())
}
private async initChatSdk(uri: string) {
if (xim.isConnected()) {
return uri
}
return new Promise((resolve: (p?: unknown) => void) => {
xim.open(uri, this.token)
this.registerXimEvent(resolve)
})
}
public registerXimEvent(onConnected?: () => void) {
xim.off("status", (e) => this.raiseOnStatusChanged(e, onConnected))
xim.on("status", (e) => this.raiseOnStatusChanged(e, onConnected))
}
private raiseOnStatusChanged(e: any, onConnected?: () => void) {
if (e === "CONNECTED") {
if (onConnected) {
onConnected()
}
}
this.debug(`client status ${e}`)
}
public getUserMapping() {
return this.userMapping
}
public appendUserMappingItem(eid: string, name: string, avatar: string) {
if (this.userMapping[eid]) {
if (name) {
this.userMapping[eid].name = name
}
if (avatar) {
this.userMapping[eid].avatar = avatar
}
} else {
this.userMapping[eid] = { name, avatar }
}
}
private debug(message: string) {
ChatLoggerService.logger?.debug(message)
}
}
export default new Chat()
import { ChatServiceLogger } from "../model"
export class ChatLoggerService {
public static logger = console
}
export interface Chat {
type: string
chat_id: number
service_id: number
title: string
// tag: string;
// ext: string;
create_time: number
update_time: number
// exit_msg_id: number;
// is_exited: boolean;
// dnd: number;
is_top: boolean
// label: string;
// join_msg_id: number;
// last_read_msg_id: number;
// biz_id: string;
// business_data: string;
// is_finish: boolean;
// is_deleted: boolean;
// is_remove: boolean;
// member_type: number;
// ref_id: number;
unread_msg_count: number
// at_me: boolean;
// at_all: boolean;
// last_login_oid: string;
last_msg_eid: string
last_msg_name: string
last_msg_ts: number
msg_id: number
msg_type: string
msg: string
}
export interface Message {
chat_id: number
oid: string
eid: string
id: number
ts: number
type: string
msg: string
total_read_count: number
read_count: number
like_count: number
ref_id: number
at_id: string
is_read: boolean
like: boolean
create_time: number
update_time: number
status: number
url: string
is_open: boolean
}
export interface NotifyMessage {
chat_type: string
chat_id: number
eid: string
ts: number
msg_type: string
msg: string
}
export interface Member {
chat_id: number
oid: string
eid: string
type: number
is_exited: boolean
is_remove: boolean
is_top: boolean
label: string
create_time: number
update_time: number
nick_name: string
}
/**
* 消息类型
* @param text 文本
* @param file 文件
* @param image 图片
* @param voice 语音
* @param notify 通知类型
* @param text.notice 文本消息:公告文本
* @param video 视频
* @param url 卡片消息
* @param forward 转发消息
* @param quote 引用消息
* @param comment.forward 转发评论消息
* @param time 时间行
*/
export type ChatMessageType =
| "text"
| "file"
| "image"
| "voice"
| "notify"
| "text.notice"
| "video"
| "url"
| "forward"
| "quote"
| "comment.forward"
| "time"
export type ChatInputBoxData = { key: string } & (
| { at_id: string; type: "text"; body: TextMessageBody }
| { type: "image"; body: ImageMessageBody }
| { type: "file"; body: FileMessageBody }
| { type: "video"; body: VideoMessageBody }
| { at_id: string; type: "quote"; body: QuoteMessageBody }
| { at_id: string; type: "tm-at-member"; body: TextMessageBody }
)
export type TextMessageBody = {
text: string
}
export type FileMessageBody = {
name: string
url: string // 生消息是本地path,熟消息是文件url_id
size: number // number, 可选,文件大小:单位-字节
remark: string
isNeedUploaded?: boolean // pc客户端自定义属性(只有生消息的消息体才有该属性, 值为false时不需要上传, true和undefined时需要上传)
source?: {
source_type: number //来源类型(若为团队小站文件,则来源类型为团队小站文件)
source_id: number //来源id(若为团队小站文件,此处为团队工作站id)
source_name: string //来源名称(若为团队小站文件,此处传入团队小站名称)
source_icon: string //来源图标(若为团队小站文件,此处传入团队icon的url)
extra?: { [prop: string]: any } //附加信息
}
}
export type ImageMessageBody = {
name: string
url: string
size: number
w: number
h: number
thumbnail?: string // 缩略图地址
preview?: string // 预览图地址
remark: string
}
export type VoiceMessageBody = {
name: string
url: string
size: number
duration: number // 语音时间:单位-ms
}
//第一顺序 default_text 第二顺序 operator_text、receiver_text
export type NotifyMessageBody = {
operator_id: string
receiver_ids: string[]
default_text: string //默认文本
operator_text: string //操作人文本
receiver_text: string //处理人文本
}
export type TextNoticeMessageBody = {
title: string
text: string
is_at_all: boolean //是否@全部人
}
export type VideoMessageBody = {
name: string
url: string // 生消息是本地path,熟消息是文件url_id
size: number
cover?: string
duration: number
w: number
h: number
isNeedUploaded?: boolean // pc客户端自定义属性(只有生消息的消息体才有该属性, 值为false时不需要上传, true和undefined时需要上传)
}
export type UrlMessageBody = {
title: string
icon: string
url: string
desc: string
source?: {
source_type: number //来源类型(若为团队小站文件,则来源类型为团队小站文件)
source_id: number //来源id(若为团队小站文件,此处为团队工作站id)
source_name: string //来源名称(若为团队小站文件,此处传入团队小站名称)
source_icon: string //来源图标(若为团队小站文件,此处传入团队icon的url)
extra?: { [prop: string]: any } //附加信息
}
}
export type ForwardMessageBody = {
snap: string
chat_type: string //会话类型
chat_id: number // 会话id
msg_ids: number[] //消息id集合
}
export type QuoteMessageBody = {
text: string
quote_text: string // JSON字符串
quote_msg_type: string
quote_eid: string
quote_msg_id: number //引用id
}
export type CommentForwardMessageBody = {
snap: string
channel_id: number //团队工作站id
topic_id: number // 主题id
comment_ids: number[] //评论id集合
}
// export type SpecifiedChatRecordMsg = SpecifiedChatRecord & {
// message?: Message | undefined | null,
// messageBody?: {
// "name"?: string, // "高达.txt",
// "url"?: string, // "9a5bd43db73681f6a90b9e717d8698c2",
// "size"?: number, // 4,
// "remark"?: string, // "C:\\Users\\Administrator\\Desktop\\高达.txt"
// }
// };
// 客服
export interface CsUser {
id: number
oid: string
eid: string
is_deleted: boolean
delete_time: number
delete_oid: string
delete_eid: string
service_id: number
type: number
name: string
mobile: string
email: string
description: string
create_time: number
update_time: number
chat_count: number
status: number
}
/** 用户类型 */
export enum UserType {
Staff = 1, // 客服
Customer, // 客户
}
export default interface User {
uid: string
oid: string
eid: string
jwt: string
userType: UserType
}
import { ChatOption, TokenStringGetter } from "./../model"
function Token() {
let _token: TokenStringGetter
return {
save(token: TokenStringGetter) {
_token = token
},
getToken() {
return _token()
},
}
}
export default Token()
import Vue from "vue"
import { XChatClient, wampDebug } from "xchat-client"
import { NotifyMessage, Message } from "./models/chat"
import { ChatLoggerService } from "./logger"
import { TokenStringGetter } from "./../model"
import chatType from "../xim/chat-type"
wampDebug(true)
const DefaultMsgPageSize = 20
function emptyFunc() {
return
}
export type MsgListener = (msg: Message) => void
export type ChatNotifyListener = (msg: NotifyMessage) => void
export type StatusChangeListener = (status: any, details: any) => void
export enum Events {
Msg = "msg",
Status = "status",
}
export enum Kind {
Chat = "chat",
ChatNotify = "chat_notify",
UserNotify = "user_notify",
}
export class Xim {
private eventBus = new Vue()
private client?: XChatClient
private paramsForReconnection?: {
url: string
token: TokenStringGetter
}
public close() {
if (this.client) {
if (this.client.connected) {
this.client.close()
}
this.client.onconnected = emptyFunc
this.client.onmsg = emptyFunc
this.client = undefined
}
}
private connectionPending = false
public async open(url: string, token: TokenStringGetter) {
this.connectionPending = true
await new Promise((success: (p?: unknown) => void, failed) => {
this.paramsForReconnection = { url, token }
this.close()
token().then((t) => {
const client = new XChatClient(url, this.trimToken(t))
this.client = client
client.onstatuschange = (status: any, details: any) => {
this.onStatusChange.call(this, status, details)
if (status === "DISCONNECTED" || status === "CLOSED") {
failed()
}
}
client.onconnected = () => {
this.onConnected.apply(this)
success()
}
client.onmsg = this.handleMsg.bind(this)
client.open()
})
}).finally(() => (this.connectionPending = false))
}
private trimToken(token: string) {
return token.replace(/^Bearer\s/, "")
}
/**
* token过期或者切换用户登录时,需要设置新的token
*/
public async setToken(token: TokenStringGetter) {
const client = this.client!
client.close()
client.setToken(this.trimToken(await token()))
client.open()
}
public fetchMsgInBox(chatId: number, msgId: number) {
return this.client!.fetchMsgInBox(chatType, chatId, msgId)
}
/**
* 发送消息
*/
public sendMsg(
chatType: string,
chatId: number,
msgType: string,
msg: string
) {
this.checkConnected()
return this.client!.sendMsg(chatType, chatId, msgType, msg, "", {})
}
public inputing(chatId: number) {
this.checkConnected()
return this.client!.userInput(chatType, chatId)
}
/*
* 查询会话
*/
public fetchChatMembers(chat_id: number) {
this.checkConnected()
return this.client!.fetchChatMembers(chat_id)
}
/**
* 查询消息
*/
public async queryMsgs(
chatType: string,
chatId: number,
lid = 0,
rid = 0,
limit = DefaultMsgPageSize,
desc = true
): Promise<Message[]> {
this.checkConnected()
const res = await this.client!.fetchChatMsgs(chatType, chatId, {
lid,
rid,
limit,
desc,
})
return res.args[0]
}
private setMessagesRead(chatId: number, msg: Message[]) {
if (msg.length === 0) return
return this.setRead(chatId, msg[0].id, msg[msg.length - 1].id)
}
/** 查询最后一页消息 */
public async queryLastPageMsg(
chatType: string,
chatId: number,
limit: number
) {
const data = await this.queryMsgs(chatType, chatId, 0, 0, limit, true)
this.setMessagesRead(chatId, data)
return data
}
/** 查询上一页消息 */
public async queryPrevPageMsg(
chatType: string,
chatId: number,
msgId: number,
limit: number
) {
const data = await this.queryMsgs(
chatType,
chatId,
0,
msgId,
limit,
true
)
this.setMessagesRead(chatId, data)
return data
}
/** 查询下一页消息 */
public async queryNextPageMsg(
chatType: string,
chatId: number,
msgId: number,
limit: number
) {
const data = await this.queryMsgs(
chatType,
chatId,
msgId,
0,
limit,
false
)
this.setMessagesRead(chatId, data)
return data
}
public on(event: "msg", chatId: number, listener: MsgListener): this
public on(event: "msg", listener: MsgListener): this
public on(
event: "chat_notify",
kind: "chat_change",
listener: ChatNotifyListener
): this
public on(
event: "chat_notify",
kind: string,
listener: ChatNotifyListener
): this
public on(event: "chat_notify", listener: ChatNotifyListener): this
public on(event: "status", listener: StatusChangeListener): this
public on(...args: any[]): this {
this.eventBus.$on(...this.parseEventListener(...args))
return this
}
public off(event: "msg", chatId: number, listener: MsgListener): this
public off(event: "msg", listener: MsgListener): this
public off(
event: "chat_notify",
kind: "chat_change",
listener: ChatNotifyListener
): this
public off(
event: "chat_notify",
kind: string,
listener: ChatNotifyListener
): this
public off(event: "chat_notify", listener: ChatNotifyListener): this
public off(event: "status", listener: StatusChangeListener): this
public off(...args: any[]): this {
this.eventBus.$off(...this.parseEventListener(...args))
return this
}
public once(...args: any[]): this {
this.eventBus.$once(...this.parseEventListener(...args))
return this
}
public emit(event: string, ...args: any[]): this {
this.eventBus.$emit(event, ...args)
return this
}
/**
* 移除会话(用户端/移动端使用)
*/
public async closeChat(chatId: number) {
return this.client?.setChat(chatId, { is_remove: true })
}
public isConnected() {
return this.client?.connected
}
public setRead(chatId: number, start_msg_id: number, end_msg_id: number) {
return this.client?.syncReadMsg(chatId, start_msg_id, end_msg_id)
}
private parseEventListener(...args: any[]): [string, Function] {
if (args.length < 2) {
throw new Error("参数个数不正确")
}
const listener: Function = args[args.length - 1]
return [args.slice(0, -1).join("."), listener]
}
private onConnected() {
// 连接成功后,需要调用pubUserInfo, 否则服务端会认为此连接无效
this.client!.pubUserInfo("")
this.debug("xim connected")
}
/*
DISCONNECTED: "DISCONNECTED",
CONNECTING: "CONNECTING",
CONNECTED: "CONNECTED",
CLOSED: "CLOSED",
*/
private onStatusChange(status: any, details: any) {
this.debug("onstatuschange", status, details)
this.emit(Events.Status, status, details)
if (status === "DISCONNECTED" || status === "CLOSED") {
this.hanldeOffline()
}
}
private hanldeOffline() {
this.debug("开始重连")
this.reOpen()
}
private reOpen() {
if (this.connectionPending) return
if (this.paramsForReconnection == null) return
this.open(
this.paramsForReconnection.url,
this.paramsForReconnection.token
)
}
private handleMsg(kind: any, msg: any) {
this.debug(`收到消息 ${new Date().getTime()}`, kind, msg)
switch (kind) {
case "chat":
this.emit(`msg`, msg)
this.emit(`msg.${msg.chat_id}`, msg)
break
case "chat_notify":
this.emit(`chat_notify`, msg)
this.emit(`chat_notify.${msg.msg_type}`, msg)
break
default:
this.emit(kind, msg)
}
}
private checkConnected() {
if (!this.client!.connected) {
try {
this.client?.open()
} catch (e) {
console.error("checkConnected", e)
this.reOpen()
}
}
}
private debug(message: any, ...params: any[]) {
ChatLoggerService.logger?.debug(message, params)
}
}
const ximInstance = new Xim()
export default ximInstance
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment