通用评论

App.js 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  1. import React, { Component } from "react";
  2. import PropTypes from "prop-types";
  3. import { message } from "antd";
  4. import axios from "axios";
  5. import Cookies from "js-cookie";
  6. import intl from "react-intl-universal";
  7. import { ERROR_DEFAULT, LIMIT, COMMENT_TYPE, LANGUAGE_LINK } from "./constant";
  8. import { CommentContext } from "./Comment";
  9. import { isFunction } from "./helper";
  10. import CommentInput from "./components/CommentInput";
  11. import CommentList from "./components/CommentList";
  12. import Editor from "./components/Editor";
  13. import RenderText from "./components/RenderText";
  14. import EditComment from "./components/EditComment/EditComment";
  15. import { SUPPORT_LOCALES, LOCALES_RESPONSE } from "./lang";
  16. import "./App.css";
  17. class App extends Component {
  18. constructor(props) {
  19. super(props);
  20. this.state = {
  21. loading: {},
  22. // oss 配置
  23. oss: {},
  24. // 评论数据
  25. list: [],
  26. page: 1,
  27. total: 0,
  28. // 是否没有更多评论了
  29. isNoMoreComment: false,
  30. initDone: false,
  31. locale: "zh-CN",
  32. editModalVisible: false,
  33. action: "",
  34. replyId: "",
  35. commentId: "",
  36. userId: "",
  37. content: "",
  38. replyPage: 1,
  39. emojiList: []
  40. };
  41. this.handleChangeLoading = this.handleChangeLoading.bind(this);
  42. this.sCreateComment = this.sCreateComment.bind(this);
  43. this.sUpdateComment = this.sUpdateComment.bind(this);
  44. this.sDeleteComment = this.sDeleteComment.bind(this);
  45. this.sCommentFavor = this.sCommentFavor.bind(this);
  46. this.sCreateReply = this.sCreateReply.bind(this);
  47. this.sDeleteReply = this.sDeleteReply.bind(this);
  48. this.errorHandler = this.errorHandler.bind(this);
  49. this.sGetComment = this.sGetComment.bind(this);
  50. this.sReplyFavor = this.sReplyFavor.bind(this);
  51. this.sGetReply = this.sGetReply.bind(this);
  52. this.sUpdateReply = this.sUpdateReply.bind(this);
  53. this.sOssSts = this.sOssSts.bind(this);
  54. this.handleEdit = this.handleEdit.bind(this);
  55. this.handleClose = this.handleClose.bind(this);
  56. }
  57. componentWillMount() {
  58. this.axios = axios;
  59. this.axios.defaults.withCredentials = true;
  60. if (this.props.token) {
  61. this.axios.defaults.headers.common[
  62. "Authorization"
  63. ] = `Bearer ${this.props.token}`;
  64. }
  65. }
  66. componentDidMount() {
  67. this.loadLocales();
  68. this.loadEmoji();
  69. }
  70. /**
  71. * 加载语言包
  72. * 只能根据url或者传入的props来确定加载哪个语言包
  73. * 优先级:传入的props > url
  74. */
  75. loadLocales() {
  76. let { locales: currentLocale } = this.props;
  77. const cookieLang = Cookies.get("lnk_lang");
  78. if (!currentLocale) {
  79. currentLocale =
  80. cookieLang ||
  81. intl.determineLocale({
  82. urlLocaleKey: "lang"
  83. });
  84. }
  85. currentLocale = SUPPORT_LOCALES.find(item => item.value === currentLocale)
  86. ? currentLocale
  87. : "zh-CN";
  88. const version = require("./version.json").hash;
  89. // 使用绝对路径
  90. const languageURL = `${LANGUAGE_LINK}/${currentLocale}.json?v=${version}`;
  91. return fetch(languageURL)
  92. .then(response => {
  93. if (response.status >= 400) {
  94. throw new Error("Bad response from server");
  95. }
  96. return response.json();
  97. })
  98. .then(data => {
  99. // console.log('data: ', data);
  100. intl
  101. .init({
  102. currentLocale,
  103. locales: {
  104. [currentLocale]: data
  105. }
  106. })
  107. .then(() => {
  108. this.setState({ initDone: true, locale: currentLocale });
  109. });
  110. });
  111. }
  112. /**
  113. * 加载表情包
  114. */
  115. loadEmoji() {
  116. this.axios
  117. .get(`${this.props.emojiAPI}/emoticons`)
  118. .then(response => {
  119. const emojiMap = {};
  120. response.data.list.forEach(item => {
  121. emojiMap[item.name] = item.url;
  122. });
  123. window.sessionStorage.setItem("emojiMap", JSON.stringify(emojiMap));
  124. this.setState({ emojiList: response.data.list });
  125. })
  126. .catch(this.errorHandler);
  127. }
  128. handleEdit({ replyId, commentId, userId, content, replyPage }) {
  129. this.setState({
  130. editModalVisible: true,
  131. action: content.replies
  132. ? "comment"
  133. : content.reply
  134. ? "replyToReply"
  135. : "reply",
  136. replyId,
  137. commentId,
  138. userId,
  139. content,
  140. replyPage
  141. });
  142. }
  143. handleClose() {
  144. this.setState({
  145. editModalVisible: false
  146. });
  147. }
  148. error(msg, info = {}) {
  149. if (this.props.showError) {
  150. message.error(msg);
  151. }
  152. if (this.props.onError) {
  153. this.props.onError(msg, info);
  154. }
  155. }
  156. errorHandler(error) {
  157. const { locale } = this.state;
  158. const localResponse = LOCALES_RESPONSE[locale];
  159. if (error.response && error.response.data && error.response.data.msg) {
  160. this.error(localResponse[error.response.data.msg] || ERROR_DEFAULT, {
  161. response: error.response
  162. });
  163. return;
  164. }
  165. this.error(localResponse[error.response.data.msg] || ERROR_DEFAULT, {
  166. response: error.response
  167. });
  168. }
  169. /**
  170. * 改变 loading 状态
  171. * @param {string} key key
  172. * @param {string} value value
  173. */
  174. handleChangeLoading(key, value) {
  175. const { loading } = this.state;
  176. loading[key] = value;
  177. this.setState({ loading });
  178. }
  179. /**
  180. * 获取评论列表
  181. */
  182. sGetComment({ page = 1 } = {}) {
  183. const { pageType } = this.props;
  184. this.handleChangeLoading("sGetComment", true);
  185. const { API, type, businessId, limit } = this.props;
  186. this.axios
  187. .get(
  188. `${API}/comments?type=${type}&business_id=${businessId}&page=${page}&limit=${limit}`
  189. )
  190. .then(response => {
  191. const { list, page, total } = response.data;
  192. if (list) {
  193. let newList = list;
  194. let { list: oldList } = this.state;
  195. if (pageType === "more") {
  196. if (page > 1) {
  197. // 删除临时数据
  198. oldList = oldList.filter(o => !o.isTemporary);
  199. newList = oldList.concat(newList);
  200. }
  201. } else if (pageType === "pagination") {
  202. // TODO 滚动到顶部
  203. window.scrollTo(0, 0);
  204. }
  205. this.setState({
  206. list: newList,
  207. page,
  208. total
  209. });
  210. this.props.onCountChange(total);
  211. } else {
  212. message.info(intl.get("message.noMoreComment"));
  213. this.setState({
  214. isNoMoreComment: true
  215. });
  216. }
  217. })
  218. .catch(this.errorHandler)
  219. .finally(() => {
  220. this.handleChangeLoading("sGetComment", false);
  221. });
  222. }
  223. /**
  224. * 获取更多回复
  225. */
  226. sGetReply({ commentId, page = 1 } = {}) {
  227. this.handleChangeLoading("sGetReply", true);
  228. const { API, limit } = this.props;
  229. this.axios
  230. .get(`${API}/replies?comment_id=${commentId}&page=${page}&limit=${limit}`)
  231. .then(response => {
  232. if (!response.data.list) {
  233. message.info(intl.get("message.noMoreData"));
  234. }
  235. const list = this.state.list.map(item => {
  236. if (item.id === commentId) {
  237. if (!item.replies) item.replies = [];
  238. if (response.data.list) {
  239. if (page === 1) {
  240. // 如果当前页数为第一页,则清空当前所有的 replies
  241. // 并将获取到的 replies 存放在 state
  242. item.replies = response.data.list;
  243. } else {
  244. item.replies = item.replies
  245. .filter(o => !o.isTemporary)
  246. .concat(response.data.list);
  247. // 如果当前页数非第一页,则合并 replies
  248. }
  249. item.reply_count = response.data.total;
  250. item.reply_page = response.data.page;
  251. } else {
  252. item.isNoMoreReply = true;
  253. }
  254. }
  255. return item;
  256. });
  257. this.setState({ list });
  258. })
  259. .catch(this.errorHandler)
  260. .finally(() => {
  261. this.handleChangeLoading("sGetReply", false);
  262. });
  263. }
  264. /**
  265. * 添加评论
  266. * @param {object} {content} comment content
  267. */
  268. sCreateComment({ content } = {}, cb) {
  269. if (!content) return this.error(intl.get("message.notNull"));
  270. this.handleChangeLoading("sCreateComment", true);
  271. const { API, type, businessId, businessUserId } = this.props;
  272. this.axios(`${API}/comments`, {
  273. method: "post",
  274. data: {
  275. type,
  276. business_id: businessId,
  277. business_user_id: businessUserId,
  278. content
  279. },
  280. withCredentials: true
  281. })
  282. .then(response => {
  283. if (this.props.showAlertComment) {
  284. message.success(intl.get("message.success"));
  285. }
  286. if (isFunction(cb)) cb(response.data);
  287. // 将数据写入到 list 中
  288. // 临时插入
  289. // 等到获取数据之后,删除临时数据
  290. const { list, total } = this.state;
  291. list.unshift({
  292. ...response.data,
  293. replies: [],
  294. isTemporary: true // 临时的数据
  295. });
  296. this.setState({ list, total: total + 1 });
  297. this.props.onCountChange(total + 1);
  298. })
  299. .catch(this.errorHandler)
  300. .finally(() => {
  301. this.handleChangeLoading("sCreateComment", false);
  302. });
  303. }
  304. /**
  305. * 删除评论
  306. */
  307. sDeleteComment(commentId) {
  308. this.handleChangeLoading("sDeleteComment", true);
  309. const { API } = this.props;
  310. this.axios(`${API}/comments/${commentId}`, {
  311. method: "delete",
  312. withCredentials: true
  313. })
  314. .then(() => {
  315. const { list, total } = this.state;
  316. const res = list.filter(item => item.id !== commentId);
  317. const deletedItem = list.find(item => item.id === commentId);
  318. this.setState({ list: res, total: total - 1 });
  319. this.props.onDelete(COMMENT_TYPE.COMMENT, deletedItem);
  320. this.props.onCountChange(total - 1);
  321. })
  322. .catch(this.errorHandler)
  323. .finally(() => {
  324. this.handleChangeLoading("sDeleteComment", false);
  325. });
  326. }
  327. /**
  328. * 更新评论
  329. * @param {object} {content} comment content
  330. */
  331. sUpdateComment({ commentId, content }) {
  332. this.handleChangeLoading("sUpdateComment", true);
  333. const { API } = this.props;
  334. this.axios(`${API}/comments/${commentId}`, {
  335. method: "post",
  336. data: {
  337. content
  338. },
  339. withCredentials: true
  340. })
  341. .then(() => {
  342. let { list } = this.state;
  343. list = list.map(it => {
  344. if (it.id === commentId) {
  345. return {
  346. ...it,
  347. content
  348. };
  349. }
  350. return it;
  351. });
  352. this.props.onUpdateComment("comment");
  353. this.setState({ list });
  354. })
  355. .catch(this.errorHandler)
  356. .finally(() => {
  357. this.handleChangeLoading("sUpdateComment", false);
  358. });
  359. }
  360. /**
  361. * 添加回复
  362. * 回复评论/回复回复
  363. * @param {object} data { comment_id, content, [reply_id] }
  364. */
  365. sCreateReply(data, cb) {
  366. if (!data.content) return this.error(intl.get("message.replyNoNull"));
  367. this.handleChangeLoading("sCreateReply", true);
  368. const { API } = this.props;
  369. this.axios(`${API}/replies`, {
  370. method: "post",
  371. data,
  372. withCredentials: true
  373. })
  374. .then(response => {
  375. if (this.props.showAlertReply) {
  376. message.success(intl.get("message.replySuccess"));
  377. }
  378. if (isFunction(cb)) cb(response.data);
  379. // 将数据写入到 list 中
  380. // 临时插入
  381. // 等到获取数据之后,删除临时数据
  382. const list = this.state.list.map(item => {
  383. if (item.id === data.comment_id) {
  384. if (!item.replies) item.replies = [];
  385. item.replies.push({
  386. ...response.data,
  387. isTemporary: true // 临时的数据
  388. });
  389. item.reply_count += 1;
  390. }
  391. return item;
  392. });
  393. this.setState({ list });
  394. })
  395. .catch(this.errorHandler)
  396. .finally(() => {
  397. this.handleChangeLoading("sCreateReply", false);
  398. });
  399. }
  400. /**
  401. * 删除回复
  402. * @param {*} replyId
  403. * @param {*} commentId
  404. */
  405. sDeleteReply(replyId, commentId) {
  406. this.handleChangeLoading("sDeleteReply", true);
  407. const { API } = this.props;
  408. this.axios(`${API}/replies/${replyId}?CommentID=${commentId}`, {
  409. method: "delete",
  410. withCredentials: true
  411. })
  412. .then(() => {
  413. let deletedItem = null;
  414. const list = this.state.list.map(item => {
  415. if (item.id === commentId) {
  416. const replies = item.replies.filter(item => item.id !== replyId);
  417. deletedItem = item.replies.find(item => item.id === replyId);
  418. item.replies = replies;
  419. item.reply_count -= 1;
  420. }
  421. return item;
  422. });
  423. this.setState({ list });
  424. this.props.onDelete(COMMENT_TYPE.REPLY, deletedItem);
  425. })
  426. .catch(this.errorHandler)
  427. .finally(() => {
  428. this.handleChangeLoading("sDeleteReply", false);
  429. });
  430. }
  431. /**
  432. * 更新回复
  433. * 回复评论/回复回复
  434. * @param {object} data { comment_id, content, reply_id }
  435. */
  436. sUpdateReply({ commentId, content, replyId, replyPage }) {
  437. this.handleChangeLoading("sUpdateReply", true);
  438. const { API } = this.props;
  439. this.axios(`${API}/replies/${replyId}`, {
  440. method: "post",
  441. data: {
  442. comment_id: commentId,
  443. content
  444. },
  445. withCredentials: true
  446. })
  447. .then(() => {
  448. for (let i = 1; i <= replyPage; i++) {
  449. this.sGetReply({ commentId, page: i });
  450. }
  451. this.props.onUpdateComment("reply");
  452. })
  453. .catch(this.errorHandler)
  454. .finally(() => {
  455. this.handleChangeLoading("sUpdateReply", false);
  456. });
  457. }
  458. /**
  459. * 评论 点赞/取消点赞
  460. * @param {string} commentId { commentId }
  461. * @param {boolean} favored 是否已经点过赞
  462. */
  463. sCommentFavor(commentId, favored) {
  464. this.handleChangeLoading("sCommentFavor", true);
  465. const { API } = this.props;
  466. this.axios(`${API}/comments/${commentId}/favor`, {
  467. method: favored ? "delete" : "put",
  468. withCredentials: true
  469. })
  470. .then(response => {
  471. if (this.props.showAlertFavor) {
  472. message.success(
  473. favored
  474. ? intl.get("message.cancelLickSuccess")
  475. : intl.get("message.likeSuccess")
  476. );
  477. }
  478. // 更新 list 中的该项数据的 favored
  479. const list = this.state.list.map(item => {
  480. if (item.id === commentId) {
  481. item.favored = !favored;
  482. item.favor_count += favored ? -1 : 1;
  483. }
  484. return item;
  485. });
  486. this.setState({ list });
  487. })
  488. .catch(this.errorHandler)
  489. .finally(() => {
  490. this.handleChangeLoading("sCommentFavor", false);
  491. });
  492. }
  493. /**
  494. * 回复 点赞/取消点赞
  495. * @param {string} replyId replyId
  496. * @param {string} commentId commentId
  497. * @param {boolean} favored 是否已经点过赞
  498. */
  499. sReplyFavor(replyId, commentId, favored) {
  500. this.handleChangeLoading("sReplyFavor", true);
  501. const { API } = this.props;
  502. this.axios(`${API}/replies/${replyId}/favor`, {
  503. method: favored ? "delete" : "put",
  504. data: {
  505. comment_id: commentId
  506. },
  507. withCredentials: true
  508. })
  509. .then(response => {
  510. message.success(
  511. favored
  512. ? intl.get("message.cancelLickSuccess")
  513. : intl.get("message.likeSuccess")
  514. );
  515. // 更新 list 中的该项数据的 favored
  516. const list = this.state.list.map(item => {
  517. if (item.id === commentId) {
  518. item.replies = item.replies.map(r => {
  519. if (r.id === replyId) {
  520. r.favored = !favored;
  521. // r.favor_count = response.data.favor_count;
  522. // 点赞数 +1,而不是使用后端返回的点赞数
  523. // 不然如果返回的不是增加 1,用户可能以为程序错误
  524. r.favor_count += favored ? -1 : 1;
  525. }
  526. return r;
  527. });
  528. }
  529. return item;
  530. });
  531. this.setState({ list });
  532. })
  533. .catch(this.errorHandler)
  534. .finally(() => {
  535. this.handleChangeLoading("sReplyFavor", false);
  536. });
  537. }
  538. /**
  539. * 获取 OSS 上传的参数
  540. */
  541. sOssSts() {
  542. this.handleChangeLoading("sOssSts", true);
  543. const { API } = this.props;
  544. this.axios
  545. .get(`${API}/oss/sts`)
  546. .then(response => {
  547. this.setState({ oss: { ...response.data } });
  548. })
  549. .catch(this.errorHandler)
  550. .finally(() => {
  551. this.handleChangeLoading("sOssSts", false);
  552. });
  553. }
  554. render() {
  555. // 添加到 Context 的数据
  556. const value = {
  557. ...this.state,
  558. ...this.props,
  559. sCreateComment: this.sCreateComment,
  560. sGetComment: this.sGetComment,
  561. sCommentFavor: this.sCommentFavor,
  562. sReplyFavor: this.sReplyFavor,
  563. sCreateReply: this.sCreateReply,
  564. sGetReply: this.sGetReply,
  565. sOssSts: this.sOssSts,
  566. sDeleteComment: this.sDeleteComment,
  567. sDeleteReply: this.sDeleteReply,
  568. sUpdateReply: this.sUpdateReply,
  569. sUpdateComment: this.sUpdateComment,
  570. handleEdit: this.handleEdit
  571. };
  572. return (
  573. this.state.initDone && (
  574. <CommentContext.Provider value={value}>
  575. <div className="comment">
  576. {this.props.showEditor && (
  577. <CommentInput content={this.props.children} />
  578. )}
  579. {this.props.showList && (
  580. <div style={{ marginTop: 20 }}>
  581. <CommentList />
  582. </div>
  583. )}
  584. </div>
  585. {this.state.editModalVisible && (
  586. <EditComment
  587. visible={this.state.editModalVisible}
  588. action={this.state.action}
  589. replyId={this.state.replyId}
  590. replyPage={this.state.replyPage}
  591. commentId={this.state.commentId}
  592. userId={this.state.content.user_id}
  593. content={this.state.content}
  594. handleClose={this.handleClose}
  595. />
  596. )}
  597. </CommentContext.Provider>
  598. )
  599. );
  600. }
  601. }
  602. App.propTypes = {
  603. type: PropTypes.number.isRequired, // 评论的 type
  604. businessId: PropTypes.string.isRequired, // 评论的 business_id
  605. businessUserId: PropTypes.number,
  606. API: PropTypes.string, // 评论的 API 前缀
  607. showList: PropTypes.bool, // 是否显示评论列表
  608. showEditor: PropTypes.bool, // 是否显示评论输入框
  609. showAlertComment: PropTypes.bool, // 评论成功之后,是否通过 Antd 的 Message 组件进行提示
  610. showAlertReply: PropTypes.bool, // 回复成功之后,是否通过 Antd 的 Message 组件进行提示
  611. showAlertFavor: PropTypes.bool, // 点赞/取消点赞成功之后,是否通过 Antd 的 Message 组件进行提示
  612. showError: PropTypes.bool, // 是否使用Antd的Message组件提示错误信息
  613. onError: PropTypes.func, // 错误回调, 出错了会被调用
  614. userId: PropTypes.number, // 用户id, comment内部不维护用户id, 调用组件时传递过来, 目前用于判断是否显示删除按钮
  615. pageType: PropTypes.string, // 分页类型
  616. page: PropTypes.number, // 页码
  617. limit: PropTypes.number, // 一次加载评论数量
  618. onPageChange: PropTypes.func, // 页码变化回调
  619. onGetMoreBtnClick: PropTypes.func, // 点击查看更多按钮回调
  620. onDelete: PropTypes.func,
  621. onUpdateComment: PropTypes.func,
  622. locales: PropTypes.string, // 传入的语言环境, en-US/zh-CN
  623. onCountChange: PropTypes.func // 评论数量变更时的回调
  624. };
  625. App.defaultProps = {
  626. businessUserId: 0,
  627. API: "//api.links123.net/comment/v1",
  628. emojiAPI: "//api.links123.net/link/v1",
  629. LOGINLINK:
  630. process.env.RUN_MOD === "production"
  631. ? "https://passport.links123.com/login"
  632. : "http://test.links123.net:5050/login",
  633. showList: true,
  634. showEditor: true,
  635. showAlertComment: false,
  636. showAlertReply: false,
  637. showAlertFavor: false,
  638. showError: true,
  639. showEdit: false,
  640. pageType: "more",
  641. limit: LIMIT,
  642. onGetMoreBtnClick: () => {},
  643. onPageChange: page => {},
  644. onDelete: () => {},
  645. onUpdateComment: () => {},
  646. onBeforeUpdateComment: () => {},
  647. onCountChange: () => {}
  648. };
  649. export { Editor, RenderText };
  650. export default App;