Browse Source

优化客服聊天

icssoa 4 years ago
parent
commit
38d07a3361

+ 50 - 626
src/cool/modules/chat/components/chat.vue

@@ -1,155 +1,21 @@
 <template>
 	<div class="cl-chat__wrap">
 		<!-- 聊天窗口 -->
-		<cl-dialog :visible.sync="visible" v-bind="conf">
+		<cl-dialog
+			:visible.sync="visible"
+			:title="title"
+			:height="height"
+			:width="width"
+			:props="conf"
+		>
 			<div class="cl-chat">
 				<!-- 会话区域 -->
-				<div class="cl-chat__session">
-					<div class="cl-chat__session-search">
-						<el-input
-							v-model="session.keyWord"
-							placeholder="搜索"
-							prefix-icon="el-icon-search"
-							size="small"
-							clearable
-							@clear="onSearch"
-							@keyup.enter.native="onSearch"
-						></el-input>
-					</div>
-
-					<!-- 会话列表 -->
-					<ul class="cl-chat__session-list scroller1" v-if="sessionList.length > 0">
-						<li
-							class="cl-chat__session-item"
-							v-for="(item, index) in sessionList"
-							:key="index"
-							:class="{
-								'is-active': session.current ? item.id == session.current.id : false
-							}"
-							@click="sessionDetail(item)"
-							@contextmenu.stop.prevent="openSessionCM($event, item.id, index)"
-						>
-							<!-- 头像 -->
-							<div class="avatar">
-								<el-badge
-									:value="item.serviceUnreadCount"
-									:hidden="item.serviceUnreadCount === 0"
-									:max="99"
-								>
-									<img :src="item.headimgurl" alt="" />
-								</el-badge>
-							</div>
-
-							<!-- 昵称,内容 -->
-							<div class="det">
-								<p class="name">{{ item.nickname }}</p>
-								<p class="content">{{ item.lastMessage }}</p>
-							</div>
-						</li>
-					</ul>
-
-					<!-- 空态 -->
-					<div class="cl-chat__session-empty" v-else>
-						没有搜索到内容...
-					</div>
-				</div>
+				<chat-session />
 
 				<!-- 会话详情 -->
-				<div class="cl-chat__detail">
-					<template v-if="session.current">
-						<div
-							class="cl-chat__detail-container scroller1"
-							ref="scroller"
-							v-loading="message.loading"
-						>
-							<!-- 加载更多 -->
-							<div class="cl-chat__detail-more" v-if="message.list.length > 0">
-								<el-button
-									round
-									size="mini"
-									:loading="message.loading"
-									@click="onLoadmore"
-									>加载更多</el-button
-								>
-							</div>
-
-							<!-- 消息列表 -->
-							<message :list="message.list" />
-						</div>
-
-						<div class="cl-chat__detail-footer">
-							<!-- 工具栏 -->
-							<div class="cl-chat__opbar">
-								<ul>
-									<!-- 表情 -->
-									<li>
-										<el-popover
-											v-model="emoji.visible"
-											placement="top-start"
-											width="470"
-											trigger="click"
-										>
-											<emoji @select="onEmojiSelect" />
-											<img
-												slot="reference"
-												src="../static/images/emoji.png"
-												alt=""
-											/>
-										</el-popover>
-									</li>
-									<!-- 图片上传 -->
-									<li hidden>
-										<cl-upload
-											accept="image/*"
-											list-type
-											:on-success="onImageSelect"
-										>
-											<img src="../static/images/image.png" alt="" />
-										</cl-upload>
-									</li>
-									<!-- 视频上传 -->
-									<li hidden>
-										<cl-upload
-											accept="video/*"
-											list-type
-											:before-upload="
-												f => {
-													onBeforeUpload(f, 'video');
-												}
-											"
-											:on-progress="onUploadProgress"
-											:on-success="
-												(r, f) => {
-													onUploadSuccess(r, f, 'video');
-												}
-											"
-										>
-											<img src="../static/images/video.png" alt="" />
-										</cl-upload>
-									</li>
-								</ul>
-							</div>
-
-							<!-- 输入框,发送按钮 -->
-							<div class="cl-chat__input">
-								<el-input
-									v-model="message.value"
-									placeholder="请描述您想咨询的问题"
-									type="textarea"
-									:rows="5"
-									@keyup.enter.native="onTextSend"
-								></el-input>
-
-								<el-button
-									type="primary"
-									size="mini"
-									:disabled="!message.value"
-									@click="onTextSend"
-									>发送</el-button
-								>
-							</div>
-						</div>
-					</template>
+				<div class="cl-chat__detail" v-if="session">
+					<chat-message />
+					<chat-input />
 				</div>
 			</div>
 		</cl-dialog>
@@ -164,22 +30,36 @@
 <script>
 import dayjs from "dayjs";
 import { mapGetters } from "vuex";
-import { isString, debounce } from "cl-admin/utils";
+import { parseContent } from "../utils";
+
 import io from "socket.io-client";
 import { socketUrl } from "@/config/env";
-import Emoji from "./emoji";
+
+import Session from "./session";
 import Message from "./message";
-import { parseContent } from "../utils";
+import Input from "./input";
+import eventBus from "../utils/event-bus";
 
 // 消息模式
