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>
<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>
<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>
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>
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 ""
}
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