reviewAppeal.vue 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825
  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.noReplyCount})`" 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.media && review.media.length > 0" class="review-media">
  85. <template v-for="(m, index) in review.media" :key="index">
  86. <el-image
  87. v-if="m.type === 'image'"
  88. :src="m.url"
  89. :preview-src-list="review.images"
  90. :initial-index="getMediaImageIndex(review.media, Number(index))"
  91. fit="cover"
  92. class="media-item review-image"
  93. />
  94. <div v-else class="media-item media-video-wrap" @click="openVideoPreview(m.url)">
  95. <video
  96. :src="m.url"
  97. class="video-thumb"
  98. preload="metadata"
  99. muted
  100. playsinline
  101. @loadeddata="onVideoLoadedData($event)"
  102. />
  103. <div class="video-mask">
  104. <el-icon class="video-play-icon">
  105. <VideoPlay />
  106. </el-icon>
  107. </div>
  108. </div>
  109. </template>
  110. </div>
  111. <div class="review-footer">
  112. <el-button type="warning" link v-if="review.appealStatus == 0"> 审核中 </el-button>
  113. <el-button
  114. type="danger"
  115. link
  116. v-if="review.appealStatus != 0"
  117. :disabled="review.appealFlag == 1 && review.appealStatus != 1 ? true : false"
  118. @click="delReviewAppeal(review)"
  119. >
  120. 申诉删除
  121. </el-button>
  122. <el-button type="primary" link @click="openReplayDialog(review)" v-if="!hasStoreReply(review)"> 回复 </el-button>
  123. </div>
  124. </div>
  125. </div>
  126. <!-- 空状态 -->
  127. <el-empty v-else description="暂无评论数据" />
  128. <!-- 分页 -->
  129. <div v-if="total > 0" class="pagination-wrapper">
  130. <el-pagination
  131. v-model:current-page="pageNum"
  132. v-model:page-size="pageSize"
  133. :page-sizes="[10, 20, 30, 50]"
  134. :total="total"
  135. layout="total, sizes, prev, pager, next, jumper"
  136. background
  137. @size-change="handleSizeChange"
  138. @current-change="handleCurrentChange"
  139. />
  140. </div>
  141. </div>
  142. <!-- 申诉提交对话框 -->
  143. <el-dialog v-model="appealDialogVisible" title="申诉删除" width="600px" @close="closeAppealDialog">
  144. <el-form ref="appealFormRef" :model="appealFormData" :rules="appealFormRules" label-width="100px">
  145. <el-form-item label="申诉原因" prop="reason">
  146. <el-input
  147. v-model="appealFormData.reason"
  148. type="textarea"
  149. :rows="4"
  150. placeholder="请输入申诉原因"
  151. maxlength="300"
  152. show-word-limit
  153. />
  154. </el-form-item>
  155. <el-form-item label="申诉凭证" prop="images">
  156. <el-upload
  157. v-model:file-list="appealFormData.fileList"
  158. list-type="picture-card"
  159. :limit="6"
  160. :before-upload="beforeAppealImageUpload"
  161. :http-request="handleUpload"
  162. :on-preview="handlePreview"
  163. :on-remove="handleRemove"
  164. :on-success="handleUploadSuccess"
  165. accept="image/*"
  166. >
  167. <el-icon><Plus /></el-icon>
  168. </el-upload>
  169. </el-form-item>
  170. </el-form>
  171. <template #footer>
  172. <el-button @click="closeAppealDialog"> 取消 </el-button>
  173. <el-button type="primary" @click="submitAppeal"> 确定 </el-button>
  174. </template>
  175. </el-dialog>
  176. <!-- 回复对话框 -->
  177. <el-dialog v-model="replyDialogVisible" title="回复评价" width="600px" @close="closeReplyDialog">
  178. <el-form ref="replyFormRef" :model="replyFormData" :rules="replyFormRules" label-width="100px">
  179. <el-form-item label="回复内容" prop="content">
  180. <el-input
  181. v-model="replyFormData.content"
  182. type="textarea"
  183. :rows="6"
  184. placeholder="请输入回复内容"
  185. maxlength="300"
  186. show-word-limit
  187. />
  188. </el-form-item>
  189. </el-form>
  190. <template #footer>
  191. <el-button @click="closeReplyDialog"> 取消 </el-button>
  192. <el-button type="primary" @click="submitReply"> 提交回复 </el-button>
  193. </template>
  194. </el-dialog>
  195. <!-- 视频预览弹窗 -->
  196. <PcVideoPreviewDialog
  197. v-model="videoPreviewVisible"
  198. :src="videoPreviewUrl"
  199. title="视频预览"
  200. :autoplay="true"
  201. @closed="closeVideoPreview"
  202. />
  203. </div>
  204. </template>
  205. <script setup lang="ts" name="reviewAppeal">
  206. import { ref, reactive, onMounted } from "vue";
  207. import { useRouter } from "vue-router";
  208. import { ElMessage } from "element-plus";
  209. import { User, Plus, VideoPlay } from "@element-plus/icons-vue";
  210. import type { FormInstance, FormRules, UploadUserFile } from "element-plus";
  211. import { getList, addAppealNew, saveComment2, getRatingCount } from "@/api/modules/newLoginApi";
  212. import { uploadFileToOss } from "@/api/upload.js";
  213. import { localGet } from "@/utils";
  214. import { useUserStore } from "@/stores/modules/user";
  215. import PcVideoPreviewDialog from "@/components/pcMediaPreview/PcVideoPreviewDialog.vue";
  216. const router = useRouter();
  217. const userStore = useUserStore();
  218. // 店铺名称
  219. const storeName = ref("重庆老火锅");
  220. // 统计数据
  221. const statistics = reactive({
  222. totalReviews: 0,
  223. badTextReviews: 0,
  224. badImageReviews: 0,
  225. neutralReviews: 0,
  226. abnormalRate: "0%"
  227. });
  228. // 标签页计数(由 getRatingCount 接口填充)
  229. const tabCounts = reactive({
  230. all: 0,
  231. pending: 0,
  232. bad: 0,
  233. good: 0,
  234. neutral: 0,
  235. noReplyCount: 0
  236. });
  237. // 当前激活的标签
  238. const activeTab = ref("0");
  239. // 评论时间筛选
  240. const timeFilter = ref("");
  241. // 评论列表
  242. const reviewList = ref<any[]>([]);
  243. // 分页参数
  244. const pageNum = ref(1);
  245. const pageSize = ref(10);
  246. const total = ref(0);
  247. // 申诉提交对话框
  248. const appealDialogVisible = ref(false);
  249. const appealFormRef = ref<FormInstance>();
  250. const currentReviewId = ref("");
  251. const appealFormData = reactive({
  252. reason: "",
  253. images: [] as string[], // 申诉凭证:OSS 图片 URL(与 fileList 顺序一致)
  254. fileList: [] as UploadUserFile[]
  255. });
  256. /** 申诉凭证仅图片,与商户端 / 动态发布一致:单张 20MB */
  257. const APPEAL_IMAGE_MAX_MB = 20;
  258. const UPLOAD_TIP_IMAGE = "图片建议不超过 20MB";
  259. const beforeAppealImageUpload = (rawFile: File) => {
  260. const mime = String(rawFile?.type || "");
  261. if (!mime.startsWith("image/")) {
  262. ElMessage.warning("只能上传图片格式");
  263. return false;
  264. }
  265. if (rawFile.size > APPEAL_IMAGE_MAX_MB * 1024 * 1024) {
  266. ElMessage.warning(UPLOAD_TIP_IMAGE);
  267. return false;
  268. }
  269. return true;
  270. };
  271. const hasStoreReply = (review: any) => {
  272. return review?.storeComment && review.storeComment.length > 0;
  273. };
  274. /** 在 media 列表中,当前项在「仅图片」列表中的下标,用于 el-image initial-index */
  275. const getMediaImageIndex = (media: { url: string; type: string }[], currentIndex: number): number => {
  276. let idx = 0;
  277. for (let i = 0; i < currentIndex; i++) if (media[i].type === "image") idx++;
  278. return idx;
  279. };
  280. const videoPreviewVisible = ref(false);
  281. const videoPreviewUrl = ref("");
  282. const openVideoPreview = (url: string) => {
  283. videoPreviewUrl.value = url;
  284. videoPreviewVisible.value = true;
  285. };
  286. const closeVideoPreview = () => {
  287. videoPreviewUrl.value = "";
  288. videoPreviewVisible.value = false;
  289. };
  290. const onVideoLoadedData = (e: Event) => {
  291. const video = e.target as HTMLVideoElement;
  292. if (video) {
  293. video.currentTime = 0;
  294. video.pause();
  295. }
  296. };
  297. const appealFormRules = reactive<FormRules>({
  298. reason: [{ required: true, message: "请输入申诉原因", trigger: "blur" }],
  299. images: [{ required: true, message: "请上传申诉凭证", trigger: "blur" }]
  300. });
  301. // 回复对话框
  302. const replyDialogVisible = ref(false);
  303. const replyFormRef = ref<FormInstance>();
  304. const currentReplyReview = ref<any>(null);
  305. const replyFormData = reactive({
  306. content: ""
  307. });
  308. const replyFormRules = reactive<FormRules>({
  309. content: [{ required: true, message: "请输入回复内容", trigger: "blur" }]
  310. });
  311. // 标签页切换
  312. const handleTabClick = (tab: any) => {
  313. // 获取点击的标签页的 name 值
  314. const tabName = tab.paneName || tab.props?.name || activeTab.value;
  315. loadReviewList(tabName, true);
  316. };
  317. // 跳转到申诉历史
  318. const goToAppealHistory = () => {
  319. router.push("/dynamicManagement/reviewAppealHistory");
  320. };
  321. // 评论时间筛选变化
  322. const handleTimeFilterChange = () => {
  323. loadReviewList(activeTab.value, true);
  324. };
  325. // 与商家端一致:兼容 code 0 / 200,从 getRatingCount 取 totalCount、goodCount、midCount、badCount、pending、replyRate
  326. const loadStatistics = async () => {
  327. try {
  328. const ratingCountRes: any = await getRatingCount({
  329. businessId: localGet("createdId"),
  330. businessType: 1
  331. });
  332. const isOk = ratingCountRes?.code === 200 || ratingCountRes?.code === 0;
  333. const ratingCount = isOk ? (ratingCountRes.data ?? ratingCountRes) : null;
  334. const rc = ratingCount as Record<string, number | undefined> | null;
  335. tabCounts.all = rc?.totalCount ?? 0;
  336. tabCounts.good = rc?.goodCount ?? 0;
  337. tabCounts.neutral = rc?.midCount ?? 0;
  338. tabCounts.bad = rc?.badCount ?? 0;
  339. tabCounts.pending = rc?.pending ?? 0;
  340. tabCounts.noReplyCount = rc?.noReplyCount ?? 0;
  341. statistics.totalReviews = tabCounts.all;
  342. statistics.badTextReviews = tabCounts.bad;
  343. statistics.badImageReviews = tabCounts.good;
  344. statistics.neutralReviews = tabCounts.neutral;
  345. if (rc?.replyRate != null) {
  346. statistics.abnormalRate = Number(rc.replyRate).toFixed(2) + "%";
  347. } else {
  348. const totalCount = tabCounts.all;
  349. const repliedCount = rc?.repliedCount ?? Math.max(0, totalCount - tabCounts.pending);
  350. if (totalCount > 0) {
  351. statistics.abnormalRate = ((repliedCount / totalCount) * 100).toFixed(2) + "%";
  352. } else {
  353. statistics.abnormalRate = "0%";
  354. }
  355. }
  356. } catch (error) {
  357. console.error("获取统计数据失败", error);
  358. }
  359. };
  360. const VIDEO_EXT = [".mp4", ".mov", ".avi", ".wmv", ".flv", ".mkv", ".webm", ".m4v"];
  361. const isVideoUrl = (url: string) => {
  362. const lower = (url || "").toLowerCase();
  363. return VIDEO_EXT.some(ext => lower.includes(ext));
  364. };
  365. // 与商家端一致:将 commonRating/getList 返回的单条转为列表展示格式;imageUrls 中区分图片与视频,视频用首帧展示
  366. function transformRatingData(item: any): any {
  367. if (!item || typeof item !== "object" || item.id == null) return null;
  368. let urls: string[] = [];
  369. if (item.imageUrls) {
  370. const raw =
  371. typeof item.imageUrls === "string" && item.imageUrls.trim()
  372. ? item.imageUrls.split(",").filter((u: string) => u?.trim())
  373. : Array.isArray(item.imageUrls)
  374. ? item.imageUrls
  375. : [];
  376. urls = raw.map((u: string) => (u && u.trim()) || "").filter(Boolean);
  377. }
  378. const media = urls.map(url => ({ url, type: isVideoUrl(url) ? ("video" as const) : ("image" as const) }));
  379. const images = media.filter(m => m.type === "image").map(m => m.url);
  380. let storeComment: { commentContent: string; createdTime?: string }[] = [];
  381. if (item.childCommonComments) {
  382. const list = Array.isArray(item.childCommonComments)
  383. ? item.childCommonComments
  384. : Object.values(item.childCommonComments || {});
  385. storeComment = list
  386. .filter((c: any) => c && Number(c.commentType) === 2)
  387. .map((c: any) => ({
  388. commentContent: c.content || c.commentContent || "",
  389. createdTime: c.createdTime || ""
  390. }));
  391. }
  392. return {
  393. id: item.id,
  394. commentContent: item.content ?? item.commentContent ?? "",
  395. score: item.score ?? 0,
  396. images,
  397. media,
  398. userAvatar: item.userImage ?? item.userAvatar ?? "",
  399. userName: item.userName ?? (item.isAnonymous === 1 ? "匿名用户" : "用户"),
  400. createdTime: item.createdTime ?? "",
  401. isAnonymous: item.isAnonymous ?? 0,
  402. storeComment,
  403. appealStatus: item.appealStatus !== undefined && item.appealStatus !== null ? item.appealStatus : null,
  404. appealFlag: item.appealFlag !== undefined ? item.appealFlag : item.appealStatus != null ? 1 : 0
  405. };
  406. }
  407. // 加载评论列表(与商家端 getRatingList 参数一致:pageNum、pageSize、businessType、businessId、userId、searchScore、days、replyStatus)
  408. const loadReviewList = async (commentLevel?: string | number, resetPage = false) => {
  409. try {
  410. if (resetPage) pageNum.value = 1;
  411. const level = commentLevel !== undefined ? commentLevel : activeTab.value;
  412. const isPending = level === "pending";
  413. const params: Record<string, any> = {
  414. pageNum: pageNum.value,
  415. pageSize: pageSize.value,
  416. businessType: 1,
  417. businessId: localGet("createdId"),
  418. userId: userStore.userInfo?.userId || userStore.userInfo?.id || ""
  419. // replyStatus: 2
  420. };
  421. const levelNum = isPending ? 3 : Number(level);
  422. if (levelNum !== 0) params.searchScore = levelNum;
  423. if (timeFilter.value) params.days = timeFilter.value;
  424. if (isPending) params.replyStatus = 2;
  425. const res: any = await getList(params);
  426. const isOk = res?.code === 200 || res?.code === 0;
  427. const data = res?.data ?? res;
  428. const records = data?.records ?? (Array.isArray(data) ? data : []);
  429. const totalCount = data?.total ?? res?.total ?? 0;
  430. if (isOk && (records.length > 0 || totalCount >= 0)) {
  431. reviewList.value = records.map((item: any) => transformRatingData(item)).filter(Boolean);
  432. total.value = totalCount;
  433. } else {
  434. reviewList.value = [];
  435. total.value = 0;
  436. if (!isOk && res?.msg) ElMessage.error(res.msg);
  437. }
  438. } catch (error: any) {
  439. console.error("获取评论列表失败", error);
  440. reviewList.value = [];
  441. total.value = 0;
  442. ElMessage.error(error?.message || error?.msg || "获取评论列表失败");
  443. }
  444. };
  445. // 分页大小变化
  446. const handleSizeChange = (val: number) => {
  447. pageSize.value = val;
  448. pageNum.value = 1;
  449. loadReviewList();
  450. };
  451. // 页码变化
  452. const handleCurrentChange = (val: number) => {
  453. pageNum.value = val;
  454. loadReviewList();
  455. };
  456. // 申诉删除
  457. const delReviewAppeal = (review: any) => {
  458. currentReviewId.value = review.id;
  459. appealDialogVisible.value = true;
  460. };
  461. //回复评论
  462. // 打开回复对话框
  463. const openReplayDialog = (review: any) => {
  464. currentReplyReview.value = review;
  465. replyDialogVisible.value = true;
  466. };
  467. // 关闭回复对话框
  468. const closeReplyDialog = () => {
  469. replyDialogVisible.value = false;
  470. replyFormRef.value?.resetFields();
  471. Object.assign(replyFormData, {
  472. content: ""
  473. });
  474. currentReplyReview.value = null;
  475. };
  476. // 提交回复(与商家端 saveComment 参数一致:businessId=店铺ID、replyId=评论ID、commentContent、storeId、userId、phoneId、businessType)
  477. const submitReply = async () => {
  478. if (!replyFormRef.value || !currentReplyReview.value) return;
  479. await replyFormRef.value.validate(async (valid: boolean) => {
  480. if (!valid) return;
  481. try {
  482. const phone = userStore.userInfo?.phone || "";
  483. const phoneId = phone.startsWith("store_") ? phone : `store_${phone}`;
  484. const storeId = userStore.userInfo?.storeId || localGet("createdId") || "";
  485. const params = {
  486. businessId: storeId,
  487. businessType: "1",
  488. userId: String(userStore.userInfo?.userId ?? userStore.userInfo?.id ?? ""),
  489. storeId,
  490. commentContent: replyFormData.content,
  491. phoneId,
  492. replyId: currentReplyReview.value.id
  493. };
  494. const res: any = await saveComment2(params);
  495. const isOk = res?.code === 200 || res?.code === 0;
  496. if (isOk) {
  497. ElMessage.success("回复提交成功");
  498. closeReplyDialog();
  499. loadReviewList();
  500. loadStatistics();
  501. } else {
  502. ElMessage.error(res?.message || res?.msg || "回复提交失败");
  503. }
  504. } catch (error: any) {
  505. console.error("回复提交失败:", error);
  506. ElMessage.error(error?.message || "回复提交失败");
  507. }
  508. });
  509. };
  510. // 关闭申诉对话框
  511. const closeAppealDialog = () => {
  512. appealDialogVisible.value = false;
  513. appealFormRef.value?.resetFields();
  514. Object.assign(appealFormData, {
  515. reason: "",
  516. images: [],
  517. fileList: []
  518. });
  519. };
  520. // 提交申诉(与商家端 addAppealNew 一致:FormData 含 appealReason、storeId、commentId、file_0/file_1...;凭证已为 OSS 地址时按 URL 字符串提交)
  521. const submitAppeal = async () => {
  522. if (!appealFormRef.value) return;
  523. await appealFormRef.value.validate(async (valid: boolean) => {
  524. if (!valid) return;
  525. try {
  526. const geekerUser = localGet("geeker-user");
  527. const storeId = geekerUser?.userInfo?.storeId ?? localGet("createdId") ?? "";
  528. const formData = new FormData();
  529. formData.append("appealReason", appealFormData.reason);
  530. formData.append("storeId", String(storeId));
  531. formData.append("commentId", String(currentReviewId.value));
  532. appealFormData.images.forEach((url: string, index: number) => {
  533. formData.append(`file_${index}`, url);
  534. });
  535. const res: any = await addAppealNew(formData);
  536. const result = res?.data?.result ?? res?.result;
  537. if (result === 0) {
  538. ElMessage.success("申诉提交成功");
  539. const review = reviewList.value.find((r: any) => String(r.id) === String(currentReviewId.value));
  540. if (review) review.appealFlag = 1;
  541. loadStatistics();
  542. } else if (result === 2) {
  543. ElMessage.warning("申诉已存在");
  544. } else if (result === 3) {
  545. ElMessage.warning("申诉理由包含敏感词,请修改后重试");
  546. } else if (result === 4) {
  547. ElMessage.warning("申诉理由超过300字限制");
  548. } else {
  549. ElMessage.error(res?.msg || "申诉提交失败");
  550. }
  551. closeAppealDialog();
  552. } catch (error: any) {
  553. ElMessage.error(error?.message || "申诉提交失败");
  554. }
  555. });
  556. };
  557. // 图片预览
  558. const handlePreview = (file: UploadUserFile) => {
  559. console.log("preview", file);
  560. };
  561. // 移除图片
  562. const handleRemove = (file: UploadUserFile, fileList: UploadUserFile[]) => {
  563. const index = appealFormData.fileList.findIndex(f => f.uid === file.uid);
  564. if (index !== -1) {
  565. appealFormData.images.splice(index, 1);
  566. }
  567. appealFormData.fileList = fileList;
  568. };
  569. /** OSS 直传后交给 el-upload 展示网络地址 */
  570. const handleUpload = async (options: any) => {
  571. const { file, onSuccess, onError } = options;
  572. try {
  573. const url = await uploadFileToOss(file as File, "image");
  574. if (!url) {
  575. throw new Error("上传失败,未返回地址");
  576. }
  577. appealFormData.images.push(url);
  578. onSuccess({ url });
  579. } catch (error: any) {
  580. onError(error);
  581. // 错误提示由 upload.js 内 ElMessage 处理
  582. }
  583. };
  584. const handleUploadSuccess = (_response: any, _file: any) => {
  585. // 列表与 images 已在 handleUpload 中维护
  586. };
  587. // 初始化
  588. onMounted(() => {
  589. loadStatistics(); // 加载统计数据
  590. loadReviewList(); // 加载评论列表
  591. });
  592. </script>
  593. <style lang="scss" scoped>
  594. .review-appeal-container {
  595. min-height: calc(100vh - 120px);
  596. background: #f5f7fa;
  597. // 统计数据区域
  598. .statistics-section {
  599. padding: 20px;
  600. margin-bottom: 20px;
  601. background: #ffffff;
  602. border-radius: 8px;
  603. .statistics-cards {
  604. display: flex;
  605. gap: 20px;
  606. margin-bottom: 16px;
  607. .stat-card {
  608. flex: 1;
  609. padding: 16px;
  610. text-align: center;
  611. background: #f5f7fa;
  612. border-radius: 4px;
  613. .stat-label {
  614. margin-bottom: 8px;
  615. font-size: 14px;
  616. color: #909399;
  617. }
  618. .stat-value {
  619. font-size: 24px;
  620. font-weight: 600;
  621. color: #303133;
  622. &.highlight {
  623. color: #f56c6c;
  624. }
  625. }
  626. }
  627. }
  628. }
  629. // 评价列表区域
  630. .review-list-section {
  631. padding: 20px;
  632. background: #ffffff;
  633. border-radius: 8px;
  634. .section-header {
  635. margin-bottom: 20px;
  636. .section-title {
  637. margin-bottom: 16px;
  638. font-size: 16px;
  639. font-weight: 600;
  640. color: #303133;
  641. }
  642. .time-filter {
  643. margin-bottom: 10px;
  644. font-size: 14px;
  645. .time-filter-select {
  646. width: 120px;
  647. }
  648. }
  649. }
  650. // 评论卡片
  651. .review-cards {
  652. display: flex;
  653. flex-direction: column;
  654. gap: 16px;
  655. .review-card {
  656. padding: 16px;
  657. border: 1px solid #e4e7ed;
  658. border-radius: 8px;
  659. .review-header {
  660. display: flex;
  661. justify-content: space-between;
  662. margin-bottom: 12px;
  663. .user-info {
  664. display: flex;
  665. gap: 12px;
  666. align-items: center;
  667. .user-details {
  668. .user-name {
  669. margin-bottom: 4px;
  670. font-size: 14px;
  671. font-weight: 600;
  672. color: #303133;
  673. }
  674. }
  675. }
  676. .review-time {
  677. font-size: 13px;
  678. color: #909399;
  679. }
  680. }
  681. .review-content {
  682. margin-bottom: 12px;
  683. font-size: 14px;
  684. line-height: 1.6;
  685. color: #606266;
  686. }
  687. .sjhf {
  688. padding-bottom: 10px;
  689. font-size: 14px;
  690. color: #606266;
  691. }
  692. .review-media {
  693. display: flex;
  694. flex-wrap: wrap;
  695. gap: 8px;
  696. margin-bottom: 12px;
  697. .media-item {
  698. flex-shrink: 0;
  699. width: 80px;
  700. height: 80px;
  701. overflow: hidden;
  702. border-radius: 4px;
  703. }
  704. .review-image {
  705. object-fit: cover;
  706. }
  707. .media-video-wrap {
  708. position: relative;
  709. cursor: pointer;
  710. background: #000000;
  711. .video-thumb {
  712. display: block;
  713. width: 100%;
  714. height: 100%;
  715. object-fit: cover;
  716. }
  717. .video-mask {
  718. position: absolute;
  719. inset: 0;
  720. display: flex;
  721. align-items: center;
  722. justify-content: center;
  723. background: rgb(0 0 0 / 35%);
  724. .video-play-icon {
  725. font-size: 28px;
  726. color: #ffffff;
  727. }
  728. }
  729. }
  730. }
  731. .review-footer {
  732. display: flex;
  733. gap: 16px;
  734. padding-top: 12px;
  735. border-top: 1px solid #e4e7ed;
  736. }
  737. }
  738. }
  739. // 分页
  740. .pagination-wrapper {
  741. display: flex;
  742. justify-content: flex-end;
  743. padding-top: 20px;
  744. margin-top: 20px;
  745. border-top: 1px solid #e4e7ed;
  746. }
  747. }
  748. }
  749. </style>