cc-comment.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661
  1. <template>
  2. <view>
  3. <view class="c_total">评论 {{ tableTotal }}</view>
  4. <template v-if="dataList && dataList.length">
  5. <view class="c_comment" v-for="(item1, index1) in dataList" :key="item1.id">
  6. <!-- 一级评论 -->
  7. <CommonComp :data="item1" @likeClick="() => likeClick({ item1, index1 })"
  8. @replyClick="() => replyClick({ item1, index1 })"
  9. @deleteClick="() => deleteClick({ item1, index1 })" />
  10. <view class="children_item" v-if="item1.children && item1.children.length">
  11. <!-- 二级评论 -->
  12. <CommonComp v-for="(item2, index2) in item1.childrenShow" :key="item2.id" :data="item2"
  13. :pData="item1" @likeClick="() => likeClick({ item1, index1, item2, index2 })"
  14. @replyClick="() => replyClick({ item1, index1, item2, index2 })"
  15. @deleteClick="() => deleteClick({ item1, index1, item2, index2 })" />
  16. <!-- 展开二级评论 -->
  17. <view class="expand_reply" v-if="expandTxtShow({ item1, index1 })"
  18. @tap="() => expandReplyFun({ item1, index1 })">
  19. <span class="txt"> 展开{{ item1.children.length - item1.childrenShow.length }}条回复 </span>
  20. <uni-icons type="down" size="24" color="#007aff"></uni-icons>
  21. </view>
  22. <!-- 折叠二级评论 -->
  23. <view class="shrink_reply" v-if="shrinkTxtShow({ item1, index1 })"
  24. @tap="() => shrinkReplyFun({ item1, index1 })">
  25. <span class="txt"> 收起回复内容 </span>
  26. <uni-icons type="up" size="24" color="#007aff"></uni-icons>
  27. </view>
  28. </view>
  29. </view>
  30. </template>
  31. <!-- 空盒子 -->
  32. <view class="empty_box" v-else>
  33. <!-- <uni-icons type="chatboxes" size="36" color="#c0c0c0"></uni-icons> -->
  34. <image src="@/static/icon/quexing_01.png" style="width: 380rpx; height: 308rpx;"></image>
  35. <view>
  36. <span class="txt"> 来鼓励一下作者吧~ </span>
  37. <span class="txt click" @click="() => newCommentFun()">说点什么...</span>
  38. </view>
  39. </view>
  40. <!-- 评论弹窗 -->
  41. <uni-popup ref="cPopupRef" type="bottom" @change="popChange">
  42. <view class="c_popup_box">
  43. <view class="reply_text">
  44. <template v-if="Object.keys(replyTemp).length">
  45. <span class="text_aid">回复给</span>
  46. <img class="user_avatar"
  47. :src="replyTemp.item2 ? replyTemp.item2.user_avatar : replyTemp.item1.user_avatar" />
  48. <span
  49. class="text_main">{{ replyTemp.item2 ? replyTemp.item2.user_name : replyTemp.item1.user_name }}</span>
  50. </template>
  51. <span v-else class="text_main">发表新评论</span>
  52. </view>
  53. <view class="content">
  54. <view class="text_area">
  55. <textarea
  56. class="textarea"
  57. v-model="commentValue"
  58. :placeholder="commentPlaceholder"
  59. :focus="focus"
  60. maxlength="300"
  61. auto-height
  62. @focus="onTextareaFocus"
  63. @keydown="handleKeydown"
  64. ></textarea>
  65. <view class="emoji-trigger" @tap="toggleEmojiPanel">
  66. <text class="fa fa-smile-o"></text>
  67. </view>
  68. </view>
  69. <view class="send_btn" @tap="() => sendClick()">发送</view>
  70. </view>
  71. <!-- 表情面板 -->
  72. <view class="emoji-panel" v-if="showEmojiPanel">
  73. <view class="emoji-grid">
  74. <view
  75. class="emoji-item"
  76. v-for="(emoji, index) in emojiList"
  77. :key="index"
  78. @tap="selectEmoji(emoji)"
  79. >
  80. {{ emoji }}
  81. </view>
  82. </view>
  83. </view>
  84. </view>
  85. </uni-popup>
  86. <!-- 删除弹窗 -->
  87. <uni-popup ref="delPopupRef" type="dialog">
  88. <uni-popup-dialog mode="base" title="" content="确定删除这条评论吗?" :before-close="true" @close="delCloseFun"
  89. @confirm="delConfirmFun"></uni-popup-dialog>
  90. </uni-popup>
  91. </view>
  92. </template>
  93. <script>
  94. import CommonComp from "./componets/common";
  95. export default {
  96. components: {
  97. CommonComp
  98. },
  99. props: {
  100. /** 登陆用户信息
  101. * id: number // 登陆用户id
  102. * user_name: number // 登陆用户名
  103. * user_avatar: string // 登陆用户头像地址
  104. */
  105. myInfo: {
  106. type: Object,
  107. default: () => {},
  108. },
  109. /** 文章作者信息
  110. * id: number // 文章作者id
  111. * user_name: number // 文章作者名
  112. * user_avatar: string // 文章作者头像地址
  113. */
  114. userInfo: {
  115. type: Object,
  116. default: () => {},
  117. },
  118. /** 评论列表
  119. * id: number // 评论id
  120. * parent_id: number // 父级评论id
  121. * reply_id: number // 被回复人评论id
  122. * reply_name: string // 被回复人名称
  123. * user_name: string // 用户名
  124. * user_avatar: string // 评论者头像地址
  125. * user_content: string // 评论内容
  126. * is_like: boolean // 是否点赞
  127. * like_count: number // 点赞数统计
  128. * create_time: string // 创建时间
  129. */
  130. tableData: {
  131. type: Array,
  132. default: () => [],
  133. },
  134. // 评论总数
  135. tableTotal: {
  136. type: Number,
  137. default: 0,
  138. },
  139. // 评论删除模式
  140. // bind - 当被删除的一级评论存在回复评论, 那么该评论内容变更显示为[当前评论内容已被移除]
  141. // only - 仅删除当前评论(后端删除相关联的回复评论, 否则总数显示不对)
  142. // all - 删除所有评论包括回复评论
  143. deleteMode: {
  144. type: String,
  145. default: "all",
  146. },
  147. },
  148. data() {
  149. return {
  150. dataList: [], // 渲染数据(前端的格式)
  151. replyTemp: {}, // 回复临时数据
  152. isNewComment: false, // 是否为新评论
  153. focus: false, // 评论弹窗
  154. commentValue: "", // 输入框值
  155. commentPlaceholder: "说点什么...", // 输入框占位符
  156. delTemp: {}, // 删除临时数据
  157. showEmojiPanel: false, // 是否显示表情面板
  158. // emojiList: [
  159. // '😊', '😂', '🤣', '❤️', '😍', '🥰', '😘', '😭',
  160. // '😅', '😉', '🤔', '😤', '😡', '🥺', '😴', '😷',
  161. // '👍', '👎', '👏', '🙌', '🤝', '🙏', '💪', '🎉',
  162. // '✨', '💫', '⭐', '🌟', '💥', '💯', '♥️', '💕'
  163. // ], // 表情列表
  164. emojiList: ['😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '🥲', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥸', '🤩', '🥳', '😏', '😒', '😞', '😔', '😟', '😕', '🙁', '☹️', '😣', '😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡', '🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗', '🤔', '🤭', '🤫', '🤥', '😶', '😐', '😑', '😬', '🙄', '😯', '😦', '😧', '😮', '😲', '🥱', '😴', '🤤', '😪', '😵', '🤐', '🥴', '🤢', '🤮', '🤧', '😷', '🤒', '🤕', '🤑', '🤠'
  165. ]
  166. };
  167. },
  168. watch: {
  169. tableData: {
  170. handler(newVal) {
  171. if (newVal.length !== this.dataList.length) {
  172. this.dataList = this.treeTransForm(newVal);
  173. }
  174. },
  175. deep: true,
  176. immediate: true,
  177. },
  178. },
  179. mounted() {},
  180. methods: {
  181. // 数据转换
  182. treeTransForm(data) {
  183. let newData = JSON.parse(JSON.stringify(data));
  184. let result = [];
  185. let map = {};
  186. newData.forEach((item, i) => {
  187. item.owner = item.user_id === this.myInfo.user_id; // 是否为当前登陆用户
  188. item.author = item.user_id === this.userInfo.user_id; // 是否为作者
  189. map[item.id] = item;
  190. });
  191. newData.forEach((item) => {
  192. let parent = map[item.parent_id];
  193. if (parent) {
  194. (parent.children || (parent.children = [])).push(item); // 所有回复
  195. if (parent.children.length === 1) {
  196. (parent.childrenShow = []).push(item); // 显示的回复
  197. }
  198. } else {
  199. result.push(item);
  200. }
  201. });
  202. return result;
  203. },
  204. // 点赞
  205. setLike(item) {
  206. item.is_like = !item.is_like;
  207. item.like_count = item.is_like ? item.like_count + 1 : item.like_count - 1;
  208. },
  209. likeClick({
  210. item1,
  211. index1,
  212. item2,
  213. index2
  214. }) {
  215. let item = item2 || item1;
  216. let that = this;
  217. this.setLike(item);
  218. this.$emit("likeFun", {
  219. params: item
  220. }, (res) => {
  221. // 请求后端失败, 重置点赞
  222. that.setLike(item);
  223. });
  224. },
  225. // 回复
  226. replyClick({
  227. item1,
  228. index1,
  229. item2,
  230. index2
  231. }) {
  232. this.replyTemp = JSON.parse(JSON.stringify({
  233. item1,
  234. index1,
  235. item2,
  236. index2
  237. }));
  238. this.$refs["cPopupRef"].open();
  239. },
  240. // 发起新评论
  241. newCommentFun() {
  242. this.isNewComment = true;
  243. this.$refs["cPopupRef"].open();
  244. },
  245. // 评论弹窗
  246. popChange(e) {
  247. // 关闭弹窗
  248. if (!e.show) {
  249. this.commentValue = ""; // 清空输入框值
  250. this.replyTemp = {}; // 清空被回复人信息
  251. this.isNewComment = false; // 恢复是否为新评论默认值
  252. this.showEmojiPanel = false; // 隐藏表情面板
  253. }
  254. this.focus = e.show;
  255. },
  256. // 切换表情面板显示状态
  257. toggleEmojiPanel() {
  258. this.showEmojiPanel = !this.showEmojiPanel;
  259. },
  260. // 选择表情
  261. selectEmoji(emoji) {
  262. this.commentValue += emoji;
  263. },
  264. // 输入框获取焦点时
  265. onTextareaFocus() {
  266. // 可以选择不关闭表情面板,让用户同时使用键盘和表情
  267. // this.showEmojiPanel = false;
  268. },
  269. // 发送评论
  270. sendClick({
  271. item1,
  272. index1,
  273. item2,
  274. index2
  275. } = this.replyTemp) {
  276. let item = item2 || item1;
  277. let params = {};
  278. // 新评论
  279. if (this.isNewComment) {
  280. params = {
  281. id: Math.random(), // 评论id
  282. parent_id: null, // 父级评论id
  283. reply_id: null, // 被回复评论id
  284. reply_name: null, // 被回复人名称
  285. };
  286. } else {
  287. // 回复评论
  288. params = {
  289. id: Math.random(), // 评论id
  290. parent_id: item?.parent_id ?? item.id, // 父级评论id
  291. reply_id: item.id, // 被回复评论id
  292. reply_name: item.user_name, // 被回复人名称
  293. };
  294. }
  295. params = {
  296. ...params,
  297. user_id: this.myInfo.user_id, // 用户id
  298. user_name: this.myInfo.user_name, // 用户名
  299. user_avatar: this.myInfo.user_avatar, // 用户头像地址
  300. user_content: this.commentValue, // 用户评论内容
  301. is_like: false, // 是否点赞
  302. like_count: 0, // 点赞数统计
  303. create_time: "刚刚", // 创建时间
  304. owner: true, // 是否为所有者 所有者可以进行删除 管理员默认true
  305. };
  306. uni.showLoading({
  307. title: "正在发送",
  308. mask: true,
  309. });
  310. this.$emit("replyFun", {
  311. params
  312. }, (res) => {
  313. uni.hideLoading();
  314. // 拿到后端返回的id赋值, 因为删除要用到id
  315. params = {
  316. ...params,
  317. id: res.id
  318. };
  319. // 新评论
  320. if (this.isNewComment) {
  321. this.dataList.push(params);
  322. } else {
  323. // 回复
  324. let c_data = this.dataList[index1];
  325. (c_data.children || (c_data.children = [])).push(params);
  326. // 如果已展开所有回复, 那么此时插入children长度会大于childrenShow长度1, 所以就直接展开显示即可
  327. if (c_data.children.length === (c_data.childrenShow || (c_data.childrenShow = [])).length +
  328. 1) {
  329. c_data.childrenShow.push(params);
  330. }
  331. }
  332. this.$emit("update:tableTotal", this.tableTotal + 1);
  333. this.$refs["cPopupRef"].close();
  334. });
  335. },
  336. //删除
  337. deleteClick({
  338. item1,
  339. index1,
  340. item2,
  341. index2
  342. }) {
  343. this.delTemp = JSON.parse(JSON.stringify({
  344. item1,
  345. index1,
  346. item2,
  347. index2
  348. }));
  349. this.$refs["delPopupRef"].open();
  350. },
  351. // 关闭删除弹窗
  352. delCloseFun() {
  353. this.delTemp = {};
  354. this.$refs["delPopupRef"].close();
  355. },
  356. // 确定删除
  357. delConfirmFun({
  358. item1,
  359. index1,
  360. item2,
  361. index2
  362. } = this.delTemp) {
  363. const deleteMode = this.deleteMode;
  364. let c_data = this.dataList[index1];
  365. uni.showLoading({
  366. title: "正在删除",
  367. mask: true,
  368. });
  369. // 删除二级评论
  370. if (index2 >= 0) {
  371. this.$emit("deleteFun", {
  372. params: [c_data.children[index2].id],
  373. mode: deleteMode
  374. }, (res) => {
  375. uni.hideLoading();
  376. this.$emit("update:tableTotal", this.tableTotal - 1);
  377. c_data.children.splice(index2, 1);
  378. c_data.childrenShow.splice(index2, 1);
  379. });
  380. } else {
  381. // 删除一级评论
  382. if (c_data?.children?.length) {
  383. // 如果一级评论包含回复评论
  384. switch (deleteMode) {
  385. case "bind":
  386. // 一级评论内容展示修改为: 当前评论内容已被移除
  387. this.$emit(
  388. "deleteFun", {
  389. params: [c_data.id],
  390. mode: deleteMode,
  391. },
  392. (res) => {
  393. uni.hideLoading();
  394. c_data.user_content = "当前评论内容已被移除";
  395. }
  396. );
  397. break;
  398. case "only":
  399. // 后端自行根据删除的一级评论id, 查找关联的子评论进行删除
  400. this.$emit(
  401. "deleteFun", {
  402. params: [c_data.id],
  403. mode: deleteMode,
  404. },
  405. (res) => {
  406. uni.hideLoading();
  407. this.$emit("update:tableTotal", this.tableTotal - c_data.children.length + 1);
  408. this.dataList.splice(index1, 1);
  409. }
  410. );
  411. break;
  412. default:
  413. // all
  414. // 收集子评论id, 提交给后端统一删除
  415. let delIdArr = [c_data.id];
  416. c_data.children.forEach((_, i) => {
  417. delIdArr.push(_.id);
  418. });
  419. this.$emit("deleteFun", {
  420. params: delIdArr,
  421. mode: deleteMode
  422. }, (res) => {
  423. uni.hideLoading();
  424. this.$emit("update:tableTotal", this.tableTotal - c_data.children.length + 1);
  425. this.dataList.splice(index1, 1);
  426. });
  427. break;
  428. }
  429. } else {
  430. // 一级评论无回复, 直接删除
  431. this.$emit("deleteFun", {
  432. params: [c_data.id],
  433. mode: deleteMode
  434. }, (res) => {
  435. uni.hideLoading();
  436. this.$emit("update:tableTotal", this.tableTotal - 1);
  437. this.dataList.splice(index1, 1);
  438. });
  439. }
  440. }
  441. this.delCloseFun();
  442. },
  443. // 展开评论if
  444. expandTxtShow({
  445. item1,
  446. index1
  447. }) {
  448. return item1.childrenShow?.length && item1.children.length - item1.childrenShow.length;
  449. },
  450. // 展开更多评论
  451. expandReplyFun({
  452. item1,
  453. index1
  454. }) {
  455. let csLen = this.dataList[index1].childrenShow.length;
  456. this.dataList[index1].childrenShow.push(
  457. ...this.dataList[index1].children.slice(csLen, csLen + 6) // 截取5条评论
  458. );
  459. },
  460. // 收起评论if
  461. shrinkTxtShow({
  462. item1,
  463. index1
  464. }) {
  465. return item1.childrenShow?.length >= 2 && item1.children.length - item1.childrenShow.length === 0;
  466. },
  467. // 收起更多评论
  468. shrinkReplyFun({
  469. item1,
  470. index1
  471. }) {
  472. this.dataList[index1].childrenShow = [];
  473. this.dataList[index1].childrenShow.push(
  474. ...this.dataList[index1].children.slice(0, 1) // 截取1条评论
  475. );
  476. },
  477. handleKeydown(event) { // 新增方法
  478. console.log(event.key, event.shiftKey);
  479. if (event.key === 'Enter' && !event.shiftKey) {
  480. event.preventDefault();
  481. this.sendClick();
  482. }
  483. },
  484. },
  485. };
  486. </script>
  487. <style lang="scss" scoped>
  488. @font-face {
  489. font-family: 'FontAwesome';
  490. src: url('https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/fonts/fontawesome-webfont.woff2') format('woff2');
  491. font-weight: normal;
  492. font-style: normal;
  493. }
  494. .fa {
  495. display: inline-block;
  496. font: normal normal normal 14px/1 FontAwesome;
  497. font-size: inherit;
  498. text-rendering: auto;
  499. -webkit-font-smoothing: antialiased;
  500. }
  501. .fa-smile-o:before {
  502. content: "\f118";
  503. }
  504. ////////////////////////
  505. .center {
  506. display: flex;
  507. align-items: center;
  508. }
  509. ////////////////////////
  510. .c_total {
  511. padding: 20rpx 30rpx 0 30rpx;
  512. font-size: 28rpx;
  513. }
  514. .empty_box {
  515. display: flex;
  516. justify-content: center;
  517. align-items: center;
  518. flex-direction: column;
  519. padding: 150rpx 10rpx;
  520. font-size: 28rpx;
  521. .txt {
  522. color: $uni-text-color-disable;
  523. }
  524. .click {
  525. color: $uni-color-primary;
  526. }
  527. }
  528. .c_comment {
  529. padding: 20rpx 30rpx;
  530. font-size: 28rpx;
  531. .children_item {
  532. padding: 10rpx 0rpx 10rpx 0rpx;
  533. margin-top: 10rpx;
  534. margin-left: 80rpx;
  535. background-color: $uni-bg-color-grey;
  536. .expand_reply,
  537. .shrink_reply {
  538. margin-top: 10rpx;
  539. margin-left: 80rpx;
  540. .txt {
  541. font-weight: 600;
  542. color: $uni-color-primary;
  543. }
  544. }
  545. }
  546. }
  547. .c_popup_box {
  548. background-color: #fff;
  549. margin-bottom: 0rpx;
  550. .reply_text {
  551. @extend .center;
  552. padding: 20rpx 20rpx 0 20rpx;
  553. font-size: 26rpx;
  554. .text_aid {
  555. color: $uni-text-color-grey;
  556. margin-right: 5rpx;
  557. }
  558. .user_avatar {
  559. width: 48rpx;
  560. height: 48rpx;
  561. border-radius: 50%;
  562. margin-right: 6rpx;
  563. margin-left: 12rpx;
  564. }
  565. }
  566. .content {
  567. @extend .center;
  568. .text_area {
  569. flex: 1;
  570. padding: 20rpx;
  571. position: relative;
  572. .textarea {
  573. width: 100%;
  574. min-height: 80rpx;
  575. font-size: 28rpx;
  576. color: #333;
  577. background: #f8f8f8;
  578. border: 2rpx solid #eee;
  579. border-radius: 8rpx;
  580. padding: 16rpx 50rpx 16rpx 16rpx;
  581. }
  582. .emoji-trigger {
  583. position: absolute;
  584. right: 30rpx;
  585. bottom: 30rpx;
  586. width: 60rpx;
  587. height: 60rpx;
  588. display: flex;
  589. align-items: center;
  590. justify-content: center;
  591. color: #666;
  592. font-size: 40rpx;
  593. &:active {
  594. opacity: 0.7;
  595. }
  596. }
  597. }
  598. .send_btn {
  599. @extend .center;
  600. justify-content: center;
  601. width: 120rpx;
  602. height: 60rpx;
  603. border-radius: 20rpx;
  604. font-size: 28rpx;
  605. color: #fff;
  606. background-color: $uni-color-primary;
  607. margin-right: 20rpx;
  608. margin-left: 5rpx;
  609. }
  610. }
  611. .emoji-panel {
  612. padding: 20rpx;
  613. background-color: #f8f8f8;
  614. border-top: 2rpx solid #eee;
  615. max-height: 40vh;
  616. overflow-y: scroll;
  617. .emoji-grid {
  618. display: flex;
  619. flex-wrap: wrap;
  620. // overflow-y: scroll;
  621. .emoji-item {
  622. width: 12.5%;
  623. height: 80rpx;
  624. display: flex;
  625. align-items: center;
  626. justify-content: center;
  627. font-size: 40rpx;
  628. &:active {
  629. background-color: rgba(0, 0, 0, 0.1);
  630. border-radius: 8rpx;
  631. }
  632. }
  633. }
  634. }
  635. }
  636. </style>