reviewAppealHistory.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. <template>
  2. <div class="review-appeal-history-container">
  3. <div class="page-header">
  4. <el-button @click="goBack"> 返回 </el-button>
  5. <h2 class="page-title">申诉历史</h2>
  6. </div>
  7. <div class="tabs-section">
  8. <el-tabs v-model="activeTab" @tab-change="handleTabClick($event)">
  9. <el-tab-pane label="全部" name="all" />
  10. <el-tab-pane label="审核中" name="pending" />
  11. <el-tab-pane label="被驳回" name="rejected" />
  12. <el-tab-pane label="已通过" name="approved" />
  13. </el-tabs>
  14. </div>
  15. <div v-if="appealHistoryList.length > 0" class="appeal-history-list">
  16. <div v-for="item in appealHistoryList" :key="item.id" class="appeal-card">
  17. <!-- 标题行:顾客评价 + 状态标签 -->
  18. <div class="card-title-row">
  19. <span class="card-title">顾客评价</span>
  20. <el-tag :class="['status-tag', getAppealStatusTagClass(item.appealStatus)]" size="default">
  21. {{ getAppealStatusText(item.appealStatus) }}
  22. </el-tag>
  23. </div>
  24. <!-- 用户信息:头像、昵称、评价时间 -->
  25. <div class="card-user">
  26. <el-avatar :src="getUserAvatar(item)" :size="40" class="user-avatar">
  27. <el-icon><User /></el-icon>
  28. </el-avatar>
  29. <div class="user-meta">
  30. <div class="user-name">
  31. {{ item.isAnonymous == 1 || !item.userName ? "匿名用户" : item.userName }}
  32. </div>
  33. <div class="review-time">
  34. {{ formatTime(item.createdTime) }}
  35. </div>
  36. </div>
  37. </div>
  38. <!-- 评价内容 -->
  39. <div class="card-content">
  40. {{ getCommentContent(item) }}
  41. </div>
  42. <!-- 评价图片/视频(视频用首帧 + 播放图标,可预览) -->
  43. <div v-if="getCommentMedia(item).length > 0" class="card-media">
  44. <template v-for="(m, idx) in getCommentMedia(item)" :key="idx">
  45. <el-image
  46. v-if="m.type === 'image'"
  47. :src="m.url"
  48. :preview-src-list="getCommentMediaImages(item)"
  49. :initial-index="getMediaImageIndex(getCommentMedia(item), idx)"
  50. fit="cover"
  51. class="media-item comment-img"
  52. />
  53. <div v-else class="media-item media-video-wrap" @click="openVideoPreview(m.url)">
  54. <video
  55. :src="m.url"
  56. class="video-thumb"
  57. preload="metadata"
  58. muted
  59. playsinline
  60. @loadeddata="onVideoLoadedData($event)"
  61. />
  62. <div class="video-mask">
  63. <el-icon class="video-play-icon">
  64. <VideoPlay />
  65. </el-icon>
  66. </div>
  67. </div>
  68. </template>
  69. </div>
  70. <!-- 申诉时间 + 状态 + 查看详情 -->
  71. <div class="card-appeal">
  72. <div class="appeal-time-row">
  73. <span class="appeal-time-label">申诉时间</span>
  74. <span class="appeal-time-value">{{ formatTime(item.appealTime || item.createdTime) }}</span>
  75. </div>
  76. <div class="appeal-status-row">
  77. <span class="appeal-status-text">
  78. <el-icon v-if="item.appealStatus === 0" class="status-icon status-pending"><Clock /></el-icon>
  79. <el-icon v-else-if="item.appealStatus === 2" class="status-icon status-approved"><CircleCheck /></el-icon>
  80. <el-icon v-else class="status-icon status-rejected"><CircleClose /></el-icon>
  81. {{ getAppealStatusDesc(item.appealStatus) }}
  82. </span>
  83. <el-button type="primary" link class="btn-detail" @click="viewDetail(item)"> 查看详情 </el-button>
  84. </div>
  85. </div>
  86. </div>
  87. </div>
  88. <el-empty v-else description="暂无申诉记录" />
  89. <div v-if="pagination.total > 0" class="pagination-section">
  90. <el-pagination
  91. v-model:current-page="pagination.page"
  92. v-model:page-size="pagination.pageSize"
  93. :page-sizes="[10, 20, 30, 50]"
  94. :total="pagination.total"
  95. layout="prev, pager, next"
  96. background
  97. @size-change="handleSizeChange"
  98. @current-change="handleCurrentChange"
  99. />
  100. </div>
  101. <!-- 视频预览弹窗 -->
  102. <el-dialog
  103. v-model="videoPreviewVisible"
  104. title="视频预览"
  105. width="80%"
  106. max-width="720px"
  107. destroy-on-close
  108. align-center
  109. @closed="closeVideoPreview"
  110. >
  111. <video v-if="videoPreviewUrl" :src="videoPreviewUrl" controls autoplay style="width: 100%; max-height: 70vh" />
  112. </el-dialog>
  113. </div>
  114. </template>
  115. <script setup lang="ts" name="reviewAppealHistory">
  116. import { ref, reactive, onMounted } from "vue";
  117. import { useRouter } from "vue-router";
  118. import { User, Clock, CircleCheck, CircleClose, VideoPlay } from "@element-plus/icons-vue";
  119. import { ElMessage } from "element-plus";
  120. import { getAppealHistory } from "@/api/modules/newLoginApi";
  121. import { localGet } from "@/utils";
  122. const router = useRouter();
  123. const activeTab = ref("all");
  124. const appealHistoryList = ref<any[]>([]);
  125. const pagination = reactive({
  126. page: 1,
  127. pageSize: 10,
  128. total: 0
  129. });
  130. const goBack = () => router.back();
  131. const handleTabClick = (val: string | number) => {
  132. activeTab.value = typeof val === "number" ? String(val) : val;
  133. pagination.page = 1;
  134. loadAppealHistory();
  135. };
  136. const handleSizeChange = (val: number) => {
  137. pagination.pageSize = val;
  138. pagination.page = 1;
  139. loadAppealHistory();
  140. };
  141. const handleCurrentChange = (val: number) => {
  142. pagination.page = val;
  143. loadAppealHistory();
  144. };
  145. const loadAppealHistory = async () => {
  146. try {
  147. let appealStatus =
  148. activeTab.value === "all" ? "" : activeTab.value === "pending" ? 0 : activeTab.value === "rejected" ? 1 : 2;
  149. const params: any = {
  150. storeId: localGet("createdId") || "",
  151. appealStatus,
  152. pageNum: pagination.page,
  153. pageSize: pagination.pageSize
  154. };
  155. const res: any = await getAppealHistory(params);
  156. const isOk = res?.code === 200 || res?.code === 0;
  157. const data = res?.data ?? res;
  158. if (isOk) {
  159. appealHistoryList.value = data?.records ?? data ?? [];
  160. pagination.total = data?.total ?? 0;
  161. } else {
  162. appealHistoryList.value = [];
  163. pagination.total = 0;
  164. if (res?.msg) ElMessage.error(res.msg);
  165. }
  166. } catch (error: any) {
  167. appealHistoryList.value = [];
  168. pagination.total = 0;
  169. ElMessage.error(error?.message || "获取申诉历史失败");
  170. }
  171. };
  172. const viewDetail = (item: any) => {
  173. router.push({ path: "/dynamicManagement/reviewAppealDetail", query: { id: item.id } });
  174. };
  175. const formatTime = (t: string) => {
  176. if (!t) return "--";
  177. return String(t).replace(/-/g, "/");
  178. };
  179. const getUserAvatar = (item: any) => {
  180. return item.userImage ?? item.userAvatar ?? "";
  181. };
  182. const getCommentContent = (item: any) => {
  183. return item.commentContent ?? item.reviewContent ?? item.content ?? "--";
  184. };
  185. const VIDEO_EXT = [".mp4", ".mov", ".avi", ".wmv", ".flv", ".mkv", ".webm", ".m4v"];
  186. const isVideoUrl = (url: string) => {
  187. const lower = (url || "").toLowerCase();
  188. return VIDEO_EXT.some(ext => lower.includes(ext));
  189. };
  190. interface MediaItem {
  191. url: string;
  192. type: "image" | "video";
  193. }
  194. const getCommentMedia = (item: any): MediaItem[] => {
  195. const urls: string[] = [];
  196. const list = item.commentImgId;
  197. if (Array.isArray(list)) urls.push(...list.filter(Boolean));
  198. else if (typeof list === "string" && list.trim())
  199. urls.push(
  200. ...list
  201. .split(",")
  202. .map((s: string) => s.trim())
  203. .filter(Boolean)
  204. );
  205. const videoList = item.commentVideoList ?? item.videoUrls ?? item.videoList;
  206. if (Array.isArray(videoList)) urls.push(...videoList.filter(Boolean));
  207. else if (typeof videoList === "string" && videoList.trim())
  208. urls.push(
  209. ...videoList
  210. .split(",")
  211. .map((s: string) => s.trim())
  212. .filter(Boolean)
  213. );
  214. return urls.map(url => ({ url, type: isVideoUrl(url) ? "video" : "image" }));
  215. };
  216. const getCommentMediaImages = (item: any): string[] => {
  217. return getCommentMedia(item)
  218. .filter(m => m.type === "image")
  219. .map(m => m.url);
  220. };
  221. const getMediaImageIndex = (media: MediaItem[], currentIndex: number): number => {
  222. let idx = 0;
  223. for (let i = 0; i < currentIndex; i++) if (media[i].type === "image") idx++;
  224. return idx;
  225. };
  226. const videoPreviewVisible = ref(false);
  227. const videoPreviewUrl = ref("");
  228. const openVideoPreview = (url: string) => {
  229. videoPreviewUrl.value = url;
  230. videoPreviewVisible.value = true;
  231. };
  232. const closeVideoPreview = () => {
  233. videoPreviewUrl.value = "";
  234. videoPreviewVisible.value = false;
  235. };
  236. const onVideoLoadedData = (e: Event) => {
  237. const video = e.target as HTMLVideoElement;
  238. if (video) {
  239. video.currentTime = 0;
  240. video.pause();
  241. }
  242. };
  243. // appealStatus: 0=审核中 1=被驳回 2=已通过
  244. const getAppealStatusTagClass = (status: number) => {
  245. const map: Record<number, string> = {
  246. 0: "tag-pending",
  247. 1: "tag-rejected",
  248. 2: "tag-approved"
  249. };
  250. return map[status] ?? "";
  251. };
  252. const getAppealStatusText = (status: number) => {
  253. const map: Record<number, string> = {
  254. 0: "审核中",
  255. 1: "被驳回",
  256. 2: "已通过"
  257. };
  258. return map[status] ?? "";
  259. };
  260. const getAppealStatusDesc = (status: number) => {
  261. const map: Record<number, string> = {
  262. 0: "等待平台审核",
  263. 1: "被驳回",
  264. 2: "已通过"
  265. };
  266. return map[status] ?? "";
  267. };
  268. onMounted(() => loadAppealHistory());
  269. </script>
  270. <style lang="scss" scoped>
  271. .review-appeal-history-container {
  272. min-height: calc(100vh - 120px);
  273. padding: 20px;
  274. background: #f5f7fa;
  275. .page-header {
  276. display: flex;
  277. gap: 16px;
  278. align-items: center;
  279. margin-bottom: 20px;
  280. .page-title {
  281. flex: 1;
  282. margin: 0;
  283. font-size: 18px;
  284. font-weight: 600;
  285. color: #303133;
  286. text-align: center;
  287. }
  288. }
  289. .tabs-section {
  290. margin-bottom: 16px;
  291. }
  292. .appeal-history-list {
  293. display: flex;
  294. flex-direction: column;
  295. gap: 16px;
  296. margin-bottom: 24px;
  297. .appeal-card {
  298. padding: 16px;
  299. overflow: hidden;
  300. background: #ffffff;
  301. border-radius: 12px;
  302. box-shadow: 0 2px 12px rgb(0 0 0 / 6%);
  303. .card-title-row {
  304. display: flex;
  305. align-items: center;
  306. justify-content: space-between;
  307. padding-bottom: 12px;
  308. margin-bottom: 16px;
  309. border-bottom: 1px solid #ebeef5;
  310. .card-title {
  311. font-size: 15px;
  312. font-weight: 600;
  313. color: #303133;
  314. }
  315. .status-tag {
  316. padding: 4px 10px;
  317. font-size: 12px;
  318. color: #ffffff;
  319. border: none;
  320. border-radius: 6px;
  321. &.tag-pending {
  322. background: #409eff;
  323. }
  324. &.tag-approved {
  325. background: #16aa68;
  326. }
  327. &.tag-rejected {
  328. background: #f4420a;
  329. }
  330. }
  331. }
  332. .card-user {
  333. display: flex;
  334. gap: 12px;
  335. align-items: center;
  336. margin-bottom: 12px;
  337. .user-avatar {
  338. flex-shrink: 0;
  339. }
  340. .user-meta {
  341. .user-name {
  342. margin-bottom: 4px;
  343. font-size: 14px;
  344. font-weight: 600;
  345. color: #303133;
  346. }
  347. .review-time {
  348. font-size: 12px;
  349. color: #909399;
  350. }
  351. }
  352. }
  353. .card-content {
  354. margin-bottom: 12px;
  355. font-size: 14px;
  356. line-height: 1.6;
  357. color: #606266;
  358. }
  359. .card-media {
  360. display: flex;
  361. flex-wrap: wrap;
  362. gap: 8px;
  363. margin-bottom: 16px;
  364. .media-item {
  365. flex-shrink: 0;
  366. width: 80px;
  367. height: 80px;
  368. overflow: hidden;
  369. border-radius: 8px;
  370. }
  371. .comment-img {
  372. object-fit: cover;
  373. }
  374. .media-video-wrap {
  375. position: relative;
  376. cursor: pointer;
  377. background: #000000;
  378. .video-thumb {
  379. display: block;
  380. width: 100%;
  381. height: 100%;
  382. object-fit: cover;
  383. }
  384. .video-mask {
  385. position: absolute;
  386. inset: 0;
  387. display: flex;
  388. align-items: center;
  389. justify-content: center;
  390. background: rgb(0 0 0 / 35%);
  391. .video-play-icon {
  392. font-size: 28px;
  393. color: #ffffff;
  394. }
  395. }
  396. }
  397. }
  398. .card-appeal {
  399. padding: 12px 16px 16px;
  400. padding-top: 12px;
  401. margin: 0 -16px -16px;
  402. background: linear-gradient(to top, rgb(86 125 244 / 3%), transparent);
  403. border-top: 1px solid #ebeef5;
  404. .appeal-time-row {
  405. display: flex;
  406. align-items: center;
  407. justify-content: space-between;
  408. margin-bottom: 10px;
  409. font-size: 13px;
  410. .appeal-time-label {
  411. color: #606266;
  412. }
  413. .appeal-time-value {
  414. color: #909399;
  415. }
  416. }
  417. .appeal-status-row {
  418. display: flex;
  419. flex-wrap: wrap;
  420. gap: 8px;
  421. align-items: center;
  422. justify-content: space-between;
  423. .appeal-status-text {
  424. display: inline-flex;
  425. gap: 6px;
  426. align-items: center;
  427. font-size: 13px;
  428. color: #606266;
  429. .status-icon {
  430. font-size: 16px;
  431. &.status-pending {
  432. color: #fa913d;
  433. }
  434. &.status-approved {
  435. color: #16aa68;
  436. }
  437. &.status-rejected {
  438. color: #f4420a;
  439. }
  440. }
  441. }
  442. .btn-detail {
  443. padding: 0;
  444. font-size: 14px;
  445. }
  446. }
  447. }
  448. }
  449. }
  450. .pagination-section {
  451. display: flex;
  452. justify-content: flex-end;
  453. padding: 16px 0;
  454. }
  455. }
  456. </style>