reviewAppeal.vue 25 KB

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