reviewAppeal.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692
  1. <template>
  2. <div class="review-appeal-container">
  3. <!-- 顶部统计数据 -->
  4. <div class="statistics-section">
  5. <div class="statistics-cards">
  6. <div class="stat-card">
  7. <div class="stat-label">评价总数</div>
  8. <div class="stat-value">
  9. {{ total }}
  10. </div>
  11. </div>
  12. <div class="stat-card">
  13. <div class="stat-label">新增差评数</div>
  14. <div class="stat-value">
  15. {{ statistics.badTextReviews }}
  16. </div>
  17. </div>
  18. <div class="stat-card">
  19. <div class="stat-label">新增好评数</div>
  20. <div class="stat-value">
  21. {{ statistics.badImageReviews }}
  22. </div>
  23. </div>
  24. <div class="stat-card">
  25. <div class="stat-label">差评中评数</div>
  26. <div class="stat-value">
  27. {{ statistics.neutralReviews }}
  28. </div>
  29. </div>
  30. <div class="stat-card">
  31. <div class="stat-label">评论回复率</div>
  32. <div class="stat-value highlight">
  33. {{ statistics.abnormalRate }}
  34. </div>
  35. </div>
  36. </div>
  37. </div>
  38. <!-- 评价列表区域 -->
  39. <div class="review-list-section">
  40. <div class="section-header">
  41. <div class="section-title">
  42. 评价列表 <el-button type="primary" style="float: right" @click="goToAppealHistory"> 申诉历史 </el-button>
  43. </div>
  44. <div class="time-filter">
  45. <span>评论时间:</span>
  46. <el-select v-model="timeFilter" placeholder="请选择" class="time-filter-select" @change="handleTimeFilterChange">
  47. <el-option label="全部" value="" />
  48. <el-option label="30天" value="30" />
  49. </el-select>
  50. </div>
  51. <el-tabs v-model="activeTab" @tab-click="handleTabClick">
  52. <el-tab-pane :label="`全部 (${tabCounts.all})`" name="0" />
  53. <el-tab-pane :label="`待回复差评 (${tabCounts.pending})`" name="pending" />
  54. <el-tab-pane :label="`差评 (${tabCounts.bad})`" name="3" />
  55. <el-tab-pane :label="`好评 (${tabCounts.good})`" name="1" />
  56. <el-tab-pane :label="`中评 (${tabCounts.neutral})`" name="2" />
  57. </el-tabs>
  58. </div>
  59. <!-- 评论卡片列表 -->
  60. <div v-if="reviewList.length > 0" class="review-cards">
  61. <div v-for="review in reviewList" :key="review.id" class="review-card">
  62. <div class="review-header">
  63. <div class="user-info">
  64. <el-avatar :src="review.userAvatar" :size="40">
  65. <el-icon><User /></el-icon>
  66. </el-avatar>
  67. <div class="user-details">
  68. <div class="user-name">
  69. {{ review.isAnonymous == 1 || !review.userName ? "匿名用户" : review.userName }}
  70. </div>
  71. <el-rate v-model="review.score" disabled />
  72. </div>
  73. </div>
  74. <div class="review-time">
  75. {{ review.createdTime.replace(/-/g, "/") }}
  76. </div>
  77. </div>
  78. <div class="review-content">
  79. {{ review.commentContent }}
  80. </div>
  81. <div v-for="(itm, idx) in review.storeComment" :key="idx">
  82. <div class="sjhf">商家回复: {{ itm.commentContent }}</div>
  83. </div>
  84. <div v-if="review.images && review.images.length > 0" class="review-images">
  85. <el-image
  86. v-for="(img, index) in review.images"
  87. :key="index"
  88. :src="img"
  89. :preview-src-list="review.images"
  90. fit="cover"
  91. class="review-image"
  92. />
  93. </div>
  94. <div class="review-footer">
  95. <el-button type="warning" link v-if="review.appealStatus == 0"> 审核中 </el-button>
  96. <el-button
  97. type="danger"
  98. link
  99. v-if="review.appealStatus != 0"
  100. :disabled="review.appealFlag == 1 && review.appealStatus != 1 ? true : false"
  101. @click="delReviewAppeal(review)"
  102. >
  103. 申诉删除
  104. </el-button>
  105. <el-button type="primary" link @click="openReplayDialog(review)" v-if="!hasStoreReply(review)"> 回复 </el-button>
  106. </div>
  107. </div>
  108. </div>
  109. <!-- 空状态 -->
  110. <el-empty v-else description="暂无评论数据" />
  111. <!-- 分页 -->
  112. <div v-if="total > 0" class="pagination-wrapper">
  113. <el-pagination
  114. v-model:current-page="pageNum"
  115. v-model:page-size="pageSize"
  116. :page-sizes="[10, 20, 30, 50]"
  117. :total="total"
  118. layout="total, sizes, prev, pager, next, jumper"
  119. background
  120. @size-change="handleSizeChange"
  121. @current-change="handleCurrentChange"
  122. />
  123. </div>
  124. </div>
  125. <!-- 申诉提交对话框 -->
  126. <el-dialog v-model="appealDialogVisible" title="申诉删除" width="600px" @close="closeAppealDialog">
  127. <el-form ref="appealFormRef" :model="appealFormData" :rules="appealFormRules" label-width="100px">
  128. <el-form-item label="申诉原因" prop="reason">
  129. <el-input
  130. v-model="appealFormData.reason"
  131. type="textarea"
  132. :rows="4"
  133. placeholder="请输入申诉原因"
  134. maxlength="300"
  135. show-word-limit
  136. />
  137. </el-form-item>
  138. <el-form-item label="申诉凭证" prop="images">
  139. <el-upload
  140. v-model:file-list="appealFormData.fileList"
  141. list-type="picture-card"
  142. :limit="6"
  143. :http-request="handleUpload"
  144. :on-preview="handlePreview"
  145. :on-remove="handleRemove"
  146. :on-success="handleUploadSuccess"
  147. accept="image/*"
  148. >
  149. <el-icon><Plus /></el-icon>
  150. </el-upload>
  151. </el-form-item>
  152. </el-form>
  153. <template #footer>
  154. <el-button @click="closeAppealDialog"> 取消 </el-button>
  155. <el-button type="primary" @click="submitAppeal"> 确定 </el-button>
  156. </template>
  157. </el-dialog>
  158. <!-- 回复对话框 -->
  159. <el-dialog v-model="replyDialogVisible" title="回复评价" width="600px" @close="closeReplyDialog">
  160. <el-form ref="replyFormRef" :model="replyFormData" :rules="replyFormRules" label-width="100px">
  161. <el-form-item label="回复内容" prop="content">
  162. <el-input
  163. v-model="replyFormData.content"
  164. type="textarea"
  165. :rows="6"
  166. placeholder="请输入回复内容"
  167. maxlength="500"
  168. show-word-limit
  169. />
  170. </el-form-item>
  171. </el-form>
  172. <template #footer>
  173. <el-button @click="closeReplyDialog"> 取消 </el-button>
  174. <el-button type="primary" @click="submitReply"> 提交回复 </el-button>
  175. </template>
  176. </el-dialog>
  177. </div>
  178. </template>
  179. <script setup lang="ts" name="reviewAppeal">
  180. import { ref, reactive, onMounted } from "vue";
  181. import { useRouter } from "vue-router";
  182. import { ElMessage } from "element-plus";
  183. import { User, Plus } from "@element-plus/icons-vue";
  184. import type { FormInstance, FormRules, UploadUserFile } from "element-plus";
  185. import { getList, addAppealNew, saveComment, uploadImg } from "@/api/modules/newLoginApi";
  186. import { localGet } from "@/utils";
  187. import { useUserStore } from "@/stores/modules/user";
  188. const router = useRouter();
  189. const userStore = useUserStore();
  190. // 店铺名称
  191. const storeName = ref("重庆老火锅");
  192. // 统计数据
  193. const statistics = reactive({
  194. totalReviews: 0,
  195. badTextReviews: 0,
  196. badImageReviews: 0,
  197. neutralReviews: 0,
  198. abnormalRate: "0%"
  199. });
  200. // 标签页计数
  201. const tabCounts = reactive({
  202. all: 22,
  203. pending: 2,
  204. bad: 5,
  205. good: 10,
  206. neutral: 7
  207. });
  208. // 当前激活的标签
  209. const activeTab = ref("0");
  210. // 评论时间筛选
  211. const timeFilter = ref("");
  212. // 评论列表
  213. const reviewList = ref<any[]>([]);
  214. // 分页参数
  215. const pageNum = ref(1);
  216. const pageSize = ref(10);
  217. const total = ref(0);
  218. // 申诉提交对话框
  219. const appealDialogVisible = ref(false);
  220. const appealFormRef = ref<FormInstance>();
  221. const currentReviewId = ref("");
  222. const appealFormData = reactive({
  223. reason: "",
  224. images: [] as string[],
  225. files: [] as File[], // 保存原始的 File 对象
  226. fileList: [] as UploadUserFile[]
  227. });
  228. const hasStoreReply = (review: any) => {
  229. console.log(review);
  230. return review.storeComment && review.storeComment.length > 0;
  231. };
  232. const appealFormRules = reactive<FormRules>({
  233. reason: [{ required: true, message: "请输入申诉原因", trigger: "blur" }],
  234. images: [{ required: true, message: "请上传申诉凭证", trigger: "blur" }]
  235. });
  236. // 回复对话框
  237. const replyDialogVisible = ref(false);
  238. const replyFormRef = ref<FormInstance>();
  239. const currentReplyReview = ref<any>(null);
  240. const replyFormData = reactive({
  241. content: ""
  242. });
  243. const replyFormRules = reactive<FormRules>({
  244. content: [{ required: true, message: "请输入回复内容", trigger: "blur" }]
  245. });
  246. // 标签页切换
  247. const handleTabClick = (tab: any) => {
  248. // 获取点击的标签页的 name 值
  249. const tabName = tab.paneName || tab.props?.name || activeTab.value;
  250. loadReviewList(tabName, true);
  251. };
  252. // 跳转到申诉历史
  253. const goToAppealHistory = () => {
  254. router.push("/dynamicManagement/reviewAppealHistory");
  255. };
  256. // 评论时间筛选变化
  257. const handleTimeFilterChange = () => {
  258. loadReviewList(activeTab.value, true);
  259. };
  260. // 加载统计数据(只在初始化时调用一次)
  261. const loadStatistics = async () => {
  262. try {
  263. const baseParams = {
  264. pageNum: 1,
  265. pageSize: 1,
  266. phoneId: `store_${localGet("geeker-user").userInfo.phone}`,
  267. businessType: 5,
  268. days: timeFilter.value,
  269. replyStatus: 0,
  270. storeId: localGet("createdId"),
  271. userType: 0
  272. };
  273. // 并行请求各个评论等级的数量
  274. const [allRes, goodRes, neutralRes, badRes, pendingRes]: any[] = await Promise.all([
  275. getList({ ...baseParams, commentLevel: 0 }), // 全部
  276. getList({ ...baseParams, commentLevel: 1 }), // 好评
  277. getList({ ...baseParams, commentLevel: 2 }), // 中评
  278. getList({ ...baseParams, commentLevel: 3 }), // 差评
  279. getList({ ...baseParams, commentLevel: 3, replyStatus: 2 }) // 待回复差评
  280. ]);
  281. // 更新标签页计数
  282. tabCounts.all = allRes?.code === 200 ? allRes.data?.total || 0 : 0;
  283. tabCounts.good = goodRes?.code === 200 ? goodRes.data?.total || 0 : 0;
  284. tabCounts.neutral = neutralRes?.code === 200 ? neutralRes.data?.total || 0 : 0;
  285. tabCounts.bad = badRes?.code === 200 ? badRes.data?.total || 0 : 0;
  286. tabCounts.pending = pendingRes?.code === 200 ? pendingRes.data?.total || 0 : 0;
  287. // 更新统计数据
  288. statistics.totalReviews = tabCounts.all;
  289. statistics.badTextReviews = tabCounts.bad; // 新增差评数
  290. statistics.badImageReviews = tabCounts.good; // 新增好评数
  291. statistics.neutralReviews = tabCounts.neutral; // 中评数
  292. // 计算评论回复率 = (已回复评论数 ÷ 总评论数) × 100%
  293. const totalCount = tabCounts.all;
  294. const repliedCount = totalCount - tabCounts.pending; // 已回复 = 总数 - 未回复
  295. if (totalCount > 0) {
  296. const rate = (repliedCount / totalCount) * 100;
  297. statistics.abnormalRate = rate.toFixed(2) + "%";
  298. } else {
  299. statistics.abnormalRate = "0%";
  300. }
  301. } catch (error) {
  302. console.error("获取统计数据失败", error);
  303. }
  304. };
  305. // 加载评论列表
  306. const loadReviewList = async (commentLevel?: string | number, resetPage = false) => {
  307. try {
  308. // 如果需要重置页码
  309. if (resetPage) {
  310. pageNum.value = 1;
  311. }
  312. // 如果没有传入参数,使用当前激活的标签页
  313. const level = commentLevel !== undefined ? commentLevel : activeTab.value;
  314. // 判断是否是待回复差评
  315. const isPending = level === "pending";
  316. const params: any = {
  317. pageNum: pageNum.value,
  318. pageSize: pageSize.value,
  319. phoneId: `store_${localGet("geeker-user").userInfo.phone}`,
  320. businessType: 5, //业务类型(1:订单评论, 2:动态社区评论, 3:活动评论, 4:店铺打卡评论, 5:订单评价, 6:订单评论的评论)
  321. commentLevel: isPending ? 3 : level, // 待回复差评传3,其他按原值
  322. days: timeFilter.value, // 评论时间筛选
  323. replyStatus: isPending ? 2 : 0, // 待回复差评传2,其他传0
  324. storeId: localGet("createdId"),
  325. userType: 0
  326. };
  327. const res: any = await getList(params);
  328. if (res.code === 200) {
  329. reviewList.value = res.data.records || [];
  330. total.value = res.data.total || 0;
  331. } else {
  332. reviewList.value = [];
  333. total.value = 0;
  334. ElMessage.error(res.msg || "获取评论列表失败");
  335. }
  336. } catch (error: any) {
  337. console.error("获取评论列表失败", error);
  338. reviewList.value = [];
  339. total.value = 0;
  340. ElMessage.error(error?.msg || "获取评论列表失败");
  341. }
  342. };
  343. // 分页大小变化
  344. const handleSizeChange = (val: number) => {
  345. pageSize.value = val;
  346. pageNum.value = 1;
  347. loadReviewList();
  348. };
  349. // 页码变化
  350. const handleCurrentChange = (val: number) => {
  351. pageNum.value = val;
  352. loadReviewList();
  353. };
  354. // 申诉删除
  355. const delReviewAppeal = (review: any) => {
  356. currentReviewId.value = review.id;
  357. appealDialogVisible.value = true;
  358. };
  359. //回复评论
  360. // 打开回复对话框
  361. const openReplayDialog = (review: any) => {
  362. console.log("打开回复对话框:", review);
  363. currentReplyReview.value = review;
  364. replyDialogVisible.value = true;
  365. };
  366. // 关闭回复对话框
  367. const closeReplyDialog = () => {
  368. replyDialogVisible.value = false;
  369. replyFormRef.value?.resetFields();
  370. Object.assign(replyFormData, {
  371. content: ""
  372. });
  373. currentReplyReview.value = null;
  374. };
  375. // 提交回复
  376. const submitReply = async () => {
  377. if (!replyFormRef.value) return;
  378. await replyFormRef.value.validate(async (valid: boolean) => {
  379. if (valid) {
  380. try {
  381. // 获取当前用户的手机号,并在前面拼接 "store_"
  382. const phone = userStore.userInfo?.phone || "";
  383. const phoneId = phone.startsWith("store_") ? phone : `store_${phone}`;
  384. // 调用 saveComment 接口
  385. const params = {
  386. businessId: currentReplyReview.value.id, // 评论ID
  387. businessType: "1", // 业务类型:1表示订单评价的回复
  388. userId: userStore.userInfo?.userId || userStore.userInfo?.id || "",
  389. storeId: userStore.userInfo?.storeId || localGet("createdId") || "",
  390. commentContent: replyFormData.content, // 回复内容
  391. phoneId: phoneId, // 店铺phoneId
  392. replyId: currentReplyReview.value.id // 被回复的评论ID
  393. };
  394. console.log("提交回复参数:", params);
  395. const res: any = await saveComment(params);
  396. if (res.code === 200) {
  397. ElMessage.success("回复提交成功");
  398. closeReplyDialog();
  399. loadReviewList();
  400. } else {
  401. ElMessage.error(res.message || "回复提交失败");
  402. }
  403. } catch (error) {
  404. console.error("回复提交失败:", error);
  405. ElMessage.error("回复提交失败");
  406. }
  407. }
  408. });
  409. };
  410. // 关闭申诉对话框
  411. const closeAppealDialog = () => {
  412. appealDialogVisible.value = false;
  413. appealFormRef.value?.resetFields();
  414. Object.assign(appealFormData, {
  415. reason: "",
  416. images: [],
  417. files: [],
  418. fileList: []
  419. });
  420. };
  421. // 提交申诉
  422. const submitAppeal = async () => {
  423. if (!appealFormRef.value) return;
  424. await appealFormRef.value.validate(async (valid: boolean) => {
  425. if (valid) {
  426. try {
  427. // 使用 FormData 发送图片
  428. const formData = new FormData();
  429. formData.append("appealReason", appealFormData.reason);
  430. formData.append("storeId", localGet("geeker-user").userInfo.storeId);
  431. formData.append("commentId", currentReviewId.value);
  432. // 添加图片文件,使用 file_0, file_1, file_2 等格式
  433. appealFormData.files.forEach((file, index) => {
  434. formData.append(`file_${index}`, file);
  435. });
  436. const res: any = await addAppealNew(formData);
  437. if (res.code === 200) {
  438. ElMessage.success("申诉提交成功");
  439. // 更新评价的申诉状态,使按钮置灰
  440. const review = reviewList.value.find((r: any) => r.id === currentReviewId.value);
  441. if (review) {
  442. review.isAppealed = true;
  443. }
  444. }
  445. closeAppealDialog();
  446. } catch (error) {
  447. ElMessage.error("申诉提交失败");
  448. }
  449. }
  450. });
  451. };
  452. // 图片预览
  453. const handlePreview = (file: UploadUserFile) => {
  454. console.log("preview", file);
  455. };
  456. // 移除图片
  457. const handleRemove = (file: UploadUserFile, fileList: UploadUserFile[]) => {
  458. console.log("remove", file);
  459. // 找到对应的索引并删除
  460. const index = appealFormData.fileList.findIndex(f => f.uid === file.uid);
  461. if (index !== -1) {
  462. appealFormData.images.splice(index, 1);
  463. appealFormData.files.splice(index, 1);
  464. }
  465. appealFormData.fileList = fileList;
  466. };
  467. // 自定义上传方法
  468. const handleUpload = async (options: any) => {
  469. const { file, onSuccess, onError } = options;
  470. try {
  471. // 保存原始的 File 对象用于提交时上传
  472. appealFormData.files.push(file);
  473. // 创建本地预览URL
  474. const previewUrl = URL.createObjectURL(file);
  475. onSuccess({ url: previewUrl });
  476. // 将预览URL添加到images数组
  477. appealFormData.images.push(previewUrl);
  478. console.log("图片已添加:", file.name);
  479. } catch (error: any) {
  480. onError(error);
  481. ElMessage.error(error?.msg || "添加图片失败");
  482. }
  483. };
  484. // 上传成功回调
  485. const handleUploadSuccess = (response: any, file: any) => {
  486. console.log("上传成功回调:", response, file);
  487. };
  488. // 初始化
  489. onMounted(() => {
  490. loadStatistics(); // 加载统计数据
  491. loadReviewList(); // 加载评论列表
  492. });
  493. </script>
  494. <style lang="scss" scoped>
  495. .review-appeal-container {
  496. min-height: calc(100vh - 120px);
  497. background: #f5f7fa;
  498. // 统计数据区域
  499. .statistics-section {
  500. padding: 20px;
  501. margin-bottom: 20px;
  502. background: #ffffff;
  503. border-radius: 8px;
  504. .statistics-cards {
  505. display: flex;
  506. gap: 20px;
  507. margin-bottom: 16px;
  508. .stat-card {
  509. flex: 1;
  510. padding: 16px;
  511. text-align: center;
  512. background: #f5f7fa;
  513. border-radius: 4px;
  514. .stat-label {
  515. margin-bottom: 8px;
  516. font-size: 14px;
  517. color: #909399;
  518. }
  519. .stat-value {
  520. font-size: 24px;
  521. font-weight: 600;
  522. color: #303133;
  523. &.highlight {
  524. color: #f56c6c;
  525. }
  526. }
  527. }
  528. }
  529. }
  530. // 评价列表区域
  531. .review-list-section {
  532. padding: 20px;
  533. background: #ffffff;
  534. border-radius: 8px;
  535. .section-header {
  536. margin-bottom: 20px;
  537. .section-title {
  538. margin-bottom: 16px;
  539. font-size: 16px;
  540. font-weight: 600;
  541. color: #303133;
  542. }
  543. .time-filter {
  544. margin-bottom: 10px;
  545. font-size: 14px;
  546. .time-filter-select {
  547. width: 120px;
  548. }
  549. }
  550. }
  551. // 评论卡片
  552. .review-cards {
  553. display: flex;
  554. flex-direction: column;
  555. gap: 16px;
  556. .review-card {
  557. padding: 16px;
  558. border: 1px solid #e4e7ed;
  559. border-radius: 8px;
  560. .review-header {
  561. display: flex;
  562. justify-content: space-between;
  563. margin-bottom: 12px;
  564. .user-info {
  565. display: flex;
  566. gap: 12px;
  567. align-items: center;
  568. .user-details {
  569. .user-name {
  570. margin-bottom: 4px;
  571. font-size: 14px;
  572. font-weight: 600;
  573. color: #303133;
  574. }
  575. }
  576. }
  577. .review-time {
  578. font-size: 13px;
  579. color: #909399;
  580. }
  581. }
  582. .review-content {
  583. margin-bottom: 12px;
  584. font-size: 14px;
  585. line-height: 1.6;
  586. color: #606266;
  587. }
  588. .sjhf {
  589. padding-bottom: 10px;
  590. font-size: 14px;
  591. color: #606266;
  592. }
  593. .review-images {
  594. display: flex;
  595. gap: 8px;
  596. margin-bottom: 12px;
  597. .review-image {
  598. width: 80px;
  599. height: 80px;
  600. border-radius: 4px;
  601. }
  602. }
  603. .review-footer {
  604. display: flex;
  605. gap: 16px;
  606. padding-top: 12px;
  607. border-top: 1px solid #e4e7ed;
  608. }
  609. }
  610. }
  611. // 分页
  612. .pagination-wrapper {
  613. display: flex;
  614. justify-content: flex-end;
  615. padding-top: 20px;
  616. margin-top: 20px;
  617. border-top: 1px solid #e4e7ed;
  618. }
  619. }
  620. }
  621. </style>