reviewAppeal.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688
  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, getRatingCount } 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. // 从 getRatingCount 获取各评论等级数量
  264. const ratingCountRes = await getRatingCount({ businessId: localGet("createdId"), businessType: 1 });
  265. const ratingCount = Number(ratingCountRes?.code) === 200 ? ratingCountRes.data : null;
  266. // 从 getRatingCount 返回的 data 取值(totalCount / goodCount / midCount / badCount / pending)
  267. const rc = ratingCount as Record<string, number | undefined> | null;
  268. tabCounts.all = rc?.totalCount ?? 0;
  269. tabCounts.good = rc?.goodCount ?? 0;
  270. tabCounts.neutral = rc?.midCount ?? 0;
  271. tabCounts.bad = rc?.badCount ?? 0;
  272. tabCounts.pending = rc?.pending ?? 0;
  273. // 更新统计数据
  274. statistics.totalReviews = tabCounts.all;
  275. statistics.badTextReviews = tabCounts.bad; // 新增差评数
  276. statistics.badImageReviews = tabCounts.good; // 新增好评数
  277. statistics.neutralReviews = tabCounts.neutral; // 中评数
  278. // 计算评论回复率 = (已回复评论数 ÷ 总评论数) × 100%
  279. const totalCount = tabCounts.all;
  280. const repliedCount = totalCount - tabCounts.pending; // 已回复 = 总数 - 未回复
  281. if (totalCount > 0) {
  282. const rate = (repliedCount / totalCount) * 100;
  283. statistics.abnormalRate = rate.toFixed(2) + "%";
  284. } else {
  285. statistics.abnormalRate = "0%";
  286. }
  287. } catch (error) {
  288. console.error("获取统计数据失败", error);
  289. }
  290. };
  291. // 加载评论列表
  292. const loadReviewList = async (commentLevel?: string | number, resetPage = false) => {
  293. try {
  294. // 如果需要重置页码
  295. if (resetPage) {
  296. pageNum.value = 1;
  297. }
  298. // 如果没有传入参数,使用当前激活的标签页
  299. const level = commentLevel !== undefined ? commentLevel : activeTab.value;
  300. // 判断是否是待回复差评
  301. const isPending = level === "pending";
  302. // 构建请求参数
  303. const params: Record<string, any> = {
  304. pageNum: pageNum.value,
  305. pageSize: pageSize.value,
  306. businessType: 1, // 1-店铺评价
  307. businessId: localGet("createdId"),
  308. userId: userStore.userInfo?.userId || userStore.userInfo?.id || ""
  309. };
  310. // 评分筛选:0-全部 1-好评(≥4.5) 2-中评(3.0-4.0) 3-差评(0.5-2.5)
  311. const levelNum = isPending ? 3 : Number(level);
  312. if (levelNum !== 0) {
  313. params.searchScore = levelNum;
  314. }
  315. if (timeFilter.value) {
  316. params.days = timeFilter.value;
  317. }
  318. // 待回复差评:回复状态 2-未回复
  319. if (isPending) {
  320. params.replyStatus = 2;
  321. }
  322. const res: any = await getList(params);
  323. if (res.code === 200) {
  324. reviewList.value = res.data.records || [];
  325. total.value = res.data.total || 0;
  326. } else {
  327. reviewList.value = [];
  328. total.value = 0;
  329. ElMessage.error(res.msg || "获取评论列表失败");
  330. }
  331. } catch (error: any) {
  332. console.error("获取评论列表失败", error);
  333. reviewList.value = [];
  334. total.value = 0;
  335. ElMessage.error(error?.msg || "获取评论列表失败");
  336. }
  337. };
  338. // 分页大小变化
  339. const handleSizeChange = (val: number) => {
  340. pageSize.value = val;
  341. pageNum.value = 1;
  342. loadReviewList();
  343. };
  344. // 页码变化
  345. const handleCurrentChange = (val: number) => {
  346. pageNum.value = val;
  347. loadReviewList();
  348. };
  349. // 申诉删除
  350. const delReviewAppeal = (review: any) => {
  351. currentReviewId.value = review.id;
  352. appealDialogVisible.value = true;
  353. };
  354. //回复评论
  355. // 打开回复对话框
  356. const openReplayDialog = (review: any) => {
  357. console.log("打开回复对话框:", review);
  358. currentReplyReview.value = review;
  359. replyDialogVisible.value = true;
  360. };
  361. // 关闭回复对话框
  362. const closeReplyDialog = () => {
  363. replyDialogVisible.value = false;
  364. replyFormRef.value?.resetFields();
  365. Object.assign(replyFormData, {
  366. content: ""
  367. });
  368. currentReplyReview.value = null;
  369. };
  370. // 提交回复
  371. const submitReply = async () => {
  372. if (!replyFormRef.value) return;
  373. await replyFormRef.value.validate(async (valid: boolean) => {
  374. if (valid) {
  375. try {
  376. // 获取当前用户的手机号,并在前面拼接 "store_"
  377. const phone = userStore.userInfo?.phone || "";
  378. const phoneId = phone.startsWith("store_") ? phone : `store_${phone}`;
  379. // 调用 saveComment 接口
  380. const params = {
  381. businessId: currentReplyReview.value.id, // 评论ID
  382. businessType: "1", // 业务类型:1表示订单评价的回复
  383. userId: userStore.userInfo?.userId || userStore.userInfo?.id || "",
  384. storeId: userStore.userInfo?.storeId || localGet("createdId") || "",
  385. commentContent: replyFormData.content, // 回复内容
  386. phoneId: phoneId, // 店铺phoneId
  387. replyId: currentReplyReview.value.id // 被回复的评论ID
  388. };
  389. console.log("提交回复参数:", params);
  390. const res: any = await saveComment(params);
  391. if (res.code === 200) {
  392. ElMessage.success("回复提交成功");
  393. closeReplyDialog();
  394. loadReviewList();
  395. } else {
  396. ElMessage.error(res.message || "回复提交失败");
  397. }
  398. } catch (error) {
  399. console.error("回复提交失败:", error);
  400. ElMessage.error("回复提交失败");
  401. }
  402. }
  403. });
  404. };
  405. // 关闭申诉对话框
  406. const closeAppealDialog = () => {
  407. appealDialogVisible.value = false;
  408. appealFormRef.value?.resetFields();
  409. Object.assign(appealFormData, {
  410. reason: "",
  411. images: [],
  412. files: [],
  413. fileList: []
  414. });
  415. };
  416. // 提交申诉
  417. const submitAppeal = async () => {
  418. if (!appealFormRef.value) return;
  419. await appealFormRef.value.validate(async (valid: boolean) => {
  420. if (valid) {
  421. try {
  422. // 使用 FormData 发送图片
  423. const formData = new FormData();
  424. formData.append("appealReason", appealFormData.reason);
  425. formData.append("storeId", localGet("geeker-user").userInfo.storeId);
  426. formData.append("commentId", currentReviewId.value);
  427. // 添加图片文件,使用 file_0, file_1, file_2 等格式
  428. appealFormData.files.forEach((file, index) => {
  429. formData.append(`file_${index}`, file);
  430. });
  431. const res: any = await addAppealNew(formData);
  432. if (res.code === 200) {
  433. ElMessage.success("申诉提交成功");
  434. // 更新评价的申诉状态,使按钮置灰
  435. const review = reviewList.value.find((r: any) => r.id === currentReviewId.value);
  436. if (review) {
  437. review.isAppealed = true;
  438. }
  439. }
  440. closeAppealDialog();
  441. } catch (error) {
  442. ElMessage.error("申诉提交失败");
  443. }
  444. }
  445. });
  446. };
  447. // 图片预览
  448. const handlePreview = (file: UploadUserFile) => {
  449. console.log("preview", file);
  450. };
  451. // 移除图片
  452. const handleRemove = (file: UploadUserFile, fileList: UploadUserFile[]) => {
  453. console.log("remove", file);
  454. // 找到对应的索引并删除
  455. const index = appealFormData.fileList.findIndex(f => f.uid === file.uid);
  456. if (index !== -1) {
  457. appealFormData.images.splice(index, 1);
  458. appealFormData.files.splice(index, 1);
  459. }
  460. appealFormData.fileList = fileList;
  461. };
  462. // 自定义上传方法
  463. const handleUpload = async (options: any) => {
  464. const { file, onSuccess, onError } = options;
  465. try {
  466. // 保存原始的 File 对象用于提交时上传
  467. appealFormData.files.push(file);
  468. // 创建本地预览URL
  469. const previewUrl = URL.createObjectURL(file);
  470. onSuccess({ url: previewUrl });
  471. // 将预览URL添加到images数组
  472. appealFormData.images.push(previewUrl);
  473. console.log("图片已添加:", file.name);
  474. } catch (error: any) {
  475. onError(error);
  476. ElMessage.error(error?.msg || "添加图片失败");
  477. }
  478. };
  479. // 上传成功回调
  480. const handleUploadSuccess = (response: any, file: any) => {
  481. console.log("上传成功回调:", response, file);
  482. };
  483. // 初始化
  484. onMounted(() => {
  485. loadStatistics(); // 加载统计数据
  486. loadReviewList(); // 加载评论列表
  487. });
  488. </script>
  489. <style lang="scss" scoped>
  490. .review-appeal-container {
  491. min-height: calc(100vh - 120px);
  492. background: #f5f7fa;
  493. // 统计数据区域
  494. .statistics-section {
  495. padding: 20px;
  496. margin-bottom: 20px;
  497. background: #ffffff;
  498. border-radius: 8px;
  499. .statistics-cards {
  500. display: flex;
  501. gap: 20px;
  502. margin-bottom: 16px;
  503. .stat-card {
  504. flex: 1;
  505. padding: 16px;
  506. text-align: center;
  507. background: #f5f7fa;
  508. border-radius: 4px;
  509. .stat-label {
  510. margin-bottom: 8px;
  511. font-size: 14px;
  512. color: #909399;
  513. }
  514. .stat-value {
  515. font-size: 24px;
  516. font-weight: 600;
  517. color: #303133;
  518. &.highlight {
  519. color: #f56c6c;
  520. }
  521. }
  522. }
  523. }
  524. }
  525. // 评价列表区域
  526. .review-list-section {
  527. padding: 20px;
  528. background: #ffffff;
  529. border-radius: 8px;
  530. .section-header {
  531. margin-bottom: 20px;
  532. .section-title {
  533. margin-bottom: 16px;
  534. font-size: 16px;
  535. font-weight: 600;
  536. color: #303133;
  537. }
  538. .time-filter {
  539. margin-bottom: 10px;
  540. font-size: 14px;
  541. .time-filter-select {
  542. width: 120px;
  543. }
  544. }
  545. }
  546. // 评论卡片
  547. .review-cards {
  548. display: flex;
  549. flex-direction: column;
  550. gap: 16px;
  551. .review-card {
  552. padding: 16px;
  553. border: 1px solid #e4e7ed;
  554. border-radius: 8px;
  555. .review-header {
  556. display: flex;
  557. justify-content: space-between;
  558. margin-bottom: 12px;
  559. .user-info {
  560. display: flex;
  561. gap: 12px;
  562. align-items: center;
  563. .user-details {
  564. .user-name {
  565. margin-bottom: 4px;
  566. font-size: 14px;
  567. font-weight: 600;
  568. color: #303133;
  569. }
  570. }
  571. }
  572. .review-time {
  573. font-size: 13px;
  574. color: #909399;
  575. }
  576. }
  577. .review-content {
  578. margin-bottom: 12px;
  579. font-size: 14px;
  580. line-height: 1.6;
  581. color: #606266;
  582. }
  583. .sjhf {
  584. padding-bottom: 10px;
  585. font-size: 14px;
  586. color: #606266;
  587. }
  588. .review-images {
  589. display: flex;
  590. gap: 8px;
  591. margin-bottom: 12px;
  592. .review-image {
  593. width: 80px;
  594. height: 80px;
  595. border-radius: 4px;
  596. }
  597. }
  598. .review-footer {
  599. display: flex;
  600. gap: 16px;
  601. padding-top: 12px;
  602. border-top: 1px solid #e4e7ed;
  603. }
  604. }
  605. }
  606. // 分页
  607. .pagination-wrapper {
  608. display: flex;
  609. justify-content: flex-end;
  610. padding-top: 20px;
  611. margin-top: 20px;
  612. border-top: 1px solid #e4e7ed;
  613. }
  614. }
  615. }
  616. </style>