index.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768
  1. <template>
  2. <div
  3. class="container"
  4. :style="{ height: `calc(100% - ${currentBottomHeight}px)` }"
  5. >
  6. <div class="chat-bg"></div>
  7. <!-- 顶部导航 -->
  8. <div class="header-chat">
  9. <svg-icon class="page-icon" name="lf-arrow" @click="goBack" />
  10. <div class="header-title">{{ wsStore.toUserInfo.nickname }}</div>
  11. <svg-icon class="page-icon" name="more" @click="goDetail" />
  12. </div>
  13. <!-- 聊天消息区域 -->
  14. <div class="chat-list" ref="chatListRef">
  15. <div v-for="(item, index) in wsStore.messages" :key="index">
  16. <div class="chat-time">
  17. {{ formatTime(item.timestamp || Date.now()) }}
  18. </div>
  19. <div class="box">
  20. <div
  21. class="list-item"
  22. :class="isSender(item.toUsername) ? '' : 'flex-reverse'"
  23. >
  24. <!-- 头像 -->
  25. <van-image
  26. class="list-img"
  27. :class="isSender(item.toUsername) ? 'mr12' : 'ml12'"
  28. round
  29. :src="item.avatar"
  30. @click="router.push('personal')"
  31. />
  32. <!-- 内容 -->
  33. <div class="list-cont">
  34. <div>{{ item.fromUsername || "匿名用户" }}</div>
  35. <!-- 文本消息 -->
  36. <div class="content" v-if="item.contentType === MSG_TYPE.TEXT">
  37. {{ item.content }}
  38. </div>
  39. <!-- 图片消息 -->
  40. <div
  41. class="img-message"
  42. v-else-if="item.contentType === MSG_TYPE.IMAGE"
  43. >
  44. <van-image
  45. :src="item?.localUrl || IM_PATH + item.url"
  46. style="max-width: 120px; border-radius: 8px"
  47. @click="previewImage(item)"
  48. />
  49. </div>
  50. <!-- 名片消息 -->
  51. <div
  52. class="content card-message"
  53. v-else-if="item.contentType === 3"
  54. >
  55. <div class="card-title">名片</div>
  56. <div class="card-name">{{ item.content }}</div>
  57. </div>
  58. <!-- 录音消息 -->
  59. <div
  60. class="audio-message"
  61. v-else-if="item.contentType === MSG_TYPE.AUDIO"
  62. >
  63. <messageAudio
  64. :src="item?.localUrl || IM_PATH + item.url"
  65. :isSender="isSender(item.toUsername)"
  66. />
  67. </div>
  68. <!-- 其他未知类型 -->
  69. <div class="content" v-else>[未知消息类型]</div>
  70. </div>
  71. </div>
  72. </div>
  73. </div>
  74. </div>
  75. <!-- 输入框 -->
  76. <div class="page-foot">
  77. <div class="flex-box">
  78. <!-- 录音/文字切换按钮 -->
  79. <svg-icon
  80. type="button"
  81. class="page-icon"
  82. :name="voiceMode ? 'keyboard' : 'voice'"
  83. @click="toggleVoiceMode"
  84. />
  85. <!-- 文字输入框 或 按住说话按钮 -->
  86. <template v-if="!voiceMode">
  87. <van-field
  88. rows="1"
  89. type="textarea"
  90. :border="false"
  91. autosize
  92. class="input"
  93. v-model="text"
  94. @focus="onFocus"
  95. placeholder="输入文本"
  96. @keyup.enter="sendMessage"
  97. />
  98. </template>
  99. <template v-else>
  100. <div
  101. class="hold-talk-btn"
  102. @touchstart.prevent.stop="handleTouchStart"
  103. @touchmove.prevent.stop="handleTouchMove"
  104. @touchend.prevent.stop="handleTouchEnd"
  105. >
  106. {{ cancelRecording ? "松开手指,取消发送" : "按住说话" }}
  107. </div>
  108. </template>
  109. <svg-icon
  110. class="page-icon mr12 emoji-toggle"
  111. name="emoji"
  112. @click="toggleAppBox(1)"
  113. />
  114. <svg-icon
  115. class="page-icon tools-toggle"
  116. name="add2"
  117. @click="toggleAppBox(2)"
  118. />
  119. </div>
  120. <!-- 录音状态浮层 -->
  121. <div v-if="recording" class="recording-toast">
  122. <div v-if="cancelRecording" class="cancel-msg">松开手指,取消发送</div>
  123. <div v-else class="send-msg">松开发送,上滑取消</div>
  124. </div>
  125. <!-- 表情面板 -->
  126. <div
  127. class="app-box"
  128. v-show="showEmoji"
  129. :style="{ height: `${appBoxHeight}px` }"
  130. ref="emojiRef"
  131. >
  132. 😀 😃 😄 😁 😆 😅 😂 🤣 😊 😇 🙂 🙃 😉 😌 😍
  133. </div>
  134. <!-- 工具栏面板 -->
  135. <div
  136. class="app-box"
  137. v-show="showTools"
  138. :style="{ height: `${appBoxHeight}px` }"
  139. ref="toolsRef"
  140. >
  141. <div class="tool-btn">
  142. <van-uploader :after-read="afterRead" :before-read="beforeRead">
  143. <svg-icon class="tool-icon" name="tp" />
  144. </van-uploader>
  145. <div>图片</div>
  146. </div>
  147. <div class="tool-btn">
  148. <svg-icon class="tool-icon" name="ps" />
  149. <div>拍摄</div>
  150. </div>
  151. <div class="tool-btn">
  152. <svg-icon
  153. class="tool-icon"
  154. name="yyth"
  155. @click="startAudioOnline(Constant.DIAL_AUDIO_ONLINE)"
  156. />
  157. <div>语音通话</div>
  158. </div>
  159. <div class="tool-btn">
  160. <svg-icon
  161. class="tool-icon"
  162. name="spth"
  163. @click="startAudioOnline(Constant.DIAL_VIDEO_ONLINE)"
  164. />
  165. <div>视频通话</div>
  166. </div>
  167. <div class="tool-btn">
  168. <svg-icon class="tool-icon" name="mp" />
  169. <div>名片</div>
  170. </div>
  171. </div>
  172. </div>
  173. </div>
  174. </template>
  175. <script setup>
  176. import { useRouter, useRoute } from "vue-router";
  177. import { useWebSocketStore } from "@/stores/modules/webSocketStore.js";
  178. import { useWalletStore } from "@/stores/modules/walletStore.js";
  179. import { Keyboard } from "@capacitor/keyboard";
  180. import { Capacitor } from "@capacitor/core";
  181. import { MSG_TYPE, MESSAGE_TYPE_USER } from "@/common/constant/msgType";
  182. import messageAudio from "@/views/im/components/messageAudio/index.vue";
  183. import { showToast, showImagePreview } from "vant";
  184. import { useWebRTCStore } from "@/stores/modules/webrtcStore";
  185. import * as Constant from "@/common/constant/Constant";
  186. const IM_PATH = import.meta.env.VITE_IM_PATH_FIlE;
  187. // 路由 & store
  188. const router = useRouter();
  189. const route = useRoute();
  190. const wsStore = useWebSocketStore();
  191. const walletStore = useWalletStore();
  192. const rtcStore = useWebRTCStore();
  193. // 输入框文本
  194. const text = ref("");
  195. // 状态管理
  196. const keyboardHeight = ref(0);
  197. const showEmoji = ref(false);
  198. const showTools = ref(false);
  199. const appBoxHeight = ref(210);
  200. const chatListRef = ref(null);
  201. const emojiRef = ref(null);
  202. const toolsRef = ref(null);
  203. const isSender = (toUsername) => {
  204. return walletStore.account === toUsername;
  205. };
  206. // 滚动到底部
  207. const scrollToBottom = () => {
  208. nextTick(() => {
  209. if (chatListRef.value) {
  210. const el = chatListRef.value;
  211. el.scrollTop = el.scrollHeight;
  212. }
  213. });
  214. };
  215. watch(
  216. () => wsStore.messages.length,
  217. () => {
  218. scrollToBottom();
  219. }
  220. );
  221. // 平台判断
  222. const isMobile = Capacitor.getPlatform() !== "web";
  223. // 计算当前底部总高度
  224. const currentBottomHeight = computed(() => {
  225. if (keyboardHeight.value > 0) return keyboardHeight.value;
  226. if (showEmoji.value || showTools.value) return appBoxHeight.value;
  227. return 0;
  228. });
  229. // 切换表情/工具面板
  230. const toggleAppBox = async (type) => {
  231. if (isMobile) await Keyboard.hide();
  232. keyboardHeight.value = 0;
  233. if (type === 1) {
  234. showEmoji.value = !showEmoji.value;
  235. showTools.value = false;
  236. } else {
  237. showTools.value = !showTools.value;
  238. showEmoji.value = false;
  239. }
  240. scrollToBottom();
  241. };
  242. // 预览图片
  243. const previewImage = (item) => {
  244. const imageList = wsStore.messages
  245. .filter((m) => m.contentType === MSG_TYPE.IMAGE)
  246. .map((m) => m.localUrl || IM_PATH + m.url);
  247. const index = imageList.findIndex(
  248. (url) => url === (item.localUrl || IM_PATH + item.url)
  249. );
  250. showImagePreview({
  251. images: imageList,
  252. startPosition: index,
  253. });
  254. };
  255. const onFocus = () => {
  256. // 隐藏所有面板
  257. showEmoji.value = false;
  258. showTools.value = false;
  259. if (isMobile) setupKeyboardListeners();
  260. scrollToBottom();
  261. };
  262. // 键盘监听
  263. const setupKeyboardListeners = async () => {
  264. Keyboard.addListener("keyboardWillShow", (info) => {
  265. keyboardHeight.value = info.keyboardHeight;
  266. });
  267. Keyboard.addListener("keyboardWillHide", () => {
  268. keyboardHeight.value = 0;
  269. });
  270. };
  271. // 录音相关状态
  272. const voiceMode = ref(false); // false: 文字输入, true: 语音模式
  273. const recording = ref(false);
  274. const cancelRecording = ref(false);
  275. const startY = ref(0);
  276. let mediaRecorder = null;
  277. let audioChunks = [];
  278. // 切换文字/语音输入模式
  279. const toggleVoiceMode = () => {
  280. voiceMode.value = !voiceMode.value;
  281. // 切换时关闭表情和工具面板,隐藏键盘
  282. showEmoji.value = false;
  283. showTools.value = false;
  284. if (isMobile) Keyboard.hide();
  285. keyboardHeight.value = 0;
  286. scrollToBottom();
  287. };
  288. // 录音事件
  289. const handleTouchStart = async (e) => {
  290. startY.value = e.touches[0].clientY;
  291. cancelRecording.value = false;
  292. recording.value = true;
  293. audioChunks = [];
  294. try {
  295. const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  296. mediaRecorder = new MediaRecorder(stream, {
  297. mimeType: "audio/webm; codecs=opus",
  298. });
  299. mediaRecorder.ondataavailable = (ev) => {
  300. audioChunks.push(ev.data);
  301. };
  302. mediaRecorder.start(1000);
  303. console.log("开始录音");
  304. } catch (err) {
  305. console.error("麦克风权限获取失败", err);
  306. recording.value = false;
  307. }
  308. };
  309. const handleTouchMove = (e) => {
  310. const currentY = e.touches[0].clientY;
  311. if (startY.value - currentY > 50) {
  312. cancelRecording.value = true;
  313. } else {
  314. cancelRecording.value = false;
  315. }
  316. };
  317. const handleTouchEnd = () => {
  318. if (!mediaRecorder) return;
  319. mediaRecorder.stop();
  320. mediaRecorder.stream.getTracks().forEach((t) => t.stop());
  321. recording.value = false;
  322. mediaRecorder.onstop = async () => {
  323. if (cancelRecording.value) {
  324. console.log("录音取消");
  325. return;
  326. }
  327. if (audioChunks.length === 0) return;
  328. const audioBlob = new Blob(audioChunks, { type: "audio/webm" });
  329. const arrayBuffer = await audioBlob.arrayBuffer();
  330. const audioData = new Uint8Array(arrayBuffer);
  331. wsStore.sendMessage({
  332. content: "",
  333. contentType: MSG_TYPE.AUDIO,
  334. messageType: MESSAGE_TYPE_USER,
  335. fileSuffix: "webm",
  336. file: audioData,
  337. });
  338. console.log("语音已发送");
  339. };
  340. };
  341. // 发送消息
  342. const sendMessage = () => {
  343. if (!text.value.trim()) return;
  344. const message = {
  345. content: text.value,
  346. contentType: MSG_TYPE.TEXT, // 1: 文本消息
  347. messageType: MESSAGE_TYPE_USER, // 1: 单聊天
  348. };
  349. wsStore.sendMessage(message);
  350. text.value = "";
  351. scrollToBottom();
  352. };
  353. // 发送图片消息
  354. const afterRead = async (file) => {
  355. const arrayBuffer = await file.file.arrayBuffer();
  356. const message = {
  357. content: text.value, // 如果有文本内容
  358. contentType: MSG_TYPE.IMAGE, // 音频消息类型
  359. messageType: MESSAGE_TYPE_USER, // 单聊消息
  360. fileSuffix: file.type, // 使用webm后缀更准确
  361. file: new Uint8Array(arrayBuffer), // 将Uint8Array转为普通数组
  362. };
  363. wsStore.sendMessage(message);
  364. };
  365. // 图片类型
  366. const beforeRead = (file) => {
  367. const realFile = file.file || file;
  368. const type = realFile.type;
  369. const name = realFile.name || "";
  370. if (type === "image/svg+xml" || name.endsWith(".svg")) {
  371. showToast("不支持上传 SVG 格式的图片");
  372. return false;
  373. }
  374. return true;
  375. };
  376. // 创建呼叫:开启语音电话: 调试用,正式逻辑读src/views/im/hook/messagesHook.js
  377. // ==== 1. 发起语音通话 ====
  378. const startAudioOnline = async (contentType) => {
  379. rtcStore.imSate.videoCallModal = true;
  380. rtcStore.imSate.callAvatar = wsStore.toUserInfo.avatar;
  381. rtcStore.imSate.callName = wsStore.toUserInfo.nickname;
  382. rtcStore.isCaller = true;
  383. wsStore.sendMessage({
  384. messageType: MESSAGE_TYPE_USER, // 单聊消息
  385. contentType,
  386. type: Constant.MESSAGE_TRANS_TYPE,
  387. avatar: walletStore.avatar,
  388. });
  389. };
  390. // 时间格式化
  391. const formatTime = (timestamp) => {
  392. const date = new Date(timestamp);
  393. const h = date.getHours().toString().padStart(2, "0");
  394. const m = date.getMinutes().toString().padStart(2, "0");
  395. return `${h}:${m}`;
  396. };
  397. // 页面生命周期
  398. onMounted(() => {
  399. wsStore.toUserInfo.uuid = route.query.uuid;
  400. wsStore.getMessages({
  401. uuid: walletStore.account,
  402. messageType: 1,
  403. friendUsername: route.query.uuid,
  404. });
  405. scrollToBottom();
  406. document.addEventListener("click", handleClickOutside);
  407. });
  408. onUnmounted(() => {
  409. if (isMobile) Keyboard.removeAllListeners();
  410. });
  411. onBeforeUnmount(() => {
  412. document.removeEventListener("click", handleClickOutside);
  413. });
  414. // 判断是否点击在元素外
  415. const handleClickOutside = (event) => {
  416. const emojiEl = emojiRef.value;
  417. const toolsEl = toolsRef.value;
  418. const target = event.target;
  419. if (
  420. showEmoji.value &&
  421. emojiEl &&
  422. !emojiEl.contains(target) &&
  423. !target.closest(".emoji-toggle")
  424. ) {
  425. showEmoji.value = false;
  426. }
  427. if (
  428. showTools.value &&
  429. toolsEl &&
  430. !toolsEl.contains(target) &&
  431. !target.closest(".tools-toggle")
  432. ) {
  433. showTools.value = false;
  434. }
  435. };
  436. // 页面跳转
  437. const goBack = () => router.push("im");
  438. const goDetail = () => router.push("detail");
  439. </script>
  440. <style lang="less" scoped>
  441. .mr12 {
  442. margin-right: 12px;
  443. }
  444. .ml12 {
  445. margin-left: 12px;
  446. }
  447. .text-right {
  448. text-align: right;
  449. }
  450. .page-icon {
  451. width: 24px;
  452. height: 24px;
  453. flex-shrink: 0;
  454. }
  455. .container {
  456. display: flex;
  457. flex-direction: column;
  458. .chat-bg {
  459. height: 126px;
  460. background: linear-gradient(90deg, @theme-color1 0%, #40a4fb 100%);
  461. position: absolute;
  462. left: 0;
  463. right: 0;
  464. z-index: -1;
  465. }
  466. .header-chat {
  467. padding-top: 56px;
  468. margin: 0 16px;
  469. display: flex;
  470. align-items: center;
  471. color: @theme-color1;
  472. .header-title {
  473. flex: 1;
  474. font-family:
  475. PingFang SC,
  476. PingFang SC;
  477. font-weight: 500;
  478. font-size: 19px;
  479. color: #ffffff;
  480. text-align: center;
  481. }
  482. }
  483. .chat-list {
  484. background: #f7f8fa;
  485. border-radius: 30px 30px 0 0;
  486. flex: 1;
  487. overflow: auto;
  488. margin-top: 20px;
  489. padding: 0 16px 24px;
  490. .chat-time {
  491. font-family:
  492. PingFang SC,
  493. PingFang SC;
  494. font-weight: 400;
  495. font-size: 12px;
  496. color: #8d8d8d;
  497. text-align: center;
  498. margin: 20px 0;
  499. }
  500. .box {
  501. .list-item {
  502. display: flex;
  503. margin-bottom: 24px;
  504. .list-img {
  505. width: 44px;
  506. height: 44px;
  507. flex-shrink: 0;
  508. }
  509. .list-cont {
  510. font-family:
  511. PingFang SC,
  512. PingFang SC;
  513. font-weight: 400;
  514. font-size: 12px;
  515. color: #8d8d8d;
  516. max-width: 70%;
  517. display: flex;
  518. flex-direction: column;
  519. align-items: flex-start;
  520. .business-card {
  521. width: 199px;
  522. height: 93px;
  523. background: #ffffff;
  524. border-radius: 10px;
  525. margin-top: 8px;
  526. padding: 10px;
  527. box-sizing: border-box;
  528. .business-card-cont {
  529. display: flex;
  530. align-items: center;
  531. font-family:
  532. PingFang SC,
  533. PingFang SC;
  534. font-weight: 400;
  535. font-size: 15px;
  536. color: #000000;
  537. }
  538. .line {
  539. height: 1px;
  540. background: #f2f2f2;
  541. margin: 10px 0 6px;
  542. }
  543. .business-card-text {
  544. font-family:
  545. PingFang SC,
  546. PingFang SC;
  547. font-weight: 400;
  548. font-size: 10px;
  549. color: #8d8d8d;
  550. }
  551. }
  552. .content {
  553. background: #ffffff; // 对方消息背景白色
  554. color: #000;
  555. border-radius: 10px;
  556. margin-top: 8px;
  557. padding: 8px 17px;
  558. word-break: break-word;
  559. white-space: pre-wrap;
  560. max-width: 100%;
  561. font-family:
  562. PingFang SC,
  563. PingFang SC;
  564. font-weight: 400;
  565. font-size: 15px;
  566. }
  567. .img-message {
  568. margin-top: 8px;
  569. }
  570. }
  571. }
  572. .withdrawal {
  573. display: flex;
  574. justify-content: center;
  575. margin-bottom: 24px;
  576. .withdrawal-text {
  577. width: 142px;
  578. height: 29px;
  579. line-height: 29px;
  580. box-sizing: border-box;
  581. background: #f2f2f2;
  582. border-radius: 4px;
  583. font-family:
  584. PingFang SC,
  585. PingFang SC;
  586. font-weight: 400;
  587. font-size: 12px;
  588. color: #8d8d8d;
  589. text-align: center;
  590. }
  591. }
  592. .flex-reverse {
  593. flex-direction: row-reverse;
  594. }
  595. .flex-reverse .list-cont {
  596. align-items: flex-end;
  597. .content {
  598. background: #4d71ff; // 自己的消息是蓝色
  599. color: #ffffff; // 白字
  600. }
  601. }
  602. }
  603. }
  604. .chat-list::-webkit-scrollbar {
  605. width: 0;
  606. }
  607. .page-foot {
  608. position: relative;
  609. background-color: #fff;
  610. .flex-box {
  611. padding: 8px 16px;
  612. display: flex;
  613. align-items: center;
  614. box-sizing: border-box;
  615. .input {
  616. flex: 1;
  617. background: #f2f2f2;
  618. border-radius: 17px;
  619. border: 1px solid #d8d8d8;
  620. padding: 6px 16px;
  621. font-weight: 500;
  622. font-size: 15px;
  623. margin: 0 12px;
  624. overflow-y: auto;
  625. }
  626. }
  627. }
  628. }
  629. .app-box {
  630. position: fixed;
  631. bottom: 210px;
  632. left: 0;
  633. right: 0;
  634. background: white;
  635. transition: transform 0.3s ease;
  636. transform: translateY(100%);
  637. display: flex;
  638. flex-wrap: wrap;
  639. padding: 10px 0 0 32px;
  640. &.visible {
  641. transform: translateY(0);
  642. }
  643. .tool-btn {
  644. margin-right: 32px;
  645. display: flex;
  646. flex-direction: column;
  647. align-items: center;
  648. font-family:
  649. PingFang SC,
  650. PingFang SC;
  651. font-weight: 400;
  652. font-size: 12px;
  653. color: #000000;
  654. .tool-icon {
  655. width: 56px;
  656. height: 56px;
  657. margin-bottom: 4px;
  658. }
  659. }
  660. }
  661. .page-foot {
  662. position: relative;
  663. z-index: 10; /* 确保输入框在上层 */
  664. }
  665. .local-video {
  666. position: absolute;
  667. bottom: 20px;
  668. right: 20px;
  669. width: 200px;
  670. height: auto;
  671. border: 2px solid white;
  672. border-radius: 8px;
  673. }
  674. .remote-video {
  675. width: 100%;
  676. height: 100vh;
  677. background: black;
  678. object-fit: cover;
  679. }
  680. .hold-talk-btn {
  681. flex: 1;
  682. text-align: center;
  683. background: #f5f5f5;
  684. padding: 6px 16px;
  685. border-radius: 17px;
  686. color: #666;
  687. font-weight: 500;
  688. font-size: 15px;
  689. user-select: none;
  690. -webkit-user-select: none;
  691. -webkit-touch-callout: none;
  692. margin: 0 12px;
  693. line-height: 24px;
  694. border: 1px solid #f5f5f5;
  695. }
  696. .recording-toast {
  697. position: fixed;
  698. bottom: 120px;
  699. left: 50%;
  700. transform: translateX(-50%);
  701. padding: 12px 20px;
  702. background: rgba(0, 0, 0, 0.75);
  703. color: #fff;
  704. border-radius: 8px;
  705. font-size: 14px;
  706. user-select: none;
  707. -webkit-user-select: none;
  708. -webkit-touch-callout: none;
  709. }
  710. .cancel-msg {
  711. color: #ff4d4f;
  712. }
  713. </style>