-const MODES = ["text", "image", "emoji", "voice", "video"];
 
 export default {
 	name: "cl-chat",
 
 	components: {
-		Message,
-		Emoji
+		"chat-session": Session,
+		"chat-message": Message,
+		"chat-input": Input
+	},
+
+	props: {
+		height: {
+			type: String,
+			default: "650px"
+		},
+		width: {
+			type: String,
+			default: "1000px"
+		}
 	},
 
 	data() {
@@ -187,55 +67,25 @@ export default {
 			visible: false,
 			socket: null,
 			conf: {
-				title: "聊天对话框",
-				height: "650px",
-				width: "1000px",
-				props: {
-					modal: true,
-					customClass: "cl-chat__dialog",
-					"append-to-body": true,
-					"close-on-click-modal": false
-				}
-			},
-			message: {
-				list: [],
-				pagination: {
-					page: 1,
-					size: 20,
-					total: 0
-				},
-				loading: false,
-				value: ""
-			},
-			session: {
-				list: [],
-				pagination: {
-					page: 1,
-					size: 100,
-					total: 0
-				},
-				current: null,
-				keyWord: ""
-			},
-			emoji: {
-				visible: false
+				modal: true,
+				customClass: "cl-chat__dialog",
+				"append-to-body": true,
+				"close-on-click-modal": false
 			}
 		};
 	},
 
+	provide() {
+		return {
+			socket: this.socket
+		};
+	},
+
 	computed: {
-		...mapGetters(["userInfo", "token"]),
-
-		sessionList() {
-			return this.session.list
-				.map(e => {
-					let { _text } = parseContent(e);
-					e.lastMessage = _text;
-					return e;
-				})
-				.sort((a, b) => {
-					return a.updateTime < b.updateTime ? 1 : -1;
-				});
+		...mapGetters(["token", "session", "sessionList"]),
+
+		title() {
+			return this.session ? `与 ${this.session.nickname} 聊天中` : "聊天对话框";
 		}
 	},
 
@@ -264,227 +114,12 @@ export default {
 	methods: {
 		open() {
 			this.visible = true;
-
-			this.refreshSession().then(res => {
-				this.sessionDetail(res.list[0]);
-			});
 		},
 
 		close() {
 			this.visible = false;
 		},
 
-		// 上传前
-		onBeforeUpload(file, key) {
-			const data = {
-				content: {
-					[`${key}Url`]: ""
-				},
-				type: 0,
-				contentType: MODES.indexOf(key),
-				uid: file.uid,
-				loading: true,
-				progress: "0%"
-			};
-
-			this.append(data);
-		},
-
-		// 上传中
-		onUploadProgress(e, file) {
-			let item = this.message.list.find(e => e.uid == file.uid);
-
-			if (item) {
-				item.progress = e.percent + "%";
-			}
-		},
-
-		// 上传成功
-		onUploadSuccess(res, file, key) {
-			let item = this.message.list.find(e => e.uid == file.uid);
-
-			if (item) {
-				item.loading = false;
-				item.content[`${key}Url`] = res.data;
-
-				this.sendMessage(item);
-			}
-		},
-
-		// 打开会话列表右键菜单
-		openSessionCM(e, id, index) {
-			this.$crud.openContextMenu(e, {
-				list: [
-					{
-						label: "删除",
-						icon: "el-icon-delete",
-						callback: (_, done) => {
-							this.$service.im.session.delete({
-								ids: id
-							});
-
-							this.session.list.splice(index, 1);
-
-							if (id == this.session.current.id) {
-								this.sessionDetail();
-							}
-
-							done();
-						}
-					}
-				]
-			});
-		},
-
-		// 刷新会话列表
-		refreshSession(params) {
-			return this.$service.im.session
-				.page({
-					...this.session.pagination,
-					keyWord: this.session.keyWord,
-					params,
-					order: "updateTime",
-					sort: "desc"
-				})
-				.then(res => {
-					this.session.list = res.list;
-					this.session.pagination = res.pagination;
-
-					return res;
-				});
-		},
-
-		// 刷新详情
-		async sessionDetail(item) {
-			if (item) {
-				let { id } = this.session.current || {};
-
-				if (id != item.id) {
-					item.serviceUnreadCount = 0;
-
-					this.conf.title = `与${item.nickname}聊天中`;
-					this.message.loading = true;
-					this.message.list = [];
-					this.session.current = item;
-
-					await this.refreshMessage({ page: 1 });
-
-					this.message.loading = false;
-				}
-
-				this.scrollToBottom();
-			} else {
-				this.conf.title = "聊天对话框";
-				this.message.list = [];
-				this.session.current = null;
-			}
-		},
-
-		// 刷新消息列表
-		refreshMessage(params) {
-			return this.$service.im.message
-				.page({
-					...this.message.pagination,
-					...params,
-					sessionId: this.session.current.id,
-					order: "createTime",
-					sort: "desc"
-				})
-				.then(res => {
-					this.message.pagination = res.pagination;
-					this.prepend.apply(this, res.list);
-				});
-		},
-
-		// 更新会话消息
-		updateSession(data) {
-			Object.assign(this.session.current, data);
-		},
-
-		// 搜索关键字
-		onSearch() {
-			this.refreshSession({ page: 1 });
-		},
-
-		// 加载更多
-		onLoadmore() {
-			this.refreshMessage({ page: this.message.pagination.page + 1 });
-		},
-
-		// 滚动到底部
-		scrollToBottom: debounce(function() {
-			this.$nextTick(() => {
-				if (this.$refs["scroller"]) {
-					this.$refs["scroller"].scrollTo({
-						top: 99999,
-						behavior: "smooth"
-					});
-				}
-			});
-		}, 300),
-
-		// 发送文本内容
-		onTextSend() {
-			if (this.message.value) {
-				if (this.message.value.replace(/\n/g, "") !== "") {
-					const data = {
-						type: 0,
-						contentType: 0,
-						content: {
-							text: this.message.value
-						}
-					};
-
-					this.append(data);
-					this.sendMessage(data);
-
-					this.$nextTick(() => {
-						this.message.value = "";
-					});
-				}
-			}
-		},
-
-		// 图片选择
-		onImageSelect(res) {
-			const data = {
-				content: {
-					imageUrl: res.data
-				},
-				type: 0,
-				contentType: 1
-			};
-			this.append(data);
-			this.sendMessage(data);
-		},
-
-		// 表情选择
-		onEmojiSelect(url) {
-			this.emoji.visible = false;
-			const data = {
-				content: {
-					imageUrl: url
-				},
-				type: 0,
-				contentType: 2
-			};
-			this.append(data);
-			this.sendMessage(data);
-		},
-
-		// 视频选择
-		onVideoSelect(url) {
-			const data = {
-				content: {
-					videoUrl: url
-				},
-				type: 0,
-				contentType: 4
-			};
-			this.append(data);
-			this.sendMessage(data);
-		},
-
 		// 监听消息
 		onMessage(msg) {
 			// 回调
@@ -497,17 +132,17 @@ export default {
 				const { contentType, fromId, content, msgId } = JSON.parse(msg);
 
 				// 是否当前
-				const same = this.session.current && this.session.current.userId == fromId;
+				const same = this.session && this.session.userId == fromId;
 
 				if (same) {
 					// 更新消息
-					this.updateSession({
+					this.$store.commit("UPDATE_SESSION", {
 						contentType,
 						content
 					});
 
 					// 追加消息
-					this.append({
+					eventBus.$emit("message-append", {
 						contentType,
 						content: JSON.parse(content),
 						type: 1
@@ -521,7 +156,7 @@ export default {
 				}
 
 				// 查找会话
-				let item = this.session.list.find(e => e.userId == fromId);
+				const item = this.sessionList.find(e => e.userId == fromId);
 
 				if (item) {
 					if (!same) {
@@ -535,7 +170,7 @@ export default {
 					});
 				} else {
 					// 刷新会话列表
-					this.refreshSession();
+					eventBus.$emit("session-refresh");
 				}
 			} catch (e) {
 				console.error("消息格式异常", e);
@@ -575,76 +210,6 @@ export default {
 					}
 				}
 			}
-		},
-
-		// 发送消息
-		sendMessage({ contentType, content }) {
-			const { id, userId } = this.session.current;
-
-			// 更新消息
-			this.updateSession({
-				contentType,
-				content
-			});
-
-			if (this.socket) {
-				this.socket.emit(`user@${userId}`, {
-					contentType,
-					type: 0,
-					content: JSON.stringify(content),
-					sessionId: id
-				});
-			}
-		},
-
-		/**
-		 * 处理消息数据
-		 * mode: 消息模式
-		 * type: 消息类型 0-回复,1-反馈
-		 * duration: 时常
-		 * videoUrl: 视频地址
-		 * videoCoverUrl: 视频封面
-		 * imageUrl: 图片地址
-		 * avatarUrl: 头像地址
-		 * nickName: 昵称
-		 */
-		handleMessage(e) {
-			if (isString(e)) {
-				e = JSON.parse(e);
-			}
-
-			if (isString(e.content)) {
-				e.content = JSON.parse(e.content);
-			}
-
-			// 昵称
-			const nickName = e.type == 0 ? this.userInfo.nickName : this.session.current.nickname;
-			// 头像
-			const avatarUrl =
-				e.type == 0
-					? this.userInfo.avatarUrl || require("../static/images/custom-avatar.png")
-					: this.session.current.headimgurl;
-
-			return {
-				...e,
-				avatarUrl,
-				nickName,
-				mode: MODES[e.contentType],
-				date: dayjs().format("YYYY-MM-DD HH:mm:ss")
-			};
-		},
-
-		// 追加数据到开头
-		prepend(...data) {
-			data.map(this.handleMessage).forEach(e => {
-				this.message.list.unshift(e);
-			});
-		},
-
-		// 追加数据到结尾
-		append(...data) {
-			this.message.list.push(...data.map(this.handleMessage));
-			this.scrollToBottom();
 		}
 	}
 };
@@ -671,90 +236,6 @@ export default {
 	height: 100%;
 	background-color: #f7f7f7;
 
-	&__session {
-		height: calc(100% - 10px);
-		width: 250px;
-		margin: 5px 0 5px 5px;
-		border-radius: 5px;
-		background-color: #fff;
-
-		&-search {
-			padding: 10px;
-		}
-
-		&-list {
-			height: calc(100% - 52px);
-			overflow: auto;
-
-			li {
-				display: flex;
-				list-style: none;
-				padding: 10px;
-				border-left: 5px solid #fff;
-
-				.avatar {
-					margin-right: 12px;
-
-					img {
-						display: block;
-						height: 40px;
-						width: 40px;
-						border-radius: 3px;
-						background-color: #eee;
-					}
-
-					.el-badge {
-						&__content {
-							height: 14px;
-							line-height: 14px;
-							padding: 0 4px;
-							background-color: #fa5151;
-							border: 0;
-						}
-					}
-				}
-
-				.det {
-					flex: 1;
-					.name {
-						font-size: 13px;
-						margin-top: 1px;
-					}
-
-					.content {
-						font-size: 12px;
-						margin-top: 5px;
-						color: #666;
-					}
-
-					.name,
-					.content {
-						overflow: hidden;
-						text-overflow: ellipsis;
-						display: -webkit-box;
-						-webkit-box-orient: vertical;
-						-webkit-line-clamp: 1;
-					}
-				}
-
-				&.is-active {
-					background-color: #eee;
-					border-color: $color-primary;
-				}
-
-				&:hover {
-					background-color: #eee;
-					cursor: pointer;
-				}
-			}
-		}
-
-		&-empty {
-			text-align: center;
-			margin-top: 10px;
-		}
-	}
-
 	&__detail {
 		display: flex;
 		flex-direction: column;
@@ -762,63 +243,6 @@ export default {
 		height: 100%;
 		padding: 5px;
 		box-sizing: border-box;
-
-		&-container {
-			flex: 1;
-			border-radius: 5px;
-			padding: 10px;
-			overflow: auto;
-			margin-bottom: 5px;
-		}
-
-		&-more {
-			display: flex;
-			justify-content: center;
-			margin-bottom: 20px;
-		}
-
-		&-footer {
-			background-color: #fff;
-			padding: 10px;
-			border-radius: 5px;
-		}
-	}
-
-	&__message {
-		flex: 1;
-		border-radius: 5px;
-	}
-
-	&__opbar {
-		margin-bottom: 5px;
-
-		ul {
-			display: flex;
-			li {
-				list-style: none;
-				margin-right: 10px;
-				cursor: pointer;
-
-				&:hover {
-					opacity: 0.7;
-				}
-
-				img {
-					height: 26px;
-					width: 26px;
-				}
-			}
-		}
-	}
-
-	&__input {
-		position: relative;
-
-		.el-button {
-			position: absolute;
-			right: 10px;
-			bottom: 10px;
-		}
 	}
 }
 </style>

+ 265 - 0
src/cool/modules/chat/components/input.vue

@@ -0,0 +1,265 @@
+<template>
+	<div class="cl-chat-input">
+		<!-- 工具栏 -->
+		<div class="cl-chat-input__opbar">
+			<ul>
+				<!-- 表情 -->
+				<li>
+					<el-popover
+						v-model="emoji.visible"
+						placement="top-start"
+						width="470"
+						trigger="click"
+					>
+						<emoji @select="onEmojiSelect" />
+						<img slot="reference" src="../static/images/emoji.png" alt="" />
+					</el-popover>
+				</li>
+				<!-- 图片上传 -->
+				<li hidden>
+					<cl-upload accept="image/*" list-type :on-success="onImageSelect">
+						<img src="../static/images/image.png" alt="" />
+					</cl-upload>
+				</li>
+				<!-- 视频上传 -->
+				<li hidden>
+					<cl-upload
+						accept="video/*"
+						list-type
+						:before-upload="
+							f => {
+								onBeforeUpload(f, 'video');
+							}
+						"
+						:on-progress="onUploadProgress"
+						:on-success="
+							(r, f) => {
+								onUploadSuccess(r, f, 'video');
+							}
+						"
+					>
+						<img src="../static/images/video.png" alt="" />
+					</cl-upload>
+				</li>
+			</ul>
+		</div>
+
+		<!-- 输入框,发送按钮 -->
+		<div class="cl-chat-input__content">
+			<el-input
+				v-model="value"
+				placeholder="请描述您想咨询的问题"
+				type="textarea"
+				:rows="5"
+				@keyup.enter.native="onTextSend"
+			></el-input>
+
+			<el-button type="primary" size="mini" :disabled="!value" @click="onTextSend"
+				>发送</el-button
+			>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapGetters } from "vuex";
+import Emoji from "./emoji";
+import eventBus from "../utils/event-bus";
+
+export default {
+	components: {
+		Emoji
+	},
+
+	inject: ["socket"],
+
+	data() {
+		return {
+			value: "",
+			emoji: {
+				visible: false
+			}
+		};
+	},
+
+	computed: {
+		...mapGetters(["session"])
+	},
+
+	methods: {
+		// 上传前
+		onBeforeUpload(file, key) {
+			const data = {
+				content: {
+					[`${key}Url`]: ""
+				},
+				type: 0,
+				contentType: MODES.indexOf(key),
+				uid: file.uid,
+				loading: true,
+				progress: "0%"
+			};
+
+			this.append(data);
+		},
+
+		// 上传中
+		onUploadProgress(e, file) {
+			const item = this.message.list.find(e => e.uid == file.uid);
+
+			if (item) {
+				item.progress = e.percent + "%";
+			}
+		},
+
+		// 上传成功
+		onUploadSuccess(res, file, key) {
+			const item = this.message.list.find(e => e.uid == file.uid);
+
+			if (item) {
+				item.loading = false;
+				item.content[`${key}Url`] = res.data;
+
+				this.send(item);
+			}
+		},
+
+		// 发送文本内容
+		onTextSend() {
+			if (this.value) {
+				if (this.value.replace(/\n/g, "") !== "") {
+					const data = {
+						type: 0,
+						contentType: 0,
+						content: {
+							text: this.value
+						}
+					};
+
+					this.send(data, true);
+
+					this.$nextTick(() => {
+						this.value = "";
+					});
+				}
+			}
+		},
+
+		// 图片选择
+		onImageSelect(res) {
+			this.send(
+				{
+					content: {
+						imageUrl: res.data
+					},
+					type: 0,
+					contentType: 1
+				},
+				true
+			);
+		},
+
+		// 表情选择
+		onEmojiSelect(url) {
+			this.emoji.visible = false;
+			this.send(
+				{
+					content: {
+						imageUrl: url
+					},
+					type: 0,
+					contentType: 2
+				},
+				true
+			);
+		},
+
+		// 视频选择
+		onVideoSelect(url) {
+			this.send(
+				{
+					content: {
+						videoUrl: url
+					},
+					type: 0,
+					contentType: 4
+				},
+				true
+			);
+		},
+
+		// 发送消息
+		send(data, isAppend) {
+			const { id, userId } = this.session;
+
+			// 更新消息
+			// this.updateSession({
+			// 	contentType,
+			// 	content
+			// });
+
+			if (this.socket) {
+				this.socket.emit(`user@${userId}`, {
+					contentType: data.contentType,
+					type: 0,
+					content: JSON.stringify(data.content),
+					sessionId: id
+				});
+
+				if (isAppend) {
+					this.append(data);
+				}
+			}
+
+			if (isAppend) {
+				this.append(data);
+			}
+		},
+
+		// 追加消息
+		append(data) {
+			eventBus.$emit("message-append", data);
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.cl-chat-input {
+	background-color: #fff;
+	padding: 10px;
+	border-radius: 3px;
+
+	&__opbar {
+		margin-bottom: 5px;
+
+		ul {
+			display: flex;
+			li {
+				list-style: none;
+				margin-right: 10px;
+				cursor: pointer;
+
+				&:hover {
+					opacity: 0.7;
+				}
+
+				img {
+					height: 26px;
+					width: 26px;
+				}
+			}
+		}
+	}
+
+	&__content {
+		position: relative;
+
+		.el-button {
+			position: absolute;
+			right: 10px;
+			bottom: 10px;
+		}
+	}
+}
+</style>

+ 252 - 80
src/cool/modules/chat/components/message.vue

@@ -1,100 +1,133 @@
 <template>
-	<div class="chat-box-message">
+	<div class="cl-chat-message" v-loading="loading" element-loading-text="消息加载中">
 		<div
-			class="chat-box-message__item"
-			v-for="item in flist"
-			:key="item.id || item.uid"
-			:class="[item.type == 0 ? `is-right` : `is-left`, `is-${item.mode}`]"
+			class="cl-chat-message__scroller scroller1"
+			ref="scroller"
+			:style="{
+				opacity: visible ? 1 : 0
+			}"
 		>
-			<div class="date" v-if="item._date">
-				<span>{{ item._date }}</span>
+			<!-- 加载更多 -->
+			<div class="cl-chat-message__more" v-if="list.length > 0">
+				<el-button round size="mini" :loading="loading" @click="onLoadmore"
+					>加载更多</el-button
+				>
 			</div>
 
-			<div class="main">
-				<div class="avatar" @tap="toUserDetail(item)">
-					<img :src="item.avatarUrl" />
-				</div>
+			<!-- 消息列表 -->
+			<div class="cl-chat-message__list">
+				<div
+					class="cl-chat-message__item"
+					v-for="item in messageList"
+					:key="item.id || item.uid"
+					:class="[item.type == 0 ? `is-right` : `is-left`, `is-${item.mode}`]"
+				>
+					<div class="date" v-if="item._date">
+						<span>{{ item._date }}</span>
+					</div>
 
-				<div class="det">
-					<span class="name">{{ item.nickName }}</span>
-
-					<div
-						class="content"
-						v-loading="item.loading"
-						:element-loading-text="item.progress"
-						@click="tapItem(item)"
-					>
-						<!-- 文本 -->
-						<template v-if="item.mode === 'text'">{{ item.content.text }}</template>
-
-						<!-- 图片 -->
-						<template v-else-if="item.mode === 'image'">
-							<el-image
-								:key="item.uid"
-								:src="item.content.imageUrl"
-								:preview-src-list="[item.content.imageUrl]"
-							></el-image>
-						</template>
-
-						<!-- 表情 -->
-						<template v-else-if="item.mode === 'emoji'">
-							<img :src="item.content.imageUrl" />
-						</template>
-
-						<!-- 语音 -->
-						<template v-else-if="item.mode === 'voice'">
-							<icon-voice :play="item.isPlay"></icon-voice>
-							<span class="duration">{{ item.content.duration | duration }}"</span>
-						</template>
-
-						<!-- 视频 -->
-						<template v-else-if="item.mode === 'video'">
-							<div class="item">
-								<video
-									:poster="item.content.videoUrl | video_poster"
-									:src="item.content.videoUrl"
-									controls
-								></video>
+					<div class="main">
+						<div class="avatar" @tap="toUserDetail(item)">
+							<img :src="item.avatarUrl" />
+						</div>
+
+						<div class="det">
+							<span class="name">{{ item.nickName }}</span>
+
+							<div
+								class="content"
+								v-loading="item.loading"
+								:element-loading-text="item.progress"
+								@click="onTap(item)"
+							>
+								<!-- 文本 -->
+								<template v-if="item.mode === 'text'">{{
+									item.content.text
+								}}</template>
+
+								<!-- 图片 -->
+								<template v-else-if="item.mode === 'image'">
+									<el-image
+										:key="item.uid"
+										:src="item.content.imageUrl"
+										:preview-src-list="[item.content.imageUrl]"
+									></el-image>
+								</template>
+
+								<!-- 表情 -->
+								<template v-else-if="item.mode === 'emoji'">
+									<img :src="item.content.imageUrl" />
+								</template>
+
+								<!-- 语音 -->
+								<template v-else-if="item.mode === 'voice'">
+									<icon-voice :play="item.isPlay"></icon-voice>
+									<span class="duration"
+										>{{ item.content.duration | duration }}"</span
+									>
+								</template>
+
+								<!-- 视频 -->
+								<template v-else-if="item.mode === 'video'">
+									<div class="item">
+										<video
+											:poster="item.content.videoUrl | video_poster"
+											:src="item.content.videoUrl"
+											controls
+										></video>
+									</div>
+								</template>
+
+								<!-- 未知 -->
+								<template v-else>
+									<span>待扩展消息类型</span>
+									<i class="el-icon-warning-outline"></i>
+								</template>
 							</div>
-						</template>
-
-						<!-- 未知 -->
-						<template v-else>
-							<span>待扩展消息类型</span>
-							<i class="el-icon-warning-outline"></i>
-						</template>
+						</div>
 					</div>
 				</div>
-			</div>
-		</div>
 
-		<!-- voice -->
-		<div class="voice">
-			<audio style="display: none" ref="voice" :src="voice.url" controls></audio>
+				<!-- voice -->
+				<div class="voice">
+					<audio style="display: none" ref="voice" :src="voice.url" controls></audio>
+				</div>
+			</div>
 		</div>
 	</div>
 </template>
 
 <script>
 import dayjs from "dayjs";
+import { mapGetters } from "vuex";
+import { isString } from "cl-admin/utils";
+import eventBus from "../utils/event-bus";
 import IconVoice from "./icon-voice";
 
+// 消息类型
+const ModeList = ["text", "image", "emoji", "voice", "video"];
+
 export default {
 	components: {
 		IconVoice
 	},
 
-	props: {
-		list: Array
-	},
-
 	data() {
 		return {
+			loading: false,
+			visible: false,
+			list: [],
+			pagination: {
+				page: 1,
+				size: 20,
+				total: 0
+			},
 			player: {},
 			voice: {
 				url: "",
 				timer: null
-			}
+			},
+			refreshRd: null
 		};
 	},
 
@@ -104,34 +137,72 @@ export default {
 		}
 	},
 
-	destroyed() {
-		clearTimeout(this.voice.timer);
-
-		this.list.map(e => {
-			e.isPlay = false;
-		});
-	},
-
 	computed: {
-		flist() {
+		...mapGetters(["userInfo", "session"]),
+
+		messageList() {
 			let date = "";
 
 			return this.list.map(e => {
+				// 时间间隔
 				e._date = date
 					? dayjs(e.createTime).isBefore(dayjs(date).add(1, "minute"))
 						? ""
 						: e.createTime
 					: e.createTime;
 
+				// 发送时间
 				date = e.createTime;
 
-				return e;
+				// 解析内容
+				if (isString(e)) {
+					e = JSON.parse(e);
+				}
+
+				if (isString(e.content)) {
+					e.content = JSON.parse(e.content);
+				}
+
+				// 解析昵称
+				const nickName = e.type == 0 ? this.userInfo.nickName : this.session.nickname;
+
+				// 解析头像
+				const avatarUrl =
+					e.type == 0
+						? this.userInfo.avatarUrl || require("../static/images/custom-avatar.png")
+						: this.session.headimgurl;
+
+				return {
+					...e,
+					avatarUrl,
+					nickName,
+					mode: ModeList[e.contentType]
+				};
 			});
 		}
 	},
 
+	created() {
+		eventBus.$on("message-refresh", this.refresh);
+		eventBus.$on("message-append", this.append);
+
+		if (this.session) {
+			this.refresh();
+		}
+	},
+
+	destroyed() {
+		clearTimeout(this.voice.timer);
+
+		this.list.map(e => {
+			e.isPlay = false;
+		});
+	},
+
 	methods: {
-		tapItem(item) {
+		// 点击
+		onTap(item) {
+			// 播放语音
 			if (item.mode == "voice") {
 				this.list.map(e => {
 					this.$set(e, "isPlay", e.id == item.id ? e.isPlay : false);
@@ -156,13 +227,114 @@ export default {
 					item.isPlay = false;
 				}, item.content.duration);
 			}
+		},
+
+		// 刷新列表
+		refresh(params) {
+			// 请求随机值
+			const rd = (this.refreshRd = Math.random());
+
+			// 请求参数
+			const data = {
+				...this.pagination,
+				...params,
+				sessionId: this.session.id,
+				order: "createTime",
+				sort: "desc"
+			};
+
+			// 首页处理
+			if (data.page === 1) {
+				this.loading = true;
+				this.visible = false;
+				this.list = [];
+			}
+
+			// 完成
+			const done = () => {
+				this.loading = false;
+				this.visible = true;
+			};
+
+			this.$service.im.message
+				.page(data)
+				.then(res => {
+					// 防止脏数据
+					if (rd != this.refreshRd) {
+						return false;
+					}
+
+					// 分页信息
+					this.pagination = res.pagination;
+					// 追加数据
+					this.prepend.apply(this, res.list);
+
+					if (data.page === 1) {
+						this.scrollToBottom();
+
+						// 首次滚动隐藏
+						setTimeout(done, 0);
+					} else {
+						done();
+					}
+				})
+				.catch(() => {
+					this.$message.error(err);
+					done();
+				});
+		},
+
+		// 加载更多
+		onLoadmore() {
+			this.refresh({ page: this.pagination.page + 1 });
+		},
+
+		// 滚动到底部
+		scrollToBottom() {
+			this.$nextTick(() => {
+				if (this.$refs["scroller"]) {
+					this.$refs["scroller"].scrollTo({
+						top: 99999,
+						behavior: this.visible ? "smooth" : "auto"
+					});
+				}
+			});
+		},
+
+		// 追加数据到开头
+		prepend(...data) {
+			this.list.unshift(...data.reverse());
+		},
+
+		// 追加数据到结尾
+		append(...data) {
+			this.list.push(...data);
+			this.scrollToBottom();
 		}
 	}
 };
 </script>
 
 <style lang="scss" scoped>
-.chat-box-message {
+.cl-chat-message {
+	height: calc(100% - 5px);
+	overflow: hidden;
+	margin-bottom: 5px;
+
+	&__scroller {
+		height: calc(100% - 10px);
+		border-radius: 5px;
+		margin: 5px 0px 5px 5px;
+		padding: 10px;
+		box-sizing: border-box;
+	}
+
+	&__more {
+		display: flex;
+		justify-content: center;
+		margin-bottom: 20px;
+	}
+
 	&__item {
 		margin-bottom: 20px;
 

+ 241 - 0
src/cool/modules/chat/components/session.vue

@@ -0,0 +1,241 @@
+<template>
+	<div class="cl-chat-session">
+		<div class="cl-chat-session__search">
+			<el-input
+				v-model="keyWord"
+				placeholder="搜索"
+				prefix-icon="el-icon-search"
+				size="small"
+				clearable
+				@clear="onSearch"
+				@keyup.enter.native="onSearch"
+			></el-input>
+		</div>
+
+		<!-- 会话列表 -->
+		<ul class="cl-chat-session__list scroller1" v-loading="loading">
+			<li
+				class="cl-chat-session__item"
+				v-for="(item, index) in list"
+				:key="index"
+				:class="{
+					'is-active': session ? item.id == session.id : false
+				}"
+				@click="toDetail(item)"
+				@contextmenu.stop.prevent="openCM($event, item.id, index)"
+			>
+				<!-- 头像 -->
+				<div class="avatar">
+					<el-badge
+						:value="item.serviceUnreadCount"
+						:hidden="item.serviceUnreadCount === 0"
+						:max="99"
+					>
+						<img :src="item.headimgurl" alt="" />
+					</el-badge>
+				</div>
+
+				<!-- 昵称,内容 -->
+				<div class="det">
+					<p class="name">{{ item.nickname }}</p>
+					<p class="content">{{ item.lastMessage }}</p>
+				</div>
+			</li>
+		</ul>
+	</div>
+</template>
+
+<script>
+import { mapGetters } from "vuex";
+import { parseContent } from "../utils";
+import eventBus from "../utils/event-bus";
+import { ContextMenu } from "cl-admin-crud";
+
+export default {
+	data() {
+		return {
+			loading: false,
+			pagination: {
+				page: 1,
+				size: 100,
+				total: 0
+			},
+			keyWord: ""
+		};
+	},
+
+	computed: {
+		...mapGetters(["sessionList", "session"]),
+
+		list() {
+			return this.sessionList
+				.map(e => {
+					const { _text } = parseContent(e);
+					e.lastMessage = _text;
+					return e;
+				})
+				.sort((a, b) => {
+					return a.updateTime < b.updateTime ? 1 : -1;
+				});
+		}
+	},
+
+	created() {
+		eventBus.$on("session-refresh", this.refresh);
+		this.refresh();
+	},
+
+	methods: {
+		// 右键菜单
+		openCM(e, id, index) {
+			ContextMenu.open(e, {
+				list: [
+					{
+						label: "删除",
+						icon: "el-icon-delete",
+						callback: (_, done) => {
+							this.$service.im.session.delete({
+								ids: id
+							});
+
+							this.list.splice(index, 1);
+
+							if (id == this.session.id) {
+								this.toDetail();
+							}
+
+							done();
+						}
+					}
+				]
+			});
+		},
+
+		// 刷新列表
+		refresh(params) {
+			this.loading = true;
+
+			this.$service.im.session
+				.page({
+					...this.pagination,
+					keyWord: this.keyWord,
+					params,
+					order: "updateTime",
+					sort: "desc"
+				})
+				.then(res => {
+					this.$store.commit("SET_SESSION_LIST", res.list);
+					this.pagination = res.pagination;
+				})
+				.catch(err => {
+					this.$message.error(err);
+				})
+				.done(() => {
+					this.loading = false;
+				});
+		},
+
+		// 搜索关键字
+		onSearch() {
+			this.refresh({ page: 1 });
+		},
+
+		// 会话详情
+		toDetail(item) {
+			if (item) {
+				if (!this.session || this.session.id != item.id) {
+					this.$store.commit("SET_SESSION", item);
+				}
+			} else {
+				this.$store.commit("CLEAR_SESSION");
+			}
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.cl-chat-session {
+	height: calc(100% - 10px);
+	width: 250px;
+	margin: 5px 0 5px 5px;
+	border-radius: 5px;
+	background-color: #fff;
+
+	&__search {
+		padding: 10px;
+	}
+
+	&__list {
+		height: calc(100% - 52px);
+		overflow: auto;
+
+		li {
+			display: flex;
+			list-style: none;
+			padding: 10px;
+			border-left: 5px solid #fff;
+
+			.avatar {
+				margin-right: 12px;
+
+				img {
+					display: block;
+					height: 40px;
+					width: 40px;
+					border-radius: 3px;
+					background-color: #eee;
+				}
+
+				.el-badge {
+					&__content {
+						height: 14px;
+						line-height: 14px;
+						padding: 0 4px;
+						background-color: #fa5151;
+						border: 0;
+					}
+				}
+			}
+
+			.det {
+				flex: 1;
+				.name {
+					font-size: 13px;
+					margin-top: 1px;
+				}
+
+				.content {
+					font-size: 12px;
+					margin-top: 5px;
+					color: #666;
+				}
+
+				.name,
+				.content {
+					overflow: hidden;
+					text-overflow: ellipsis;
+					display: -webkit-box;
+					-webkit-box-orient: vertical;
+					-webkit-line-clamp: 1;
+				}
+			}
+
+			&.is-active {
+				background-color: #eee;
+				border-color: $color-primary;
+			}
+
+			&:hover {
+				background-color: #eee;
+				cursor: pointer;
+			}
+		}
+	}
+
+	&__empty {
+		text-align: center;
+		margin-top: 10px;
+	}
+}
+</style>

+ 2 - 1
src/cool/modules/chat/index.js

@@ -1,4 +1,5 @@
 import components from "./components";
 import service from "./service";
+import store from "./store";
 
-export default { components, service };
+export default { components, service, store };

+ 5 - 0
src/cool/modules/chat/store/index.js

@@ -0,0 +1,5 @@
+import session from "./session";
+
+export default {
+	session
+};

+ 44 - 0
src/cool/modules/chat/store/session.js

@@ -0,0 +1,44 @@
+import eventBus from "../utils/event-bus";
+
+export default {
+	state: {
+		list: [],
+		current: null
+	},
+
+	getters: {
+		// 当前会话
+		session: state => state.current,
+		// 会话列表
+		sessionList: state => state.list
+	},
+
+	mutations: {
+		// 设置会话信息
+		SET_SESSION(state, data) {
+			state.current = data;
+			state.current.serviceUnreadCount = 0;
+			eventBus.$emit("message-refresh", { page: 1 });
+		},
+
+		// 清空会话信息
+		CLEAR_SESSION(state) {
+			state.session = null;
+		},
+
+		// 更新会话信息
+		UPDATE_SESSION(state, data) {
+			Object.assign(state.current, data);
+		},
+
+		// 设置会话列表
+		SET_SESSION_LIST(state, data) {
+			state.list = data;
+		},
+
+		// 清空会话列表
+		CLEAR_SESSION_LIST(state) {
+			state.list = [];
+		}
+	}
+};

+ 2 - 0
src/cool/modules/chat/utils/event-bus.js

@@ -0,0 +1,2 @@
+import Vue from "vue";
+export default new Vue();

+ 4 - 0
src/mock/chat.js

@@ -48,6 +48,10 @@ Mock.mock("/im/session/unreadCount", "get", options => {
 	};
 });
 
+Mock.setup({
+	timeout: "500-1000"
+});
+
 Mock.mock("/im/message/page", "post", options => {
 	const data = Mock.mock({
 		"list|20": [