Commit 723eb400 by 杨铁龙

合并修改

parents 01ea14ef ccd1ce05
.h-100 {
height: 100%;
}
// 此部分是参考常见栅格样式,提供常用分割布局的全局class
.col-1,
.col-2,
.col-3,
.col-4,
.col-5,
.col-6,
.col-7,
.col-8,
.col-9,
.col-10,
.col-11,
.col-12,
.col,
.col-auto,
.col-sm-1,
.col-sm-2,
.col-sm-3,
.col-sm-4,
.col-sm-5,
.col-sm-6,
.col-sm-7,
.col-sm-8,
.col-sm-9,
.col-sm-10,
.col-sm-11,
.col-sm-12,
.col-sm,
.col-sm-auto,
.col-md-1,
.col-md-2,
.col-md-3,
.col-md-4,
.col-md-5,
.col-md-6,
.col-md-7,
.col-md-8,
.col-md-9,
.col-md-10,
.col-md-11,
.col-md-12,
.col-md,
.col-md-auto,
.col-lg-1,
.col-lg-2,
.col-lg-3,
.col-lg-4,
.col-lg-5,
.col-lg-6,
.col-lg-7,
.col-lg-8,
.col-lg-9,
.col-lg-10,
.col-lg-11,
.col-lg-12,
.col-lg,
.col-lg-auto,
.col-xl-1,
.col-xl-2,
.col-xl-3,
.col-xl-4,
.col-xl-5,
.col-xl-6,
.col-xl-7,
.col-xl-8,
.col-xl-9,
.col-xl-10,
.col-xl-11,
.col-xl-12,
.col-xl,
.col-xl-auto {
position: relative;
width: 100%;
padding-right: 15px;
padding-left: 15px;
}
.col {
-ms-flex-preferred-size: 0;
flex-basis: 0;
-ms-flex-positive: 1;
flex-grow: 1;
max-width: 100%;
}
.row-cols-1 > * {
-ms-flex: 0 0 100%;
flex: 0 0 100%;
max-width: 100%;
}
.row-cols-2 > * {
-ms-flex: 0 0 50%;
flex: 0 0 50%;
max-width: 50%;
}
.row-cols-3 > * {
-ms-flex: 0 0 33.333333%;
flex: 0 0 33.333333%;
max-width: 33.333333%;
}
.row-cols-4 > * {
-ms-flex: 0 0 25%;
flex: 0 0 25%;
max-width: 25%;
}
.row-cols-5 > * {
-ms-flex: 0 0 20%;
flex: 0 0 20%;
max-width: 20%;
}
.row-cols-6 > * {
-ms-flex: 0 0 16.666667%;
flex: 0 0 16.666667%;
max-width: 16.666667%;
}
.col-auto {
-ms-flex: 0 0 auto;
flex: 0 0 auto;
width: auto;
max-width: 100%;
}
.col-1 {
-ms-flex: 0 0 8.333333%;
flex: 0 0 8.333333%;
max-width: 8.333333%;
}
.col-2 {
-ms-flex: 0 0 16.666667%;
flex: 0 0 16.666667%;
max-width: 16.666667%;
}
.col-3 {
-ms-flex: 0 0 25%;
flex: 0 0 25%;
max-width: 25%;
}
.col-4 {
-ms-flex: 0 0 33.333333%;
flex: 0 0 33.333333%;
max-width: 33.333333%;
}
.col-5 {
-ms-flex: 0 0 41.666667%;
flex: 0 0 41.666667%;
max-width: 41.666667%;
}
.col-6 {
-ms-flex: 0 0 50%;
flex: 0 0 50%;
max-width: 50%;
}
.col-7 {
-ms-flex: 0 0 58.333333%;
flex: 0 0 58.333333%;
max-width: 58.333333%;
}
.col-8 {
-ms-flex: 0 0 66.666667%;
flex: 0 0 66.666667%;
max-width: 66.666667%;
}
.col-9 {
-ms-flex: 0 0 75%;
flex: 0 0 75%;
max-width: 75%;
}
.col-10 {
-ms-flex: 0 0 83.333333%;
flex: 0 0 83.333333%;
max-width: 83.333333%;
}
.col-11 {
-ms-flex: 0 0 91.666667%;
flex: 0 0 91.666667%;
max-width: 91.666667%;
}
.col-12 {
-ms-flex: 0 0 100%;
flex: 0 0 100%;
max-width: 100%;
}
.d-flex {
display: -ms-flexbox;
display: flex;
}
.d-inline-flex {
display: -ms-inline-flexbox;
display: inline-flex;
}
.flex-row {
-ms-flex-direction: row;
flex-direction: row;
}
.flex-column {
-ms-flex-direction: column;
flex-direction: column;
}
.flex-row-reverse {
-ms-flex-direction: row-reverse;
flex-direction: row-reverse;
}
.flex-column-reverse {
-ms-flex-direction: column-reverse;
flex-direction: column-reverse;
}
.flex-wrap {
-ms-flex-wrap: wrap;
flex-wrap: wrap;
}
.flex-nowrap {
-ms-flex-wrap: nowrap;
flex-wrap: nowrap;
}
.flex-wrap-reverse {
-ms-flex-wrap: wrap-reverse;
flex-wrap: wrap-reverse;
}
.flex-fill {
-ms-flex: 1 1 auto;
flex: 1 1 auto;
}
.flex-none {
flex: none;
}
.flex-grow-0 {
-ms-flex-positive: 0;
flex-grow: 0;
}
.flex-grow-1 {
-ms-flex-positive: 1;
flex-grow: 1;
}
.flex-shrink-0 {
-ms-flex-negative: 0;
flex-shrink: 0;
}
.flex-shrink-1 {
-ms-flex-negative: 1;
flex-shrink: 1;
}
.justify-content-start {
-ms-flex-pack: start;
justify-content: flex-start;
}
.justify-content-end {
-ms-flex-pack: end;
justify-content: flex-end;
}
.justify-content-center {
-ms-flex-pack: center;
justify-content: center;
}
.justify-content-between {
-ms-flex-pack: justify;
justify-content: space-between;
}
.justify-content-around {
-ms-flex-pack: distribute;
justify-content: space-around;
}
.align-items-start {
-ms-flex-align: start;
align-items: flex-start;
}
.align-items-end {
-ms-flex-align: end;
align-items: flex-end;
}
.align-items-center {
-ms-flex-align: center;
align-items: center;
}
.align-items-baseline {
-ms-flex-align: baseline;
align-items: baseline;
}
.align-items-stretch {
-ms-flex-align: stretch;
align-items: stretch;
}
.align-content-start {
-ms-flex-line-pack: start;
align-content: flex-start;
}
.align-content-end {
-ms-flex-line-pack: end;
align-content: flex-end;
}
.align-content-center {
-ms-flex-line-pack: center;
align-content: center;
}
.align-content-between {
-ms-flex-line-pack: justify;
align-content: space-between;
}
.align-content-around {
-ms-flex-line-pack: distribute;
align-content: space-around;
}
.align-content-stretch {
-ms-flex-line-pack: stretch;
align-content: stretch;
}
.align-self-auto {
-ms-flex-item-align: auto;
align-self: auto;
}
.align-self-start {
-ms-flex-item-align: start;
align-self: flex-start;
}
.align-self-end {
-ms-flex-item-align: end;
align-self: flex-end;
}
.align-self-center {
-ms-flex-item-align: center;
align-self: center;
}
.align-self-baseline {
-ms-flex-item-align: baseline;
align-self: baseline;
}
.align-self-stretch {
-ms-flex-item-align: stretch;
align-self: stretch;
}
.no-bottom-scrollbar {
> .el-scrollbar__wrap {
overflow-x: hidden;
......
<template>
<div class="chat-con" :class="{ userMode, isSingle: isSingleChat }" v-if="chatId != null">
<ChatTitle :close="hide" class="chat-title" @updateActive="$emit('updateActive', $event)" />
<div class="chat-area-con" :class="{isSingle: isSingleChat, needSearch: !modelName}">
<div v-if="chatId != null" class="h-100 chat-area">
<chat-room />
</div>
<div class="chat-panel pos-rel" v-if="!userMode">
<el-tabs class="chat-panel-tabs h-100" v-model="currentTab" v-if="currentChat && !refreshFlag">
<el-tab-pane label="数据" name="one" class="h-100">
<ModelDetail
:model_name="currentChat.business_data.model_name"
:id="currentChat.business_data.obj_id"
:name="currentChat.business_data.detail_name"
/>
</el-tab-pane>
<el-tab-pane
class="h-100"
:label="`成员${chatMembers.length}人`"
name="two"
>
<ChatMembers />
</el-tab-pane>
<el-tab-pane class="h-100" label="工作流" name="three">
<workflow
:model_name="currentChat.business_data.model_name"
:id="currentChat.business_data.obj_id"
:name="currentChat.business_data.detail_name"
/>
</el-tab-pane>
<el-tab-pane class="h-100" label="备注" name="four">
<remarkList
:isInSmallPage="true"
:modelName="currentChat.business_data.model_name"
:associateId="currentChat.business_data.obj_id"
/>
</el-tab-pane>
<el-tab-pane label="回复" name="five" class="h-100">
<MsgShortCut />
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch, Prop } from "vue-property-decorator";
import remarkList from "../components/common/remarkList.vue";
import ChatMembers from "./components/chat-members.vue";
import ChatRoom from "./components/chat-room.vue";
import ChatTitle from "./components/chat-title.vue";
import MessageList from "./components/message-list.vue";
import ModelDetail from "./components/model-detail.vue";
import MsgShortCut from "./components/msg-shortcut.vue";
import workflow from "./components/workflow.vue";
import buttonThrottle from "./utils/button-throttle";
import { ChatStore, chatStore } from "@/customer-service/store/model";
@Component({
components: {
MsgShortCut,
MessageList,
ChatRoom,
ChatMembers,
remarkList,
ChatTitle,
ModelDetail,
workflow,
},
})
export default class Chat extends Vue {
@chatStore.Getter(ChatStore.GETTER_CURRENT_CHAT_PRESENT_MEMBERS)
private readonly chatMembers!: ChatStore.GETTER_CURRENT_CHAT_PRESENT_MEMBERS;
@chatStore.Getter(ChatStore.GETTER_CURRENT_CURRENT_CHAT)
private readonly currentChat!: ChatStore.GETTER_CURRENT_CURRENT_CHAT;
@chatStore.State(ChatStore.STATE_CHAT_CURRENT_CHAT_ID)
private readonly chatId!: ChatStore.STATE_CHAT_CURRENT_CHAT_ID;
@chatStore.State(ChatStore.STATE_CHAT_DIALOG_IS_SINGLE)
private readonly isSingleChat: ChatStore.STATE_CHAT_DIALOG_IS_SINGLE;
@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;
@chatStore.Action(ChatStore.ACTION_CHAT_ADD_MEMBERS)
private readonly _addMember!: ChatStore.ACTION_CHAT_ADD_MEMBERS;
private userMode = false;
private refreshFlag = false;
private currentTab = "one";
private get chatMembersId() {
return this.chatMembers.map((k) => +k.eid);
}
@buttonThrottle()
private terminate() {
this._terminate();
}
@Watch("chatId") chatIdUpdate() {
this.refreshFlag = true;
this.$nextTick(() => {
this.refreshFlag = false;
})
}
@Prop(String) modelName: string;
}
</script>
<style lang="less" scoped>
.chat-area-con {
height: 100%;
&.isSingle {
height: 70vh;
}
&.needSearch {
height: calc(100% - 60px);
}
}
.chat-con {
height: 100%;
--chat-panel-width: 350px;
font-size: 13px;
color: black;
&.isSingle {
height: 70vh;
}
&.userMode {
--chat-panel-width: 0px;
.chat-title {
/deep/ .title-buttons {
.button {
display: none;
}
}
}
}
}
.chat-area,
.chat-panel {
display: inline-block;
vertical-align: top;
}
.chat-area {
width: calc(100% - var(--chat-panel-width) - 2px);
}
.chat-panel {
position: relative;
width: var(--chat-panel-width);
border-left: 1px solid #e1e1e1;
height: calc(100% - 2px);
}
.buttons {
position: absolute;
left: 0;
right: 0;
bottom: 20px;
text-align: center;
}
.chat-panel {
/deep/ .el-tabs__item {
padding: 0 18px;
}
}
.chat-panel-tabs {
/deep/ .el-tabs__content {
height: calc(100% - 54px);
}
/deep/ .el-tabs__nav-wrap {
padding-left: 15px;
}
}
</style>
<template>
<el-avatar class="tm-avatar" :src="hasAvatar ? src : ''" :size="size" :shape="shape" :alt="alt" :fit="fit">
<img class="tm-load-img" v-if="src" :src="src" @load="hasAvatar = true" />
<el-avatar
class="tm-avatar"
:src="hasAvatar ? src : ''"
:size="size"
:shape="shape"
:alt="alt"
:fit="fit"
>
<img
class="tm-load-img"
v-if="src"
:src="src"
@load="hasAvatar = true"
/>
<slot>
<img src="../imgs/default-avatar.png" :style="`width:${size}px`" />
</slot>
......@@ -8,60 +20,62 @@
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { Component, Prop, Vue } from "vue-property-decorator";
@Component
export default class Avatar extends Vue {
@Prop({
type: String,
})
private readonly src?: string;
@Component
export default class Avatar extends Vue {
@Prop()
private readonly src?: string;
@Prop({
type: [String, Number],
default: 30,
validator: value => ["large", "medium", "small"].indexOf(value) >= 0 || typeof value === "number",
})
private readonly size!: number | "large" | "medium" | "small";
@Prop({
type: [String, Number],
default: 30,
validator: (value) =>
["large", "medium", "small"].indexOf(value) >= 0 ||
typeof value === "number",
})
private readonly size!: number | "large" | "medium" | "small";
@Prop({
type: String,
default: "square",
validator: value => ["circle", "square"].indexOf(value) >= 0,
})
private readonly shape!: "circle" | "square";
@Prop({
type: String,
default: "square",
validator: (value) => ["circle", "square"].indexOf(value) >= 0,
})
private readonly shape!: "circle" | "square";
@Prop(String)
private readonly alt?: string;
@Prop(String)
private readonly alt?: string;
@Prop({
type: String,
default: "cover",
validator: value => ["fill", "contain", "cover", "none", "scale-down"].indexOf(value) >= 0,
})
private readonly fit!: "fill" | "contain" | "cover" | "none" | "scale-down";
@Prop({
type: String,
default: "cover",
validator: (value) =>
["fill", "contain", "cover", "none", "scale-down"].indexOf(value) >=
0,
})
private readonly fit!: "fill" | "contain" | "cover" | "none" | "scale-down";
private hasAvatar = false;
}
private hasAvatar = false;
}
</script>
<style lang="less" scoped>
.tm-avatar {
background: none;
flex-shrink: 0;
flex-grow: 0;
}
.tm-avatar .tm-svg-icon {
background: #d8d8d8;
}
.tm-avatar .tm-load-img {
display: none;
}
.tm-avatar /deep/img {
width: 100%;
}
.default-avatar {
display: inline-block;
color: #333;
}
.tm-avatar {
background: none;
flex-shrink: 0;
flex-grow: 0;
}
.tm-avatar .tm-svg-icon {
background: #d8d8d8;
}
.tm-avatar .tm-load-img {
display: none;
}
.tm-avatar /deep/img {
width: 100%;
}
.default-avatar {
display: inline-block;
color: #333;
}
</style>
<template>
<div class="chat-container" :class="{ 'is-in-page': isInPage }">
<div class="search-wrap" v-if="!modelName">
<el-input
class="keyword-input"
placeholder="会话标题"
prefix-icon="el-icon-search"
v-model="searchKeyword"
v-on:keyup.enter.native="search"
clearable
@clear="search"
></el-input>
<i
v-if="!isInPage"
class="close-btn el-icon-close"
@click="$emit('close')"
></i>
</div>
<chat-list
v-if="!modelName"
ref="chatListComp"
@list-count-update="$emit('list-count-update', $event)"
/>
<chat-list-model
v-if="modelName"
@list-count-update="$emit('list-count-update', $event)"
ref="chatListModel"
:modelName="modelName"
:listName="listName"
/>
<div class="chat-content-wrap" v-if="chatVisible && onShow">
<chat
:modelName="modelName"
@updateActive="$emit('updateActive', $event)"
/>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Ref, Vue, Watch } from "vue-property-decorator";
import ChatList from "@/customer-service/components/chat-list.vue";
import ChatListModel from "@/customer-service/components/chat-list-model.vue";
import chat from "@/customer-service/chat.vue";
import { ChatStore, chatStore } from "@/customer-service/store/model";
@Component({
name: "ChatContainer",
components: {
chat,
ChatList,
ChatListModel,
},
})
export default class ChatContainer extends Vue {
@Prop(Boolean) isInPage: boolean;
@Prop(String) modelName: string;
@Prop(String) listName: string;
@Prop(String) activeName: string;
@Prop(Boolean) isActive: boolean;
private onShow = false;
@chatStore.State(ChatStore.STATE_CHAT_DIALOG_VISIBLE)
private readonly chatVisible!: ChatStore.STATE_CHAT_DIALOG_VISIBLE;
@chatStore.Mutation(ChatStore.MUTATION_HIDE_CHAT)
private readonly hideChat: ChatStore.MUTATION_HIDE_CHAT;
@Ref("chatListComp") chatListComp: ChatList;
private searchKeyword = "";
@Ref("chatListModel") chatListModel: ChatListModel;
private search() {
this.chatListComp.search(this.searchKeyword);
}
@Watch("isActive", { immediate: true }) isActiveUpdate() {
this.onShow = this.isActive;
if (
(!this.onShow && this.activeName !== "my_receiving") ||
(this.onShow && this.listName)
) {
this.chatListModel && this.chatListModel.clearActiveId();
}
}
private onChatDrawerClose() {
this.hideChat();
}
}
</script>
<style lang="less">
.chat-container {
height: 70vh;
&.is-in-page {
height: 100%;
}
}
.keyword-input {
width: 300px;
margin: 15px 0 14px 20px;
/deep/ .el-input__inner {
font-size: 13px;
height: 30px;
line-height: 28px;
border-radius: 15px;
padding-right: 15px;
}
/deep/ .el-icon-time {
background: transparent;
}
/deep/ .el-input__icon {
line-height: 32px;
}
}
.search-wrap {
height: 59px;
border-bottom: 1px solid #ddd;
}
.close-btn {
float: right;
font-size: 20px;
color: #aaa;
padding: 5px;
cursor: pointer;
margin: 15px 10px 0;
}
.chat-content-wrap {
display: inline-block;
width: 75%;
height: calc(100% - 60px);
box-sizing: border-box;
vertical-align: top;
}
</style>
<template>
<div class="chat-list-con">
<div class="chat-list h-100">
<slot />
<div class="chat-list-scroll">
<el-scrollbar ref="scrollbar" class="h-100 no-bottom-scrollbar">
<div
......@@ -53,11 +54,14 @@
<script lang="ts">
import { Component, Prop, Ref, Vue } from "vue-property-decorator";
import Controller from "./controller/chat-list";
import { EVENTS } from "@/EventConsts";
import avatar from "@/customer-service/components/avatar.vue";
import { ChatStore } from "@/customer-service/store/model";
import { Chat as ChatType } from "@/customer-service/xim/models/chat";
import { EVENTS } from "@/EventConsts";
import Controller from "./controller/chat-list";
import { ServiceType } from "../model";
import xim from "@/customer-service/xim";
@Component({ components: { avatar } })
export default class ChatList extends Controller {
......@@ -72,18 +76,20 @@ export default class ChatList extends Controller {
private unReadMsgCount = 0;
private get chatRooms() {
const list =
this.chatList?.list.filter(
(chat) => chat.title.indexOf(this.searchKeyword) > -1
) || [];
let unReadMsgCount = 0;
list.filter((chat) => chat.unread_msg_count > 0).forEach((chat) => {
unReadMsgCount += chat.unread_msg_count;
});
this.unReadMsgCount = unReadMsgCount;
this.$emit("list-count-update", this.unReadMsgCount);
this.$eventHub.$emit(EVENTS.NewMsg, this.unReadMsgCount);
return list;
if (this.chatList) {
const list = this.chatList.list
.filter((chat) => chat.title.indexOf(this.searchKeyword) > -1)
.sort((x, y) => y.last_msg_ts - x.last_msg_ts);
let unReadMsgCount = 0;
list.filter((chat) => chat.unread_msg_count > 0).forEach((chat) => {
unReadMsgCount += chat.unread_msg_count;
});
this.unReadMsgCount = unReadMsgCount;
this.$emit("list-count-update", this.unReadMsgCount);
xim.$emit(EVENTS.NewMsg, this.unReadMsgCount);
return list;
}
return [];
}
private isSelected(item: ChatType) {
......@@ -95,7 +101,7 @@ export default class ChatList extends Controller {
async created() {
await this.getMyChatList();
this.setSource(ChatStore.StateChatSourceDirection.Server);
this.setSource(ServiceType.Backend);
this.scrollbar.update();
}
......@@ -144,7 +150,7 @@ export default class ChatList extends Controller {
display: inline-block;
width: 25%;
box-sizing: border-box;
height: calc(100% - 59px);
height: 100%;
border-right: 1px solid #ddd;
.title {
padding-left: 20px;
......@@ -164,8 +170,8 @@ export default class ChatList extends Controller {
}
}
.keyword-input {
width: 200px;
margin: 15px 0;
width: 90%;
margin: 15px;
/deep/ .el-input__inner {
font-size: 13px;
height: 30px;
......@@ -176,6 +182,9 @@ export default class ChatList extends Controller {
/deep/ .el-icon-time {
background: transparent;
}
/deep/ .el-input__icon {
line-height: 32px;
}
}
.chat-list {
.chat-item {
......
......@@ -5,13 +5,13 @@
v-for="item in chatMembers"
:key="item.id"
>
<span class="member-name ver-mid">
<span class="member-name ver-mid text-truncate">
{{ item.name || item.eid }}
</span>
<span class="member-phone ver-mid">
<span class="member-phone ver-mid text-nowrap">
{{ item.phone }}
</span>
<span class="member-type ver-mid">
<span class="member-type ver-mid text-nowrap">
{{ memberTypeStr(item.type) }}
</span>
<el-button class="ver-mid get-out" type="text" @click="getout(item)"
......@@ -22,10 +22,10 @@
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { ChatStore, chatStore } from "../store/model";
import avatar from "@/customer-service/components/avatar.vue";
import { ChatRole } from "../model";
@Component({ components: { avatar } })
export default class ChatMembers extends Vue {
@chatStore.Getter(ChatStore.GETTER_CURRENT_CHAT_PRESENT_MEMBERS)
......@@ -37,33 +37,38 @@ export default class ChatMembers extends Vue {
@chatStore.Action(ChatStore.ACTION_CHAT_REMOVE_CS)
private readonly _getoutCs!: ChatStore.ACTION_CHAT_REMOVE_CS;
private async getout(item: ChatStore.GETTER_CURRENT_CHAT_PRESENT_MEMBERS[number]) {
private async getout(
item: ChatStore.GETTER_CURRENT_CHAT_PRESENT_MEMBERS[number]
) {
await this.$confirm(`确定要移除${item.name}?`, "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
// xim里的eid等于uniplat里的uid
if (item.type == 25) { // 普通成员
this._getout([item.eid])
} else if (item.type == 92) { // 可否
this._getoutCs([item.eid])
if (item.type === ChatRole.Default) {
// 普通成员
this._getout([item.eid]);
} else if (item.type === ChatRole.CustomerService) {
// 可否
this._getoutCs([item.eid]);
}
}
private memberTypeStr(type: string | number) {
if (type.toString() === "25") {
return ""
} else if (type.toString() === "92") {
return "客服"
} else if (type.toString() === "85") {
return "管理员"
if (+type === ChatRole.CustomerService) {
return "客服";
}
if (+type === ChatRole.Admin) {
return "管理员";
}
return "";
}
}
</script>
<style lang="less" scoped>
.chat-members {
padding: 30px;
padding: 20px;
padding-bottom: 0;
background: #fff;
.chat-member {
......@@ -71,7 +76,7 @@ export default class ChatMembers extends Vue {
vertical-align: top;
align-items: center;
margin: 10px 0;
padding: 10px 0;
padding: 10px;
&:hover {
background-color: #f5f7fa;
.get-out {
......@@ -81,9 +86,7 @@ export default class ChatMembers extends Vue {
}
.member-name {
display: inline-block;
width: 5em;
word-break: break-word;
white-space: pre-line;
min-width: 7em;
margin-right: 10px;
}
.member-type {
......
......@@ -4,7 +4,7 @@
<div class="chat-area h-100 d-flex flex-column" ref="chatBox">
<div
ref="top"
class="chat-messages pos-rel flex-fill"
class="chat-messages pos-rel flex-fill d-flex"
:class="{ 'is-not-chat-member': !isChatMember }"
>
<div
......@@ -13,7 +13,8 @@
>
{{ getCurrentInputingPeople }}正在输入
</div>
<messages />
<messages class="flex-fill" />
<slot name="chat-right-panel"></slot>
</div>
<div
class="resize"
......@@ -24,7 +25,7 @@
></div>
<div
ref="bottom"
class="chat-input flex-none overflow-hidden"
class="chat-input flex-none h-100"
v-if="isChatMember"
>
<message-input @error="onError" />
......@@ -37,10 +38,10 @@
import {
Component,
Prop,
Provide,
Ref,
Watch,
Vue,
Provide,
Watch,
} from "vue-property-decorator";
import MessageInput from "@/customer-service/components/message-input.vue";
......@@ -56,10 +57,10 @@ type RoomInfoTab = "customer" | "order";
},
})
export default class ChatRoom extends Vue {
@Ref("chatBox") chatBox: Element;
@Ref("top") refTop: Element;
@Ref("bottom") refBottom: Element;
@Ref("resize") refResize: Element;
@Ref("chatBox") chatBox!: Element;
@Ref("top") refTop!: Element;
@Ref("bottom") refBottom!: Element;
@Ref("resize") refResize!: Element;
@chatStore.State(ChatStore.STATE_CHAT_CURRENT_CHAT_ID)
private readonly chatId!: ChatStore.STATE_CHAT_CURRENT_CHAT_ID;
......@@ -156,8 +157,9 @@ export default class ChatRoom extends Vue {
}
mounted() {
(this.refBottom as HTMLElement).style.height =
this.chatBox.clientHeight - this.refTop.clientHeight + "px";
this.refBottom &&
((this.refBottom as HTMLElement).style.height =
this.chatBox.clientHeight - this.refTop.clientHeight + "px");
}
}
</script>
......@@ -218,7 +220,7 @@ export default class ChatRoom extends Vue {
height: calc(100% - 130px + 1px);
border-bottom: 1px solid #e1e1e1;
&.is-not-chat-member {
height: 100%;
height: calc(100% - 50px);
border-bottom: none;
}
}
......
<template>
<div class="room-title d-flex justify-content-between align-items-center">
<div class="title">
<div class="title text-nowrap">
{{ chatTitle }}
<template v-if="chatMembers.length">
<span class="members-count"
......@@ -18,26 +18,29 @@
<el-button
class="button"
@click="startReception"
round
size="small"
v-if="!isChatMember"
type="primary"
>我要接待</el-button
>
<el-button
class="button"
@click="exitChat"
round
v-if="isChatMember"
>退出会话</el-button
<el-button class="button" @click="showAddMember" size="small"
>添加客服</el-button
>
<el-button
class="button"
@click="finishReception"
round
size="small"
v-if="isChatMember && operatorType > 25"
type="warning"
>结束接待</el-button
>
<el-button class="button" @click="showAddMember" round
>添加客服</el-button
<el-button
class="button"
@click="exitChat"
size="small"
v-if="isChatMember"
type="danger"
>退出会话</el-button
>
<i
v-if="close && isSingleChat"
......@@ -55,11 +58,20 @@
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import ChatCreator from "@/customer-service/components/create-chat.vue";
import { ChatStore, chatStore } from "@/customer-service/store/model";
import { ChatRole } from "../model";
import {
ChatChangedEvent,
ChatEventHandler,
} from "./controller/chat-event-handler";
@Component({ components: { ChatCreator } })
export default class ChatTitle extends Vue {
@chatStore.State(ChatStore.STATE_CHAT_CURRENT_CHAT_ID)
private readonly chatId!: ChatStore.STATE_CHAT_CURRENT_CHAT_ID;
@chatStore.Getter(ChatStore.GETTER_CURRENT_CHAT_PRESENT_MEMBERS)
private readonly chatMembers!: ChatStore.GETTER_CURRENT_CHAT_PRESENT_MEMBERS;
......@@ -128,22 +140,35 @@ export default class ChatTitle extends Vue {
}
}
private noop() {
return 1;
}
private async exitChat() {
try {
if (this.operatorType == "25") {
await this._userExitChat();
} else if (+this.operatorType > 25) {
await this._csExitChat();
}
this.hideChat();
} catch (error) {
console.error(error);
}
this.$confirm("确认要退出此会话?")
.then(async () => {
try {
if (+this.operatorType === ChatRole.Default) {
await this._userExitChat();
} else if (+this.operatorType > ChatRole.Default) {
await this._csExitChat();
}
this.hideChat();
} catch (error) {
console.error(error);
}
})
.catch(this.noop);
}
private async startReception() {
try {
await this._startReception();
await this._startReception().then(() =>
ChatEventHandler.raiseChatChanged(
ChatChangedEvent.Start,
this.chatId
)
);
this.$emit("updateActive", "my_receiving");
} catch (error) {
console.error(error);
......@@ -160,7 +185,9 @@ export default class ChatTitle extends Vue {
type: "warning",
}
);
await this._finishReception();
await this._finishReception().then(() =>
ChatEventHandler.raiseChatChanged(ChatChangedEvent.End, this.chatId)
);
this.hideChat();
}
}
......@@ -170,6 +197,7 @@ export default class ChatTitle extends Vue {
font-size: 16px;
padding: 0 20px;
height: 60px;
min-height: 60px;
border-bottom: 1px solid #e1e1e1;
.title {
cursor: pointer;
......
export const enum ChatChangedEvent {
Start = 1,
End,
}
export class ChatEventHandler {
private static actions: {
key: string;
action: (e: ChatChangedEvent, chat: number) => void;
}[] = [];
public static registerOnChatChanged(
vue: Vue,
action: (e: ChatChangedEvent, chat: number) => void
) {
const key = `${new Date().valueOf()}-${Math.random()}`;
this.actions.push({ key, action });
vue.$once("hook:beforeDestroy", () => {
const t = this.actions.find((i) => i.key === key);
if (t) {
this.actions = this.actions.filter((i) => i !== t);
}
});
}
public static raiseChatChanged(e: ChatChangedEvent, chat: number) {
for (const item of this.actions) {
item.action(e, chat);
}
}
}
import { Component, Vue } from "vue-property-decorator";
import { chatStore, ChatStore } from "@/customer-service/store/model";
import { parserMessage } from ".";
import { Chat as ChatItem } from "@/customer-service/xim/models/chat";
import { chatStore, ChatStore } from "@/customer-service/store/model";
import { formatTime, TimeFormatRule } from "@/customer-service/utils/time";
import { Chat as ChatItem } from "@/customer-service/xim/models/chat";
@Component({ components: {} })
export default class ChatList extends Vue {
......@@ -34,25 +36,35 @@ export default class ChatList extends Vue {
protected readonly saveChatTitle!: ChatStore.MUTATION_SAVE_CHAT_TITLE;
@chatStore.Mutation(ChatStore.MUTATION_SHOW_CHAT)
protected readonly showChat: ChatStore.MUTATION_SHOW_CHAT;
protected readonly showChat!: ChatStore.MUTATION_SHOW_CHAT;
@chatStore.Mutation(ChatStore.MUTATION_HIDE_CHAT)
protected readonly hideChat: ChatStore.MUTATION_HIDE_CHAT;
protected readonly hideChat!: ChatStore.MUTATION_HIDE_CHAT;
@chatStore.Action(ChatStore.ACTION_UPDATE_CHAT)
protected readonly updateChat!: ChatStore.ACTION_UPDATE_CHAT;
@chatStore.Action(ChatStore.ACTION_REBUILD_UNREAD_MESSAGE_COUNT)
protected readonly updateUnreadMessageCount!: ChatStore.ACTION_REBUILD_UNREAD_MESSAGE_COUNT;
@chatStore.Action(ChatStore.ACTION_CLEAR_CURRENT_CHAT_DATA)
protected readonly reset!: ChatStore.ACTION_CLEAR_CURRENT_CHAT_DATA;
protected parseMesage(data: ChatItem) {
if (data.last_msg_sender && data.last_msg_sender != "0") {
if (this.userNames[data.last_msg_sender] === undefined) {
if (data.last_msg_sender && data.last_msg_sender !== "0") {
if (!this.userNames[data.last_msg_sender]) {
this.updateUserName({ id: data.last_msg_sender, name: "" });
this.sdk
.model("user")
.detail(data.last_msg_sender)
.query()
.then((userInfo) => {
.then((userInfo: any) => {
this.updateUserName({
id: data.last_msg_sender,
name: userInfo.row.first_name.display as string,
});
});
})
.catch(() => {});
}
}
if (data.last_msg_content === "") {
......
import { MessageType } from "@/customer-service/model";
export function parserMessage(type: string, rawMsg: string) {
if (!type) return "";
if (!rawMsg) return "";
const msg = JSON.parse(rawMsg);
if (type === "text") {
if (type === MessageType.Text) {
const msg = JSON.parse(rawMsg);
return msg.text;
} else if (type === "image") {
} else if (type === MessageType.Image) {
return `[图片]`;
} else if (type === "file") {
} else if (type === MessageType.File) {
return `[文件]`;
} else if (type === MessageType.Withdraw) {
return `[撤回了一条消息]`;
} else {
return `[系统自动回复]`;
}
......
......@@ -57,12 +57,13 @@
</el-dialog>
</template>
<script lang="ts">
import { ListEasy, ListTypes } from "uniplat-sdk";
import { ListEasy, ListTypes, TagManagerTypes } from "uniplat-sdk";
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import GeneralTagSelectForFilter from "@/components/statistic/GeneralTagSelectForFilter.vue"
import buttonThrottle from "../utils/button-throttle";
import GeneralTagSelectForFilter from "@/components/statistic/GeneralTagSelectForFilter.vue";
import avatar from "@/customer-service/components/avatar.vue";
import { TagManagerTypes } from "uniplat-sdk"
import chat from "@/customer-service/xim/index";
type User = {
id: string;
......@@ -103,9 +104,9 @@ export default class ChatCreator extends Vue {
if (this.$refs.generalTagSelect) {
return (
this.$refs.generalTagSelect as GeneralTagSelectForFilter
).getSelectedTags()
).getSelectedTags();
}
return []
return [];
}
private async getUserList(searchText: string | null = null) {
......@@ -127,7 +128,7 @@ export default class ChatCreator extends Vue {
this.getList = getList;
this.userList = this.exactUserList(pageData.rows);
this.loading = false;
this.tagGroups = pageData.tagGroups || []
this.tagGroups = pageData.tagGroups || [];
}
private exactUserList(rows: any[]) {
......
......@@ -13,7 +13,7 @@ import ChatInput, {
FILE_INFO_CLASS,
isImageOrFile,
} from "../hybrid-input/index.vue";
import { Message } from "../model";
import { Message, MessageType } from "../model";
import { uploadFile } from "../service/upload";
import { ChatLoggerService } from "../xim/logger";
import xim from "../xim/xim";
......@@ -67,15 +67,15 @@ export default class MessageInput extends Vue {
for (const item of msg) {
if (isImageOrFile(item)) {
if ((item as Element).classList.contains(FILE_INFO_CLASS)) {
this.sendFile(item, "file");
await this.sendFile(item, MessageType.File);
} else {
this.sendFile(item, "image");
await this.sendFile(item, MessageType.Image);
}
continue;
}
if (item.textContent) {
this.sendText(item.textContent);
await this.sendText(item.textContent);
}
}
ChatLoggerService.logger?.debug("all messages sent");
......@@ -88,17 +88,17 @@ export default class MessageInput extends Vue {
await xim.inputing(this.chatId);
}
private sendText(text: string) {
private async 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) });
return this.sendMsg({ msgType: MessageType.Text, msg: JSON.stringify(msg) });
}
}
private async sendFile(file: any, type: "image" | "file") {
private async sendFile(file: any, type: MessageType.Image | MessageType.File) {
const src = JSON.parse(
file.attributes[`data-${type}`]?.value || ""
) as {
......@@ -112,7 +112,7 @@ export default class MessageInput extends Vue {
if (file) {
let w = 0;
let h = 0;
if (type === "image") {
if (type === MessageType.Image) {
const img = new Image();
img.src = src.url;
img.onload = function () {
......
<template>
<div
class="msg-detail voice-message d-flex align-items-center"
:class="{ playing: playing, 'can-play': messageRealUrl }"
v-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>
</template>
<script lang="ts">
import { Component, Ref } from "vue-property-decorator";
import BaseMessage from "./index";
import VoiceIcon from "./voice.vue";
@Component({ components: { VoiceIcon } })
export default class Index extends BaseMessage {
@Ref("audio")
private readonly audioRef!: HTMLAudioElement;
private playing = false;
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 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;
}
mounted() {
this.buildMessageUrl();
}
}
</script>
<style lang="less" scoped>
.voice-message {
height: 40px;
width: 200px;
&.can-play {
cursor: pointer;
}
i {
font-size: 16px;
}
}
.my-message {
.voice-message {
> div {
flex-flow: row-reverse;
}
svg {
transform: rotateY(180deg);
}
}
}
</style>
<template>
<span class="file-icon" :title="value" v-html="html"></span>
<span class="file-icon" :title="value" v-html="html"></span>
</template>
<script lang="ts">
......@@ -9,70 +9,70 @@ import { FileType, getSvg } from "./file-controller";
@Component({ components: {} })
export default class FileIcon extends Vue {
@Model("update")
private value!: FileType;
@Model("update")
private value!: FileType;
private get audio() {
return this.value === FileType.Audio;
}
private get audio() {
return this.value === FileType.Audio;
}
private get excel() {
return this.value === FileType.Excel;
}
private get excel() {
return this.value === FileType.Excel;
}
private get image() {
return this.value === FileType.Image;
}
private get image() {
return this.value === FileType.Image;
}
private get others() {
return this.value === FileType.Others;
}
private get others() {
return this.value === FileType.Others;
}
private get pdf() {
return this.value === FileType.Pdf;
}
private get pdf() {
return this.value === FileType.Pdf;
}
private get ppt() {
return this.value === FileType.Ppt;
}
private get ppt() {
return this.value === FileType.Ppt;
}
private get rp() {
return this.value === FileType.Rp;
}
private get rp() {
return this.value === FileType.Rp;
}
private get txt() {
return this.value === FileType.Txt;
}
private get txt() {
return this.value === FileType.Txt;
}
private get video() {
return this.value === FileType.Video;
}
private get video() {
return this.value === FileType.Video;
}
private get word() {
return this.value === FileType.Word;
}
private get word() {
return this.value === FileType.Word;
}
private get xmid() {
return this.value === FileType.Xmind;
}
private get xmid() {
return this.value === FileType.Xmind;
}
private get zip() {
return this.value === FileType.Zip;
}
private get zip() {
return this.value === FileType.Zip;
}
private get html() {
return getSvg(this.value);
}
private get html() {
return getSvg(this.value);
}
}
</script>
<style lang="less" scoped>
.file-icon {
margin-left: 10px;
margin-left: 10px;
svg {
max-width: 36px;
max-height: 36px;
}
/deep/ svg {
max-width: 36px;
max-height: 36px;
}
}
</style>
<template>
<div class="msg-detail file-message d-flex" @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">
{{ format(messageBody.msg.size) }}
</div>
</div>
<file-icon :value="fileIcon"></file-icon>
<a
class="
d-flex
align-items-center
justify-content-center
download-icon
"
:href="messageRealUrl"
:download="getAttachment"
title="下载文件"
>
<img src="~@/customer-service/imgs/download.png" alt="Download" />
</a>
</div>
</template>
<script lang="ts">
import { Component } from "vue-property-decorator";
import BaseMessage from "./index";
import FileIcon from "./file-icon.vue";
import { FileType, getFileType } from "./file-controller";
const k = 1024,
m = 1024 * k,
g = 1024 * m,
t = 1024 * g;
function formatSize(size: number) {
if (size === undefined || size === null) {
return "";
}
if (size < k) {
return size + " B";
}
if (size < m) {
return Number((size / k).toFixed(2)) + " KB";
}
if (size < g) {
return Number((size / m).toFixed(2)) + " MB";
}
if (size < t) {
return Number((size / g).toFixed(2)) + " GB";
}
return Number((size / t).toFixed(2)) + " TB";
}
@Component({ components: { FileIcon } })
export default class Index extends BaseMessage {
private get getAttachment() {
if (this.messageBody) {
return this.messageBody.msg.name;
}
return "文件下载";
}
private get fileIcon() {
if (this.value) {
return getFileType(this.messageBody.msg.name);
}
return FileType.Others;
}
private format(v: number) {
return formatSize(v);
}
mounted() {
this.buildMessageUrl();
}
}
</script>
<style lang="less" scoped>
.file-message {
background-color: transparent !important;
border-radius: 4px !important;
border: 1px solid #c5d4e5;
.file-message-name {
max-width: 130px;
}
}
</style>
<template>
<div
class="msg-detail image-message"
:class="{ 'image-404': fileFailed2Load }"
@dblclick="openFile"
>
<img
v-if="messageRealUrl"
:src="messageRealUrl"
:title="messageBody.msg.name"
:alt="messageBody.msg.name"
@error="onImageError"
/>
<file-icon v-else-if="fileFailed2Load" :value="image404"></file-icon>
</div>
</template>
<script lang="ts">
import { Component } from "vue-property-decorator";
import { FileType } from "./file-controller";
import BaseMessage from "./index";
import FileIcon from "./file-icon.vue";
@Component({ components: { FileIcon } })
export default class Index extends BaseMessage {
private readonly image404 = FileType.Image_404;
private onImageError() {
this.fileFailed2Load = true;
this.messageRealUrl = "";
}
mounted() {
this.buildMessageUrl();
}
}
</script>
<style lang="less" scoped>
.image-message {
background-color: transparent !important;
border-radius: 4px !important;
border: 1px solid #c5d4e5;
line-height: 1;
max-width: 300px;
box-sizing: content-box;
img {
width: 100%;
}
&.image-404 {
background: #f7f8fa;
display: flex;
align-items: center;
justify-content: center;
.file-icon {
margin: 0;
}
}
/deep/ .file-icon {
margin-left: 0;
}
}
.my-message {
&.image-message:not(.image-404) {
background-color: transparent !important;
border-radius: 4px !important;
border: 1px solid #c5d4e5;
}
}
</style>
import { Component, Vue, Model, Prop } from "vue-property-decorator";
import { Message } from "@/customer-service/model";
import { isAccessibleUrl } from "@/customer-service/service/tools";
@Component({ components: {} })
export default class BaseMessage extends Vue {
@Model()
protected readonly value!: Message;
@Prop()
protected readonly userName!: string;
protected messageRealUrl = "";
protected fileFailed2Load = false;
protected loadingRealUrl = false;
protected get messageBody(): { eid?: string; oid?: string; msg: any } {
if (this.value) {
try {
return { ...this.value, msg: JSON.parse(this.value.msg) };
} catch {
return {
...this.value,
msg: JSON.parse(this.value.msg.replace(/\n/g, "\\n")),
};
}
}
return { msg: { text: "" } };
}
protected openFile() {
this.$emit("open", this.messageRealUrl);
}
protected buildMessageUrl() {
if (this.messageRealUrl || this.loadingRealUrl) {
return;
}
const url = this.messageBody.msg.url as string;
if (url) {
if (isAccessibleUrl(url)) {
return (this.messageRealUrl = url);
}
this.loadingRealUrl = true;
} else {
this.fileFailed2Load = true;
}
}
}
<template>
<div
class="msg-detail inline-text"
v-html="format2Link(messageBody.msg.text || emptyText)"
></div>
</template>
<script lang="ts">
import { replaceText2Link } from "@/customer-service/utils";
import xim from "@/customer-service/xim";
import { Component } from "vue-property-decorator";
import BaseMessage from "./index";
@Component({ components: {} })
export default class Index extends BaseMessage {
private readonly emptyText = " ";
private format2Link(text: string) {
let t = replaceText2Link(text);
const keywords = xim.getMatchedTextKeywords();
for (const item of keywords) {
const r = new RegExp(item, "g");
t = t.replace(r, `<span class="highlight">${item}</span>`);
}
return t;
}
}
</script>
<style lang="less" scoped>
.inline-text {
display: inline-block;
white-space: pre-wrap;
text-align: left;
/deep/ .highlight {
color: #e87005;
}
}
</style>
\ No newline at end of file
<template>
<div
class="
msg-detail
video-message
d-flex
align-items-center
justify-content-center
"
>
<video-player-icon @click.native="openFile"></video-player-icon>
</div>
</template>
<script lang="ts">
import { Component } from "vue-property-decorator";
import BaseMessage from "./index";
import VideoPlayerIcon from "./video-player-icon.vue";
@Component({ components: { VideoPlayerIcon } })
export default class Index extends BaseMessage {
mounted() {
this.buildMessageUrl();
}
}
</script>
<style lang="less" scoped>
.video-message {
height: 160px;
width: 200px;
background-color: #000 !important;
border-radius: 0 !important;
svg {
cursor: pointer;
}
}
</style>
......@@ -32,20 +32,18 @@
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator"
;
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
@Component({ components: {} })
export default class VoiceIcon extends Vue {
@Prop({ default: 25 })
private size!: number
private size!: number;
@Prop()
private loading!: boolean
private loading!: boolean;
private status = 0
private interval = 0
private status = 0;
private interval = 0;
@Watch("loading")
private onLoadingChanged() {
......
<template>
<div class="msg-detail withdraw-message">{{ userName }}撤回了一条消息</div>
</template>
<script lang="ts">
import { Component } from "vue-property-decorator";
import BaseMessage from "./index";
@Component({ components: {} })
export default class Index extends BaseMessage {}
</script>
<style lang="less" scoped>
.my-message {
.withdraw-message {
background-color: transparent !important;
padding: 0 4px;
font-size: 12px;
color: #999;
}
}
</style>
......@@ -2,12 +2,7 @@
<div v-loading="chatIniting" class="message-list h-100">
<el-scrollbar
ref="message-scrollbar"
class="
message-list-scrollbar
no-bottom-scrollbar
adjust-el-scroll-right-bar
h-100
"
class="message-list-scrollbar no-bottom-scrollbar h-100"
>
<template v-for="item in messages">
<div :key="item.id" class="message-template">
......@@ -28,6 +23,7 @@
:data="item"
:shape="shape"
@open="open"
@withdraw="refresh"
/>
</div>
</template>
......@@ -44,7 +40,7 @@
<script lang="ts">
import { Component, Prop, Ref, Vue, Watch } from "vue-property-decorator";
import { Message } from "../model";
import { Message, MessageType } from "../model";
import { throttle } from "../utils";
import { formatTime } from "../utils/time";
......@@ -53,6 +49,7 @@ import message from "./message.vue";
import VideoPreview from "./video-preview.vue";
import { ChatStore, chatStore } from "@/customer-service/store/model";
import { dbController } from "../database";
@Component({ components: { message, ImagePreview, VideoPreview } })
export default class MessageList extends Vue {
......@@ -83,6 +80,15 @@ export default class MessageList extends Vue {
@chatStore.Mutation(ChatStore.MUTATION_CLEAR_FUNC_SCROLL_TO_BOTTOM)
private readonly clearScrollToBottomFunc!: ChatStore.MUTATION_CLEAR_FUNC_SCROLL_TO_BOTTOM;
@chatStore.Mutation(ChatStore.MUTATION_SAVE_FUNC_ON_NEW_MSG)
private readonly onNewMessage!: ChatStore.MUTATION_SAVE_FUNC_ON_NEW_MSG;
@chatStore.Mutation(ChatStore.MUTATION_CLEAR_FUNC_ON_NEW_MSG)
private readonly clearNewMessage!: ChatStore.MUTATION_CLEAR_FUNC_ON_NEW_MSG;
@chatStore.Mutation(ChatStore.MUTATION_WITHDRAW)
private readonly executeWithDraw!: ChatStore.MUTATION_WITHDRAW;
@Prop({ default: "circle" })
private shape!: string;
......@@ -176,6 +182,14 @@ export default class MessageList extends Vue {
public created() {
this.handleScrollWrapper();
this.onNewMessage((e) => {
if (e.type === MessageType.Withdraw) {
this.executeWithDraw(e.ref_id);
dbController
.removeMessage(e.chat_id, e.ref_id)
.finally(() => this.refresh());
}
});
}
public mounted() {
......@@ -183,12 +197,15 @@ export default class MessageList extends Vue {
this.scollWrapper.addEventListener("scroll", this.handleScroll);
this.saveScrollToBottomFunc(this.scrollToNewMsg);
this.scrollToNewMsg();
setTimeout(() => this.scroll2End(200));
setTimeout(() => this.scroll2End(1000));
}
public beforeDestroy() {
this.scollWrapper &&
this.scollWrapper.removeEventListener("scroll", this.handleScroll);
this.clearScrollToBottomFunc();
this.clearNewMessage();
// this.clearChatId();
}
......@@ -199,12 +216,16 @@ export default class MessageList extends Vue {
) as HTMLElement;
if (wrap) {
if (delay) {
setTimeout(() => {
wrap.scrollTop = 100000;
}, delay);
return;
return setTimeout(
() =>
(wrap.scrollTop = Math.max(
wrap.scrollHeight + 100,
10000
)),
delay
);
}
wrap.scrollTop = 100000;
wrap.scrollTop = Math.max(wrap.scrollHeight + 100, 10000);
}
});
}
......@@ -353,12 +374,17 @@ export default class MessageList extends Vue {
}
return v;
}
private refresh() {
this.fetchNewMsg();
}
}
</script>
<style lang="less" scoped>
.message-list {
padding: 0 20px;
padding-right: 0;
}
.loading-mask {
......
<template>
<div class="h-100 pos-rel">
<div class="scroll-wrap">
<el-scrollbar class="h-100">
<div
class="data-row"
v-for="item in detailData"
:key="item.label"
>
<span class="data-key"
>{{ item.label }}{{ item.label ? ":" : "" }} </span
><span class="data-value" v-html="item.template"></span>
<span
class="operation_field"
v-if="item.actions && item.actions.length > 0"
>
<el-button
v-for="action in item.actions"
:key="action.name"
@click="execute_action(action)"
type="text"
size="small"
>{{ action.label }}</el-button
>
</span>
</div>
<div class="top-actions">
<el-button
v-for="action in actions"
:key="action.name"
@click="execute_action(action)"
type="text"
size="small"
>{{ action.label }}</el-button
>
</div>
</el-scrollbar>
</div>
<div class="detal-btns">
<el-button @click="goTodetail">查看详情</el-button>
</div>
</div>
</template>
<script lang="ts">
import { DetailTypes } from "uniplat-sdk";
import { Component, Prop, Vue } from "vue-property-decorator";
import { ChatStore, chatStore } from "../store/model";
import Chat from "../xim";
import { EVENTS } from "@/EventConsts";
import { goForward } from "@/utils/go-forward";
@Component({ components: {} })
export default class ChatModelDetail extends Vue {
@chatStore.Mutation(ChatStore.MUTATION_HIDE_CHAT)
private readonly hideChat: ChatStore.MUTATION_HIDE_CHAT;
@Prop({ type: String, required: true })
private readonly model_name!: string;
@Prop({ type: String, required: true })
private readonly id!: string;
@Prop({ type: String, default: null })
private readonly name!: string;
private sseMessageRefreshData = false;
private detailData:
| DetailTypes.getDetailRequestResult["meta"]["header"]["field_groups"]
| null = null;
private detailRow: DetailTypes.getDetailRequestResult["row"] | null = null;
private keyField = "";
private actions:
| DetailTypes.getDetailRequestResult["meta"]["actions"]
| null = null;
public async created() {
await this.init();
}
private async init() {
const data = await Chat.getSdk()
.model(this.model_name)
.detail(this.id, this.name)
.query();
this.detailData = data.meta.header.field_groups;
this.detailRow = data.row;
this.keyField = data.meta.key_field;
this.actions = data.meta.actions;
}
private goTodetail() {
this.$router.push(
`/${this.$route.params.project}/${this.$route.params.entrance}/detail/${this.model_name}/key/${this.id}`
);
this.hideChat();
}
private async execute_action(actionParams) {
let {
action_name,
container,
forward,
confirm_caption,
open_in_new_tab,
authed,
} = actionParams;
const x = this.detailRow;
const r: { v: number; id: number } = { v: 0, id: 0 };
r.id = x[this.keyField].value as number;
if (x.uniplat_version) {
r.v = x.uniplat_version.value as number;
}
if (!authed) {
this.$eventHub.$emit(EVENTS.ShowModalDialog, {
dialogName: "authors_list",
params: {
actionName: actionParams.action_name,
modelName: this.model_name,
},
});
return;
}
if (container === "page") {
this.$message.warning("该类型操作暂不支持");
} else if (container === "dialog" || container === "none") {
this.$eventHub.$emit(EVENTS.ShowModalDialog, {
dialogName: "general_executor_dialog",
params: {
autoSubmit: container === "none",
modelName: this.model_name,
actionName: action_name,
selected: JSON.stringify([r]),
prefilters: JSON.stringify([]),
options: { confirm_caption },
callWhenSuccess: (data) => {
const forward = data.forward;
this.init();
if (!!forward && forward !== "") {
goForward.call(this, forward, this.init);
}
},
},
});
} else if (container === "batch") {
this.$eventHub.$emit(EVENTS.ShowModalDialog, {
dialogName: "general_execute_batch",
params: {
model_name: this.model_name,
action_name,
selected_list: JSON.stringify([r]),
prefilters: [],
onSuccessed: this.init,
},
});
} else if (container === "detail_dialog") {
this.$eventHub.$emit(EVENTS.ShowModalDialog, {
dialogName: "general_detail_dialog",
params: {
forward: forward,
},
});
} else if (container === "iframe") {
if (
forward.indexOf("http://") !== 0 &&
forward.indexOf("https://") !== 0
) {
if (this.global.$ssr && this.global.$vapperRootPath) {
forward =
"/" +
this.global.$vapperRootPath +
forward.replace(/^\//, "");
}
}
this.$eventHub.$emit(EVENTS.ShowModalDialog, {
dialogName: "general_iframe_dialog",
params: {
title: "",
href: forward,
width: "70%",
height: 500,
},
});
} else if (container === "startProcess") {
this.$eventHub.$emit(EVENTS.ShowModalDialog, {
dialogName: "start_process_dialog",
params: {
autoSubmit: container === "none",
modelName: this.model_name,
actionName: action_name,
selected: JSON.stringify([r]),
prefilters: {},
filters: {},
options: { confirm_caption },
callWhenSuccess: () => {
this.$emit("datachange");
},
},
});
} else {
if (
forward.indexOf("http://") === 0 ||
forward.indexOf("https://") === 0
) {
window.open(forward, "_blank");
} else {
if (this.global.$ssr && this.global.$vapperRootPath) {
forward =
"/" +
this.global.$vapperRootPath +
forward.replace(/^\//, "");
}
if (open_in_new_tab) {
window.open(`${forward}`, "_blank");
} else {
this.$router.push(forward);
}
}
}
}
}
</script>
<style lang="less" scoped>
.data-row {
color: #666;
font-size: 14px;
padding: 5px 20px;
}
.data-key {
width: 5em;
}
.data-value {
display: inline-block;
vertical-align: top;
}
.detal-btns {
position: absolute;
bottom: 20px;
right: 0;
left: 0;
text-align: center;
}
.scroll-wrap {
height: calc(100% - 70px);
}
.operation_field {
margin-left: 2px;
/deep/ .el-button {
padding: 0;
}
}
.top-actions {
margin-top: 10px;
padding: 0 20px;
white-space: normal;
.el-button + .el-button {
margin-left: 0;
}
.el-button {
margin-right: 10px;
}
}
</style>
......@@ -176,6 +176,11 @@ export default class WhoReadList extends Vue {
z-index: 2;
margin-left: -100px;
width: 125px;
&.offset {
margin-left: 0;
}
.number-count {
font-size: 14px;
color: #333333;
......
......@@ -76,16 +76,12 @@ export default class WorkFlow extends Vue {
private readonly startProcessIns!: startProcessDialog;
@Prop({
type: String,
required: true,
})
private readonly model_name!: string;
@Prop({
type: String,
required: true,
})
private readonly id!: string;
@Prop({ required: true })
private readonly id!: string | number;
@Prop({
type: String,
......@@ -93,8 +89,8 @@ export default class WorkFlow extends Vue {
})
private readonly name!: string;
private processName = ""
private selectedProcess = []
private processName = "";
private selectedProcess = [];
private startDialogConfirm() {
console.log("startDialogConfirm");
......@@ -108,7 +104,10 @@ export default class WorkFlow extends Vue {
private flowList = [];
public async created() {
this.flowList = await sdk().model(this.model_name).workflow2().queryProcessByAssociateId(+this.id);
this.flowList = await sdk()
.model(this.model_name)
.workflow2()
.queryProcessByAssociateId(+this.id);
}
public start(workflow: any) {
......@@ -118,7 +117,7 @@ export default class WorkFlow extends Vue {
}
public goToDetail(workflow: any) {
this.$eventHub.$emit(EVENTS.ShowModalDialog, {
Chat.$emit(EVENTS.ShowModalDialog, {
dialogName: "show_process_detail",
params: {
modelName: this.model_name,
......
<template>
<div class="input-wrap h-100 overflow-hidden">
<div class="input-wrap h-100">
<div class="tool-bar">
<img
class="tool-bar-icon"
......@@ -73,7 +73,7 @@ import {
MESSAGE_FILE_EMPTY,
MESSAGE_FILE_TOO_LARGE,
MESSAGE_IMAGE_TOO_LARGE,
} from "../components/file-controller";
} from "../components/message-item/file-controller";
import { EmojiService } from "../service/emoji";
import { ChatStore } from "../store/model";
import { formatFileSize } from "../utils";
......@@ -97,7 +97,7 @@ export interface InputMessage {
file?: File | null;
}
const chatStoreNamespace = namespace("chatStore");
const chatStore = namespace("chatStore");
const chatCache: { [key: number]: any } = {};
......@@ -115,9 +115,12 @@ export function isImageOrFile(node: ChildNode) {
@Component({ components: {} })
export default class Input extends Vue {
@chatStoreNamespace.State(ChatStore.STATE_CHAT_CURRENT_CHAT_ID)
@chatStore.State(ChatStore.STATE_CHAT_CURRENT_CHAT_ID)
private readonly chatId!: ChatStore.STATE_CHAT_CURRENT_CHAT_ID;
@chatStore.Action(ChatStore.ACTION_GET_MY_CHAT_LIST)
protected readonly getMyChatList!: ChatStore.ACTION_GET_MY_CHAT_LIST;
@Ref("input")
private readonly messageInputBox!: HTMLDivElement;
......@@ -276,7 +279,7 @@ export default class Input extends Vue {
const el = this.messageInputBox;
const range = document.createRange();
const sel = window.getSelection();
const offset = sel.focusOffset;
const offset = sel!.focusOffset;
const content = el.innerHTML;
el.innerHTML =
content.slice(0, offset) +
......@@ -284,8 +287,8 @@ export default class Input extends Vue {
(content.slice(offset) || "\n");
range.setStart(el.childNodes[0], offset + 1);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
sel!.removeAllRanges();
sel!.addRange(range);
return;
}
if (e.shiftKey) {
......@@ -306,12 +309,14 @@ export default class Input extends Vue {
this.$emit("error", e);
reject(e);
}
}).then(() => {
this.clearInput();
if (this.chatId) {
chatCache[this.chatId] = [];
}
});
})
.then(() => {
this.clearInput();
if (this.chatId) {
chatCache[this.chatId] = [];
}
})
.finally(() => setTimeout(() => this.getMyChatList(), 120));
}
/**
......@@ -635,7 +640,7 @@ export default class Input extends Vue {
.emoji-picker {
position: absolute;
z-index: 2;
bottom: 99px;
top: -232px;
left: -1px;
background-color: #fff;
padding: 20px;
......
<svg xmlns="http://www.w3.org/2000/svg" width="42" height="42" viewBox="0 0 42 42"><defs><style>.a{fill:#e84929;}.b{fill:#fff;}</style></defs><path class="a" d="M21,0A21,21,0,1,0,42,21,21,21,0,0,0,21,0Z"/><g transform="translate(8.066 8.066)"><g transform="translate(0 0)"><path class="b" d="M3462.491,2574.047a12.934,12.934,0,0,0,12.476,13.375q.231.008.461.008a12.933,12.933,0,1,0-12.937-13.383Zm25.367.882a12.449,12.449,0,1,1-12.447-12.882c.145,0,.292,0,.438.007A12.463,12.463,0,0,1,3487.858,2574.929Z" transform="translate(-3462.483 -2561.563)"/><path class="b" d="M3573.4,2676.362a.238.238,0,0,0-.238.238v3.916l1.784-.026-.021-4.128Z" transform="translate(-3559.739 -2662.417)"/><path class="b" d="M3573.158,2723.018v3.94a10.489,10.489,0,0,0,1.81-.237l-.019-3.73Z" transform="translate(-3559.739 -2703.343)"/><path class="b" d="M3504.454,2582.427h7.627a10.326,10.326,0,0,0-12.393,0h3.1a.241.241,0,0,1,.238.243v.972a.241.241,0,0,1-.238.243H3498.1a10.567,10.567,0,0,0-.8,1.017h5.427a.24.24,0,0,1,.238.243v2.872a1.444,1.444,0,0,1-1.43,1.458h-1.736a.241.241,0,0,0-.239.243v9.874a10.439,10.439,0,0,0,1.249.827v-9.648a.241.241,0,0,1,.238-.243h1.668a.241.241,0,0,1,.239.243v10.556a10.138,10.138,0,0,0,1.24.284v-10.8a.241.241,0,0,1,.238-.243h9.535a.241.241,0,0,1,.238.243v6.743a10.854,10.854,0,0,0-.547-13.67h-9.212a.241.241,0,0,1-.239-.243v-.972A.241.241,0,0,1,3504.454,2582.427Zm0,2.491h1.669a.241.241,0,0,1,.238.243v2.613a.241.241,0,0,0,.238.243h5.3a.241.241,0,0,0,.239-.243v-1.154a.241.241,0,0,0-.239-.244h-4.589a.24.24,0,0,1-.238-.243v-.972a.241.241,0,0,1,.238-.243h6.734a.241.241,0,0,1,.239.243v2.856a1.444,1.444,0,0,1-1.431,1.458h-8.4a.241.241,0,0,1-.239-.243v-4.071A.241.241,0,0,1,3504.454,2584.919Z" transform="translate(-3492.747 -2578.121)"/><path class="b" d="M3607.532,2676.362h-1.574l.021,4.1,1.791-.026V2676.6A.238.238,0,0,0,3607.532,2676.362Z" transform="translate(-3588.562 -2662.417)"/><path class="b" d="M3606.2,2725.573a10.5,10.5,0,0,0,1.77-1.044v-2.019l-1.785.026Z" transform="translate(-3588.764 -2702.919)"/><path class="b" d="M3486.713,2631.115v-1.148a.239.239,0,0,0-.239-.239h-4.129a10.5,10.5,0,0,0,.963,10.8v-8.938a.239.239,0,0,1,.239-.239h2.927A.238.238,0,0,0,3486.713,2631.115Z" transform="translate(-3479.042 -2621.378)"/></g></g></svg>
\ No newline at end of file
import type { UniplatSdk } from "uniplat-sdk";
export const enum ChatRole {
Default = 25,
Admin = 85,
CustomerService = 92,
}
export interface Chat {
chat_id: number;
title: string;
......@@ -27,6 +33,17 @@ export interface Chat {
unread_msg_count: number;
}
export const enum CustomerServiceProduct {
Default,
Fulibao,
Hrs,
}
export const enum ServiceType {
Frontend,
Backend,
}
export type TokenStringGetter = () => Promise<string>;
export interface ChatOption {
......@@ -38,6 +55,20 @@ export interface ChatOption {
sdk: () => UniplatSdk;
orgId: () => string | number;
product?: CustomerServiceProduct;
/**
* 用于标记会话启动是在客户端(用户)还是服务端(后端)
*/
serviceType?: ServiceType;
eventHub?: Vue;
/**
* 用户信息(头像,别名)可选
*/
user?: { icon?: string; username?: string };
}
export interface ChatServiceLogger {
......@@ -62,6 +93,12 @@ export const enum MessageType {
Withdraw = "withdraw",
}
export const enum MessageHandled {
Default,
Handled,
Ignored,
}
export interface Message {
at_id: string;
chat_id: number;
......@@ -82,11 +119,12 @@ export interface Message {
type: MessageType;
update_time: number;
url: string;
handled?: MessageHandled;
}
export type MessageRequestResult = readonly Message[];
export interface CreateChatByServicemanRequestResult {
export interface BaseChatItem {
id: number;
org_id: string;
uid: string;
......@@ -97,7 +135,6 @@ export interface CreateChatByServicemanRequestResult {
app_id: string;
tag: string;
msg_id: number;
ext: string;
exit_msg_id: number;
is_exited: boolean;
dnd: number;
......@@ -106,7 +143,6 @@ export interface CreateChatByServicemanRequestResult {
join_msg_id: number;
last_read_msg_id: number;
biz_id: string;
business_data: string;
is_finish: boolean;
is_deleted: boolean;
is_remove: boolean;
......@@ -124,11 +160,27 @@ export interface CreateChatByServicemanRequestResult {
last_msg_ts: number;
members_updated: number;
user_updated: number;
model_name: string;
obj_id: string;
}
export interface RawChatItem extends BaseChatItem {
ext: string;
last_msg_content: string;
business_data?: string;
}
export interface TransferedChatItem extends BaseChatItem {
chat_id: number;
ext: string;
last_msg_content: { text?: string };
}
export type ChatMemberExtraInfo = {
name?: string;
phone?: string;
};
export interface ChatMember {
id: number;
org_id: string;
......
......@@ -48,7 +48,7 @@ export namespace ChatStore {
export type STATE_CHAT_CURRENT_CHAT_UNIPLAT_ID = string | null;
export const STATE_CHAT_USERNAME = "会话用户id-name";
export type STATE_CHAT_USERNAME = { [key: string]: string } | {};
export type STATE_CHAT_USERNAME = { [key: string]: string };
export const STATE_CHAT_MY_ID = "聊天窗口显示在右边那个人的id";
export type STATE_CHAT_MY_ID = string | null;
......@@ -56,7 +56,7 @@ export namespace ChatStore {
export type STATE_CHAT_MY_UID = string | null;
export const STATE_CHAT_SOURCE = "stateChatSource";
export type STATE_CHAT_SOURCE = StateChatSourceDirection;
export type STATE_CHAT_SOURCE = dto.ServiceType;
export const STATE_CURRENT_CHAT_INPUTING = "当前会话正在输入的人";
export type STATE_CURRENT_CHAT_INPUTING = string[];
......@@ -67,14 +67,6 @@ export namespace ChatStore {
export const STATE_CHAT_SEND_FAIL_MESSAGE = "最新一条发送失败消息";
export type STATE_CHAT_SEND_FAIL_MESSAGE = string | null;
/**
* 消息来源,是来自客服端(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)[]
......@@ -89,6 +81,9 @@ export namespace ChatStore {
export const STATE_FUNC_ON_NEW_MSG = "收到消息回调方法";
export type STATE_FUNC_ON_NEW_MSG = (e: chatDto.Message) => void;
export const STATE_CURRENT_UNREAD_MESSAGE_COUNT = "当前未读消息数";
export type STATE_CURRENT_UNREAD_MESSAGE_COUNT = number;
/* getter */
export const GETTER_CURRENT_CHAT_PRESENT_MEMBERS = "当前会话未退出的参与者";
export type GETTER_CURRENT_CHAT_PRESENT_MEMBERS = dto.ChatMembers | null;
......@@ -98,8 +93,10 @@ export namespace ChatStore {
/* mutation */
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
......@@ -170,6 +167,9 @@ export namespace ChatStore {
export const MUTATION_CLEAR_CURRENT_CHAT_UNIPLAT_ID = "清空chat uniplat id";
export type MUTATION_CLEAR_CURRENT_CHAT_UNIPLAT_ID = () => void;
export const MUTATION_WITHDRAW = "撤回";
export type MUTATION_WITHDRAW = (id: number) => void;
export const MUTATION_SAVE_MYSELF_ID =
"保存我的id:聊天窗口显示在右边那个人的id";
export type MUTATION_SAVE_MYSELF_ID = () => void;
......@@ -179,9 +179,7 @@ export namespace ChatStore {
export type MUTATION_CLEAR_MYSELF_ID = () => void;
export const MUTATION_SET_CHAT_SOURCE = "setChatSource";
export type MUTATION_SET_CHAT_SOURCE = (
payload: StateChatSourceDirection
) => void;
export type MUTATION_SET_CHAT_SOURCE = (payload: dto.ServiceType) => void;
export const MUTATION_SAVE_CURRENT_CHAT_MEMBERS = "保存当前会话参与者";
export type MUTATION_SAVE_CURRENT_CHAT_MEMBERS = (
......@@ -246,7 +244,10 @@ export namespace ChatStore {
export const ACTION_GET_MY_CHAT_LIST = "获取我的会话列表";
export type ACTION_GET_MY_CHAT_LIST = (
keyword?: string
) => Promise<ChatStore.STATE_MY_CHAT_ROOM_LIST>;
) => Promise<ChatType[]>;
export const ACTION_REBUILD_UNREAD_MESSAGE_COUNT = "重新计算未读消息数";
export type ACTION_REBUILD_UNREAD_MESSAGE_COUNT = () => void;
export const ACTION_JOIN_CHAT = "加入某个会话";
export type ACTION_JOIN_CHAT = (chatId: number) => void;
......@@ -309,10 +310,10 @@ export namespace ChatStore {
export const ACTION_SEND_MESSAGE = "发送消息";
export type ACTION_SEND_MESSAGE = (params: {
msgType: "text" | "image" | "file" | "voice" | "video";
msgType: dto.MessageType;
msg: string;
ts?: number;
}) => void;
}) => Promise<void>;
export const ACTION_TERINATE_CHAT = "结束会话";
export type ACTION_TERINATE_CHAT = () => Promise<void>;
export const ACTION_CHAT_ADD_MEMBERS = "添加成员";
......@@ -336,6 +337,24 @@ export namespace ChatStore {
export const ACTION_CHAT_CS_EXIT = "客服退出";
export type ACTION_CHAT_CS_EXIT = () => Promise<void>;
export const ACTION_SET_HANDLED = "设置敏感词已处理";
export type ACTION_SET_HANDLED = (p: {
id: number;
value: dto.MessageHandled;
}) => void;
export interface ChatUpdateParameter {
chat: number;
type?: dto.MessageType;
msg?: string;
ts?: number;
eid?: string;
unread?: number;
}
export const ACTION_UPDATE_CHAT = "更新会话信息";
export type ACTION_UPDATE_CHAT = (p: ChatUpdateParameter) => void;
}
export interface ChatStoreState {
......@@ -362,6 +381,7 @@ export interface ChatStoreState {
[ChatStore.STATE_CHAT_CURRENT_USER_TYPE]: ChatStore.STATE_CHAT_CURRENT_USER_TYPE;
[ChatStore.STATE_CHAT_SEND_FAIL_MESSAGE]: ChatStore.STATE_CHAT_SEND_FAIL_MESSAGE;
[ChatStore.STATE_CHAT_USERNAME]: ChatStore.STATE_CHAT_USERNAME;
[ChatStore.STATE_CURRENT_UNREAD_MESSAGE_COUNT] : ChatStore.STATE_CURRENT_UNREAD_MESSAGE_COUNT;
}
export const chatStore = namespace(ChatStore.ns);
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);
}
}
}
......@@ -162,7 +162,7 @@ export function formatTime(
if (String(time).indexOf("-")) {
time = String(time).replace(/-/g, "/");
}
if (/^\d+$/.test(time + '') && +time < STANDARD) {
if (/^\d+$/.test(time + "") && +time < STANDARD) {
time = +time * 1000;
}
const t = new Date(time);
......@@ -222,3 +222,13 @@ export function formatTime(
format2DetailTime(hour, t, option.rule)
);
}
export function parseString2TimeValue(time: string, defaultValue = 0) {
if (!time) {
return defaultValue;
}
if (String(time).indexOf("-")) {
time = String(time).replace(/-/g, "/");
}
return new Date(time).valueOf();
}
......@@ -2,21 +2,29 @@ import type { UniplatSdk } from "uniplat-sdk";
import { EmojiService } from "../service/emoji";
import { ChatOption, TokenStringGetter } from "./../model";
import {
ChatOption,
TokenStringGetter,
ServiceType,
CustomerServiceProduct,
} from "./../model";
import { ChatLoggerService } from "./logger";
import tokenManager from "./token";
import xim from "./xim";
import { dbController } from "../database";
class Chat {
private _sdk?: () => UniplatSdk;
private _orgId: () => string | number = () => "0";
private token!: TokenStringGetter;
private serviceType = ServiceType.Backend;
private product = CustomerServiceProduct.Default;
private eventHub: Vue | null = null;
private keywords: string[] = [];
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`);
......@@ -29,15 +37,30 @@ class Chat {
}
this._sdk = option.sdk;
this._orgId = option.orgId;
option.serviceType !== undefined && (this.serviceType = option.serviceType);
option.product && (this.product = option.product);
this.eventHub = option.eventHub || null;
if (option.user) {
this.userMapping[this._sdk().global.uid] = {
name: option.user.username || "",
avatar: option.user.icon || "",
};
}
dbController.setup(this._sdk().global.uid);
this.token = async () => option.sdk().global.jwtToken;
tokenManager.save(this.token);
EmojiService.raiseOnReady(this.token);
option.sdk().events.addTokenChanged((token) => {
this.setToken(() => new Promise((resolve) => resolve(token)));
});
option
.sdk()
.events.addTokenChanged((token) =>
this.setToken(() => new Promise((resolve) => resolve(token)))
);
// this.keywords = ["社保"];
return this.initChatSdk(option.webSocketUri);
}
......@@ -47,20 +70,28 @@ class Chat {
}
public getSdk = () => {
if (this._sdk == null) {
if (!this._sdk) {
throw new Error("sdk shouldn't undefined");
}
return this._sdk();
};
public getServiceType() {
return this.serviceType;
}
public isBackend() {
return this.serviceType === ServiceType.Backend;
}
public getProduct() {
return this.product;
}
public getOrgId = () => {
return this._orgId();
};
public isWebHost() {
return this.webHost;
}
public setToken(token: TokenStringGetter) {
return xim.setToken(token);
}
......@@ -99,12 +130,28 @@ class Chat {
}
public getUserMapping() {
return {} as any;
return this.userMapping;
}
private debug(message: string) {
ChatLoggerService.logger?.debug(message);
}
public $emit(event: string, ...args: any[]) {
if (this.eventHub) {
this.eventHub.$emit(event, ...args);
}
}
public $on(event: string, callback: Function) {
if (this.eventHub) {
this.eventHub.$on(event, callback);
}
}
public getMatchedTextKeywords() {
return this.keywords;
}
}
export default new Chat();
import { MessageType } from "@/customer-service/model";
import { MessageHandled, MessageType } from "@/customer-service/model";
export interface Chat {
id: number;
......@@ -22,12 +22,9 @@ export interface Chat {
biz_id: string;
last_msg_sender: string;
last_msg_content: string;
last_msg_type: MessageType;
business_data: {
model_name: string;
obj_id: string;
detail_name: string;
};
last_msg_type: string;
model_name: string;
obj_id: string;
is_finish: boolean;
is_deleted: boolean;
is_remove: boolean;
......@@ -48,6 +45,8 @@ export interface Chat {
members_updated: number;
user_updated: number;
chat_id: number;
catalog: string;
biz_type_id: number;
}
export interface Message {
......@@ -56,7 +55,7 @@ export interface Message {
eid: string;
id: number;
ts: number;
type: string;
type: MessageType;
msg: string;
total_read_count: number;
read_count: number;
......@@ -70,6 +69,7 @@ export interface Message {
status: number;
url: string;
is_open: boolean;
handled?: MessageHandled;
}
export interface NotifyMessage {
......@@ -95,44 +95,6 @@ export interface Member {
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;
};
......@@ -232,17 +194,6 @@ export type CommentForwardMessageBody = {
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;
......
......@@ -6,6 +6,7 @@ import chatType from "../xim/chat-type";
import { TokenStringGetter } from "./../model";
import { ChatLoggerService } from "./logger";
import { Message, NotifyMessage } from "./models/chat";
import chat from "./index";
wampDebug(true);
......@@ -16,11 +17,11 @@ function emptyFunc() {
return null;
}
export type MsgListener = (msg: Message) => void
export type MsgListener = (msg: Message) => void;
export type ChatNotifyListener = (msg: NotifyMessage) => void
export type ChatNotifyListener = (msg: NotifyMessage) => void;
export type StatusChangeListener = (status: any, details: any) => void
export type StatusChangeListener = (status: any, details: any) => void;
export enum Events {
Msg = "msg",
......@@ -34,14 +35,14 @@ export enum Kind {
}
export class Xim {
private eventBus = new Vue()
private eventBus = new Vue();
private client?: XChatClient
private client?: XChatClient;
private paramsForReconnection?: {
url: string;
token: TokenStringGetter;
}
};
public close() {
if (this.client) {
......@@ -56,7 +57,7 @@ export class Xim {
}
}
private connectionPending = false
private connectionPending = false;
public async open(url: string, token: TokenStringGetter) {
this.connectionPending = true;
......@@ -112,6 +113,11 @@ export class Xim {
return this.client.fetchChatList({});
}
public fetchChatListAfter(lastMsgTs: number) {
if (this.client == null) return;
return this.client.fetchChatList({ last_msg_ts: lastMsgTs });
}
public fetchChat(chat_id: number) {
if (this.client == null) return;
return this.client.fetchChat(chat_id);
......@@ -160,7 +166,7 @@ export class Xim {
this.checkConnected();
if (this.client == null) {
throw new Error("client shouldn't undefined");
};
}
const res = await this.client.fetchChatMsgs(chatType, chatId, {
lid,
rid,
......@@ -233,43 +239,43 @@ export class Xim {
return data;
}
public on(event: "msg", chatId: number, listener: MsgListener): this
public on(event: "msg", listener: MsgListener): this
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
): this;
public on(
event: "chat_notify",
kind: string,
listener: ChatNotifyListener
): this
): this;
public on(event: "chat_notify", listener: ChatNotifyListener): this
public on(event: "status", listener: StatusChangeListener): 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: "msg", chatId: number, listener: MsgListener): this;
public off(event: "msg", listener: MsgListener): this;
public off(
event: "chat_notify",
kind: "chat_change",
listener: ChatNotifyListener
): this
): this;
public off(
event: "chat_notify",
kind: string,
listener: ChatNotifyListener
): this
): this;
public off(event: "chat_notify", listener: ChatNotifyListener): this
public off(event: "status", listener: StatusChangeListener): 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;
......@@ -391,6 +397,11 @@ export class Xim {
private debug(message: any, ...params: any[]) {
ChatLoggerService.logger?.debug(message, params);
}
public registerOnMessage(vue: Vue, action: (e: Message) => void) {
this.on("msg", action);
vue.$once("hook:beforeDestroy", () => this.off("msg", action));
}
}
const ximInstance = new Xim();
......
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