reviewAppeal.vue 25 KB

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