box.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807
  1. <template>
  2. <div class="chat-wrap">
  3. <!-- 聊天窗口 -->
  4. <cl-dialog :visible.sync="visible" v-bind="conf">
  5. <div class="chat-box">
  6. <!-- 会话区域 -->
  7. <div class="chat-box__session">
  8. <div class="chat-box__session-search">
  9. <el-input
  10. v-model="session.keyWord"
  11. placeholder="搜索"
  12. prefix-icon="el-icon-search"
  13. size="small"
  14. clearable
  15. @clear="onSearch"
  16. @keyup.enter.native="onSearch"
  17. ></el-input>
  18. </div>
  19. <!-- 会话列表 -->
  20. <ul class="chat-box__session-list scroller1">
  21. <li
  22. class="chat-box__session-item"
  23. v-for="(item, index) in sessionList"
  24. :key="index"
  25. :class="{
  26. 'is-active': session.current ? item.id == session.current.id : false
  27. }"
  28. @click="sessionDetail(item)"
  29. @contextmenu.stop.prevent="openSessionCM($event, item.id, index)"
  30. >
  31. <!-- 头像 -->
  32. <div class="avatar">
  33. <el-badge
  34. :value="item.serviceUnreadCount"
  35. :hidden="item.serviceUnreadCount === 0"
  36. :max="99"
  37. >
  38. <img :src="item.headimgurl" alt="" />
  39. </el-badge>
  40. </div>
  41. <!-- 昵称,内容 -->
  42. <div class="det">
  43. <p class="name">{{ item.nickname }}</p>
  44. <p class="content">{{ item.lastMessage }}</p>
  45. </div>
  46. </li>
  47. </ul>
  48. </div>
  49. <!-- 会话详情 -->
  50. <div class="chat-box__detail">
  51. <template v-if="session.current">
  52. <div
  53. class="chat-box__detail-container scroller1"
  54. ref="scroller"
  55. v-loading="message.loading"
  56. >
  57. <!-- 加载更多 -->
  58. <div class="chat-box__detail-more" v-if="message.list.length > 0">
  59. <el-button
  60. round
  61. size="mini"
  62. :loading="message.loading"
  63. @click="onLoadmore"
  64. >加载更多</el-button
  65. >
  66. </div>
  67. <!-- 消息列表 -->
  68. <message :list="message.list" />
  69. </div>
  70. <div class="chat-box__detail-footer">
  71. <!-- 工具栏 -->
  72. <div class="chat-box__opbar">
  73. <ul>
  74. <!-- 表情 -->
  75. <li>
  76. <el-popover
  77. v-model="emoji.visible"
  78. placement="top-start"
  79. width="470"
  80. trigger="click"
  81. >
  82. <emoji @select="onEmojiSelect" />
  83. <img
  84. slot="reference"
  85. src="../static/images/emoji.png"
  86. alt=""
  87. />
  88. </el-popover>
  89. </li>
  90. <!-- 图片上传 -->
  91. <li>
  92. <cl-upload
  93. accept="image/*"
  94. list-type
  95. :on-success="onImageSelect"
  96. >
  97. <img src="../static/images/image.png" alt="" />
  98. </cl-upload>
  99. </li>
  100. <!-- 视频上传 -->
  101. <li>
  102. <cl-upload
  103. accept="video/*"
  104. list-type
  105. :before-upload="
  106. (f) => {
  107. onBeforeUpload(f, 'video');
  108. }
  109. "
  110. :on-progress="onUploadProgress"
  111. :on-success="
  112. (r, f) => {
  113. onUploadSuccess(r, f, 'video');
  114. }
  115. "
  116. >
  117. <img src="../static/images/video.png" alt="" />
  118. </cl-upload>
  119. </li>
  120. </ul>
  121. </div>
  122. <!-- 输入框,发送按钮 -->
  123. <div class="chat-box__input">
  124. <el-input
  125. v-model="message.value"
  126. placeholder="请描述您想咨询的问题"
  127. type="textarea"
  128. :rows="5"
  129. @keyup.enter.native="onTextSend"
  130. ></el-input>
  131. <el-button
  132. type="primary"
  133. size="mini"
  134. :disabled="!message.value"
  135. @click="onTextSend"
  136. >发送</el-button
  137. >
  138. </div>
  139. </div>
  140. </template>
  141. </div>
  142. </div>
  143. </cl-dialog>
  144. <!-- MP3 -->
  145. <div class="mp3">
  146. <audio style="display: none" ref="sound" src="../static/notify.mp3" controls></audio>
  147. </div>
  148. </div>
  149. </template>
  150. <script>
  151. import dayjs from "dayjs";
  152. import io from "socket.io-client";
  153. import { isString, debounce } from "cl-admin/utils";
  154. import { mapGetters } from "vuex";
  155. import { socketUrl } from "@/config/env";
  156. import Emoji from "./emoji";
  157. import Message from "./message";
  158. import { parseContent } from "../utils";
  159. // 消息模式
  160. const MODES = ["text", "image", "emoji", "voice", "video"];
  161. export default {
  162. name: "cl-chat",
  163. components: {
  164. Message,
  165. Emoji
  166. },
  167. data() {
  168. return {
  169. visible: false,
  170. conf: {
  171. title: "聊天对话框",
  172. props: {
  173. modal: true,
  174. "custom-class": "chat-box__wrap",
  175. "append-to-body": true,
  176. "close-on-click-modal": false,
  177. width: "1000px"
  178. }
  179. },
  180. message: {
  181. list: [],
  182. pagination: {
  183. page: 1,
  184. size: 20,
  185. total: 0
  186. },
  187. loading: false,
  188. value: ""
  189. },
  190. session: {
  191. list: [],
  192. pagination: {
  193. page: 1,
  194. size: 100,
  195. total: 0
  196. },
  197. current: null,
  198. keyWord: ""
  199. },
  200. emoji: {
  201. visible: false
  202. },
  203. socket: null
  204. };
  205. },
  206. computed: {
  207. ...mapGetters(["userInfo", "token"]),
  208. sessionList() {
  209. return this.session.list
  210. .map((e) => {
  211. let { _text } = parseContent(e);
  212. e.lastMessage = _text;
  213. return e;
  214. })
  215. .sort((a, b) => {
  216. return a.updateTime < b.updateTime ? 1 : -1;
  217. });
  218. }
  219. },
  220. mounted() {
  221. this.socket = io(`${socketUrl}?isAdmin=true&token=${this.token}`);
  222. this.socket.on("connect", () => {
  223. console.log("socket connect");
  224. });
  225. this.socket.on("admin", (msg) => {
  226. this.onMessage(msg);
  227. });
  228. this.socket.on("error", (err) => {
  229. console.log(err);
  230. });
  231. this.socket.on("disconnect", () => {
  232. console.log("disconnect connect");
  233. });
  234. },
  235. destroyed() {
  236. this.socket.close();
  237. },
  238. methods: {
  239. open() {
  240. this.visible = true;
  241. this.refreshSession().then((res) => {
  242. this.sessionDetail(res.list[0]);
  243. });
  244. },
  245. close() {
  246. this.visible = false;
  247. },
  248. // 上传前
  249. onBeforeUpload(file, key) {
  250. const data = {
  251. content: {
  252. [`${key}Url`]: ""
  253. },
  254. type: 0,
  255. contentType: MODES.indexOf(key),
  256. uid: file.uid,
  257. loading: true,
  258. progress: "0%"
  259. };
  260. this.append(data);
  261. },
  262. // 上传中
  263. onUploadProgress(e, file) {
  264. let item = this.message.list.find((e) => e.uid == file.uid);
  265. if (item) {
  266. item.progress = e.percent + "%";
  267. }
  268. },
  269. // 上传成功
  270. onUploadSuccess(res, file, key) {
  271. let item = this.message.list.find((e) => e.uid == file.uid);
  272. if (item) {
  273. item.loading = false;
  274. item.content[`${key}Url`] = res.data;
  275. this.sendMessage(item);
  276. }
  277. },
  278. // 打开会话列表右键菜单
  279. openSessionCM(e, id, index) {
  280. this.$crud.openContextMenu(e, {
  281. list: [
  282. {
  283. label: "删除",
  284. icon: "el-icon-delete",
  285. callback: (_, done) => {
  286. this.$service.im.session.delete({
  287. ids: id
  288. });
  289. this.session.list.splice(index, 1);
  290. if (id == this.session.current.id) {
  291. this.sessionDetail();
  292. }
  293. done();
  294. }
  295. }
  296. ]
  297. });
  298. },
  299. // 刷新会话列表
  300. refreshSession(params) {
  301. return this.$service.im.session
  302. .page({
  303. ...this.session.pagination,
  304. keyWord: this.session.keyWord,
  305. params,
  306. order: "updateTime",
  307. sort: "desc"
  308. })
  309. .then(async (res) => {
  310. this.session.list = res.list;
  311. this.session.pagination = res.pagination;
  312. return res;
  313. });
  314. },
  315. // 刷新详情
  316. async sessionDetail(item) {
  317. if (item) {
  318. let { id } = this.session.current || {};
  319. if (id != item.id) {
  320. item.serviceUnreadCount = 0;
  321. this.conf.title = `与${item.nickname}聊天中`;
  322. this.message.loading = true;
  323. this.message.list = [];
  324. this.session.current = item;
  325. await this.refreshMessage({ page: 1 });
  326. this.message.loading = false;
  327. }
  328. this.scrollToBottom();
  329. } else {
  330. this.conf.title = "聊天对话框";
  331. this.message.list = [];
  332. this.session.current = null;
  333. }
  334. },
  335. // 刷新消息列表
  336. refreshMessage(params) {
  337. return this.$service.im.message
  338. .page({
  339. ...this.message.pagination,
  340. ...params,
  341. sessionId: this.session.current.id,
  342. order: "createTime",
  343. sort: "desc"
  344. })
  345. .then((res) => {
  346. this.message.pagination = res.pagination;
  347. this.prepend.apply(this, res.list);
  348. });
  349. },
  350. // 更新会话消息
  351. updateSession(data) {
  352. Object.assign(this.session.current, data);
  353. },
  354. // 搜索关键字
  355. onSearch() {
  356. this.refreshSession({ page: 1 });
  357. },
  358. // 加载更多
  359. onLoadmore() {
  360. this.refreshMessage({ page: this.message.pagination.page + 1 });
  361. },
  362. // 滚动到底部
  363. scrollToBottom: debounce(function () {
  364. this.$nextTick(() => {
  365. if (this.$refs["scroller"]) {
  366. this.$refs["scroller"].scrollTo(0, 999999);
  367. }
  368. });
  369. }, 300),
  370. // 发送文本内容
  371. onTextSend() {
  372. if (this.message.value) {
  373. if (this.message.value.replace(/\n/g, "") !== "") {
  374. const data = {
  375. type: 0,
  376. contentType: 0,
  377. content: {
  378. text: this.message.value
  379. }
  380. };
  381. this.append(data);
  382. this.sendMessage(data);
  383. this.$nextTick(() => {
  384. this.message.value = "";
  385. });
  386. }
  387. }
  388. },
  389. // 图片选择
  390. onImageSelect(res) {
  391. const data = {
  392. content: {
  393. imageUrl: res.data
  394. },
  395. type: 0,
  396. contentType: 1
  397. };
  398. this.append(data);
  399. this.sendMessage(data);
  400. },
  401. // 表情选择
  402. onEmojiSelect(url) {
  403. this.emoji.visible = false;
  404. const data = {
  405. content: {
  406. imageUrl: url
  407. },
  408. type: 0,
  409. contentType: 2
  410. };
  411. this.append(data);
  412. this.sendMessage(data);
  413. },
  414. // 视频选择
  415. onVideoSelect(url) {
  416. const data = {
  417. content: {
  418. videoUrl: url
  419. },
  420. type: 0,
  421. contentType: 4
  422. };
  423. this.append(data);
  424. this.sendMessage(data);
  425. },
  426. // 监听消息
  427. onMessage(msg) {
  428. // 回调
  429. this.$emit("message", this.visible);
  430. // 消息通知
  431. this.notification(msg);
  432. try {
  433. const { contentType, fromId, content, msgId } = JSON.parse(msg);
  434. // 是否当前
  435. const same = this.session.current && this.session.current.userId == fromId;
  436. if (same) {
  437. // 更新消息
  438. this.updateSession({
  439. contentType,
  440. content
  441. });
  442. // 追加消息
  443. this.append({
  444. contentType,
  445. content: JSON.parse(content),
  446. type: 1
  447. });
  448. // 读消息
  449. this.$service.im.message.read({
  450. ids: [msgId],
  451. session: this.session.current.id
  452. });
  453. }
  454. // 查找会话
  455. let item = this.session.list.find((e) => e.userId == fromId);
  456. if (item) {
  457. if (!same) {
  458. item.serviceUnreadCount += 1;
  459. }
  460. // 更新消息
  461. Object.assign(item, {
  462. updateTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
  463. contentType,
  464. content
  465. });
  466. } else {
  467. // 刷新会话列表
  468. this.refreshSession();
  469. }
  470. } catch (e) {
  471. console.error("消息格式异常", e);
  472. }
  473. },
  474. // 消息通知
  475. notification(msg) {
  476. const { _text } = parseContent(JSON.parse(msg));
  477. // 播放音乐
  478. if (this.$refs.sound) {
  479. this.$refs.sound.play();
  480. }
  481. if (!this.visible) {
  482. // 页面消息提示
  483. this.$notify({
  484. title: "提示",
  485. message: this.$createElement("span", _text)
  486. });
  487. // 浏览器消息通知
  488. const NotificationInstance = Notification || window.Notification;
  489. if (!!NotificationInstance) {
  490. if (NotificationInstance.permission !== "denied") {
  491. NotificationInstance.requestPermission((status) => {
  492. let n = new Notification("COOL-MALL", {
  493. body: _text,
  494. icon: "/favicon.ico"
  495. });
  496. setTimeout(() => {
  497. n.close();
  498. }, 2000);
  499. });
  500. }
  501. }
  502. }
  503. },
  504. // 发送消息
  505. sendMessage({ contentType, content }) {
  506. const { id, userId } = this.session.current;
  507. // 更新消息
  508. this.updateSession({
  509. contentType,
  510. content
  511. });
  512. this.socket.emit(`user@${userId}`, {
  513. contentType,
  514. type: 0,
  515. content: JSON.stringify(content),
  516. sessionId: id
  517. });
  518. },
  519. /**
  520. * 处理消息数据
  521. * mode: 消息模式
  522. * type: 消息类型 0-回复,1-反馈
  523. * duration: 时常
  524. * videoUrl: 视频地址
  525. * videoCoverUrl: 视频封面
  526. * imageUrl: 图片地址
  527. * avatarUrl: 头像地址
  528. * nickName: 昵称
  529. */
  530. handleMessage(e) {
  531. if (isString(e)) {
  532. e = JSON.parse(e);
  533. }
  534. if (isString(e.content)) {
  535. e.content = JSON.parse(e.content);
  536. }
  537. // 昵称
  538. const nickName = e.type == 0 ? this.userInfo.nickName : this.session.current.nickname;
  539. // 头像
  540. const avatarUrl =
  541. e.type == 0
  542. ? this.userInfo.avatarUrl || require("../static/images/custom-avatar.png")
  543. : this.session.current.headimgurl;
  544. return {
  545. ...e,
  546. avatarUrl,
  547. nickName,
  548. mode: MODES[e.contentType],
  549. date: dayjs().format("YYYY-MM-DD HH:mm:ss")
  550. };
  551. },
  552. // 追加数据到开头
  553. prepend(...data) {
  554. data.map(this.handleMessage).forEach((e) => {
  555. this.message.list.unshift(e);
  556. });
  557. },
  558. // 追加数据到结尾
  559. append(...data) {
  560. this.message.list.push(...data.map(this.handleMessage));
  561. this.scrollToBottom();
  562. }
  563. }
  564. };
  565. </script>
  566. <style lang="scss">
  567. .chat-box__wrap {
  568. height: 650px;
  569. min-width: 1000px;
  570. margin-bottom: 0 !important;
  571. .el-dialog__body {
  572. height: calc(100% - 46px);
  573. padding: 0;
  574. .cl-dialog__container {
  575. height: 100%;
  576. }
  577. }
  578. }
  579. .chat-box {
  580. display: flex;
  581. height: 100%;
  582. background-color: #f7f7f7;
  583. &__session {
  584. height: calc(100% - 10px);
  585. width: 250px;
  586. margin: 5px 0 5px 5px;
  587. border-radius: 5px;
  588. background-color: #fff;
  589. &-search {
  590. padding: 10px;
  591. }
  592. ul {
  593. height: calc(100% - 52px);
  594. overflow: auto;
  595. li {
  596. display: flex;
  597. list-style: none;
  598. padding: 10px;
  599. border-left: 5px solid #fff;
  600. .avatar {
  601. height: 40px;
  602. width: 40px;
  603. margin-right: 12px;
  604. img {
  605. display: block;
  606. height: 100%;
  607. width: 100%;
  608. border-radius: 3px;
  609. background-color: #eee;
  610. }
  611. .el-badge {
  612. &__content {
  613. height: 14px;
  614. line-height: 14px;
  615. padding: 0 4px;
  616. background-color: #fa5151;
  617. border: 0;
  618. }
  619. }
  620. }
  621. .det {
  622. flex: 1;
  623. .name {
  624. font-size: 13px;
  625. margin-top: 1px;
  626. }
  627. .content {
  628. font-size: 12px;
  629. margin-top: 5px;
  630. color: #666;
  631. }
  632. .name,
  633. .content {
  634. @include text_ellipsis(1);
  635. }
  636. }
  637. &.is-active {
  638. background-color: #eee;
  639. border-color: $color-main;
  640. }
  641. &:hover {
  642. background-color: #eee;
  643. cursor: pointer;
  644. }
  645. }
  646. }
  647. }
  648. &__detail {
  649. display: flex;
  650. flex-direction: column;
  651. flex: 1;
  652. height: 100%;
  653. padding: 5px;
  654. box-sizing: border-box;
  655. &-container {
  656. flex: 1;
  657. border-radius: 5px;
  658. padding: 10px;
  659. overflow: auto;
  660. margin-bottom: 5px;
  661. }
  662. &-more {
  663. display: flex;
  664. justify-content: center;
  665. margin-bottom: 20px;
  666. }
  667. &-footer {
  668. background-color: #fff;
  669. padding: 10px;
  670. border-radius: 5px;
  671. }
  672. }
  673. &__message {
  674. flex: 1;
  675. border-radius: 5px;
  676. }
  677. &__opbar {
  678. margin-bottom: 5px;
  679. ul {
  680. display: flex;
  681. li {
  682. list-style: none;
  683. margin-right: 10px;
  684. cursor: pointer;
  685. &:hover {
  686. opacity: 0.7;
  687. }
  688. img {
  689. height: 26px;
  690. width: 26px;
  691. }
  692. }
  693. }
  694. }
  695. &__input {
  696. position: relative;
  697. .el-button {
  698. position: absolute;
  699. right: 10px;
  700. bottom: 10px;
  701. }
  702. }
  703. }
  704. </style>