caseDetail.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. <template>
  2. <div class="case-detail">
  3. <div class="header">
  4. <el-button @click="goBack"> 返回 </el-button>
  5. <h2 class="title">案例详情</h2>
  6. </div>
  7. <el-card v-loading="loading">
  8. <template v-if="detail">
  9. <div class="detail-list">
  10. <div class="detail-item">
  11. <div class="detail-label">所属活动 : {{ detail.activityName || detail.activityTitle || "-" }}</div>
  12. </div>
  13. <div class="detail-item">
  14. <div class="detail-label">用户昵称 : {{ detail.nickName || detail.nickname || "-" }}</div>
  15. </div>
  16. <div class="detail-item">
  17. <div class="detail-label">姓名 : {{ detail.userName || "-" }}</div>
  18. </div>
  19. <div class="detail-item">
  20. <div class="detail-label">联系方式 : {{ detail.phone || "-" }}</div>
  21. </div>
  22. <div class="detail-item">
  23. <div class="detail-label">报名时间 : {{ formatTime(detail.createdTime) }}</div>
  24. </div>
  25. </div>
  26. <div v-if="detail.achievementList">
  27. <div class="result-section" v-for="(item, index) in detail.achievementList" :key="index">
  28. <div class="result-title">成果展示</div>
  29. <div class="detail-list">
  30. <div class="detail-item">
  31. <div class="detail-label">更新时间 : {{ formatTime(item.updatedTime) }}</div>
  32. </div>
  33. <div class="detail-item">
  34. <div class="detail-label">成果描述 : {{ item.achievementDesc || "-" }}</div>
  35. </div>
  36. <div class="detail-item" v-if="mediaList.length > 0">
  37. <div class="detail-label">图片与视频 :</div>
  38. <div class="media-grid">
  39. <template v-for="(item, index) in mediaList" :key="index">
  40. <div v-if="item.type === 'video'" class="media-item video-item" @click="playVideo(item.url)">
  41. <el-image v-if="item.coverUrl" :src="item.coverUrl" fit="cover" class="media-image">
  42. <template #error>
  43. <div class="media-placeholder">
  44. <el-icon class="play-icon">
  45. <VideoPlay />
  46. </el-icon>
  47. </div>
  48. </template>
  49. </el-image>
  50. <div v-else class="media-placeholder">
  51. <el-icon class="play-icon">
  52. <VideoPlay />
  53. </el-icon>
  54. </div>
  55. <!-- 视频播放图标覆盖层 -->
  56. <div class="video-overlay">
  57. <el-icon class="play-icon-overlay">
  58. <VideoPlay />
  59. </el-icon>
  60. </div>
  61. </div>
  62. <div v-else class="media-item image-item" @click="previewImage(item.url, index)">
  63. <el-image
  64. :src="item.url"
  65. fit="cover"
  66. class="media-image"
  67. :preview-src-list="imageList"
  68. :initial-index="getImageIndex(item.url)"
  69. >
  70. <template #error>
  71. <div class="image-slot">
  72. <el-icon>
  73. <Picture />
  74. </el-icon>
  75. </div>
  76. </template>
  77. </el-image>
  78. </div>
  79. </template>
  80. </div>
  81. </div>
  82. <div class="detail-item" v-else>
  83. <div class="detail-label">暂无成果展示</div>
  84. </div>
  85. </div>
  86. </div>
  87. </div>
  88. </template>
  89. <el-empty v-else-if="!loading" description="暂无数据" />
  90. </el-card>
  91. <el-dialog v-model="videoDialogVisible" title="视频预览" width="640px" destroy-on-close @close="previewVideo = ''">
  92. <video v-if="previewVideo" :src="previewVideo" controls autoplay class="dialog-video" />
  93. </el-dialog>
  94. </div>
  95. </template>
  96. <script setup lang="ts" name="caseDetail">
  97. import { ref, onMounted, computed } from "vue";
  98. import { useRoute, useRouter } from "vue-router";
  99. import { Picture, VideoPlay } from "@element-plus/icons-vue";
  100. import { getPersonCaseDetail } from "@/api/modules/operationManagement";
  101. const route = useRoute();
  102. const router = useRouter();
  103. const loading = ref(false);
  104. const detail = ref<any>(null);
  105. const videoDialogVisible = ref(false);
  106. const previewVideo = ref("");
  107. const activityId = computed(() => route.query.activityId as string);
  108. const userId = computed(() => route.query.userId as string);
  109. const goBack = () => {
  110. router.push({ path: "/operationManagement/cases" });
  111. };
  112. const formatTime = (time: string | null | undefined) => {
  113. if (!time) return "-";
  114. try {
  115. const date = new Date(time);
  116. const y = date.getFullYear();
  117. const m = String(date.getMonth() + 1).padStart(2, "0");
  118. const d = String(date.getDate()).padStart(2, "0");
  119. const h = String(date.getHours()).padStart(2, "0");
  120. const min = String(date.getMinutes()).padStart(2, "0");
  121. const s = String(date.getSeconds()).padStart(2, "0");
  122. return `${y}/${m}/${d} ${h}:${min}:${s}`;
  123. } catch {
  124. return time;
  125. }
  126. };
  127. type MediaItem = { type: "image" | "video"; url: string; coverUrl?: string };
  128. const mediaList = computed<MediaItem[]>(() => {
  129. if (!detail.value) return [];
  130. const list: MediaItem[] = [];
  131. // 优先处理 achievementList[0].mediaUrlList
  132. if (detail.value.achievementList && detail.value.achievementList[0]?.mediaUrlList) {
  133. const mediaUrlList = detail.value.achievementList[0].mediaUrlList;
  134. const arr = Array.isArray(mediaUrlList) ? mediaUrlList : [mediaUrlList];
  135. for (const it of arr) {
  136. if (typeof it === "string") {
  137. // 检查是否包含 | 分隔符(视频格式:xxx.mp4 | XXX.jpg)
  138. if (it.includes("|")) {
  139. const parts = it
  140. .split("|")
  141. .map(s => s.trim())
  142. .filter(s => s);
  143. if (parts.length >= 2) {
  144. // 第一部分是视频URL,第二部分是封面URL
  145. list.push({ type: "video", url: parts[0], coverUrl: parts[1] });
  146. } else if (parts.length === 1) {
  147. // 只有视频URL,没有封面
  148. list.push({ type: "video", url: parts[0] });
  149. }
  150. } else {
  151. // 如果是字符串,根据文件扩展名判断类型
  152. const isVideo = /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it);
  153. list.push({ type: isVideo ? "video" : "image", url: it });
  154. }
  155. } else if (it?.url) {
  156. // 如果是对象,使用 type 字段或根据 url 判断
  157. const isVideo = it.type === "video" || /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it.url);
  158. list.push({ type: isVideo ? "video" : "image", url: it.url, coverUrl: it.coverUrl });
  159. } else if (it?.mediaUrl) {
  160. // 处理 mediaUrl 字段
  161. const isVideo = it.mediaType === "video" || it.type === "video" || /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it.mediaUrl);
  162. list.push({ type: isVideo ? "video" : "image", url: it.mediaUrl, coverUrl: it.coverUrl });
  163. }
  164. }
  165. }
  166. // 处理 mediaList 字段(可能是数组或对象数组)
  167. const raw = detail.value.mediaList ?? detail.value.images ?? detail.value.videos ?? [];
  168. const arr = Array.isArray(raw) ? raw : [raw];
  169. for (const it of arr) {
  170. if (typeof it === "string") {
  171. // 检查是否包含 | 分隔符(视频格式:xxx.mp4 | XXX.jpg)
  172. if (it.includes("|")) {
  173. const parts = it
  174. .split("|")
  175. .map(s => s.trim())
  176. .filter(s => s);
  177. if (parts.length >= 2) {
  178. // 第一部分是视频URL,第二部分是封面URL
  179. list.push({ type: "video", url: parts[0], coverUrl: parts[1] });
  180. } else if (parts.length === 1) {
  181. // 只有视频URL,没有封面
  182. list.push({ type: "video", url: parts[0] });
  183. }
  184. } else {
  185. const isVideo = /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it);
  186. list.push({ type: isVideo ? "video" : "image", url: it });
  187. }
  188. } else if (it?.url) {
  189. const isVideo = it.type === "video" || /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it.url);
  190. list.push({ type: isVideo ? "video" : "image", url: it.url, coverUrl: it.coverUrl });
  191. } else if (it?.mediaUrl) {
  192. // 处理 mediaUrl 字段
  193. const isVideo = it.mediaType === "video" || it.type === "video" || /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it.mediaUrl);
  194. list.push({ type: isVideo ? "video" : "image", url: it.mediaUrl, coverUrl: it.coverUrl });
  195. }
  196. }
  197. // 处理单独的图片和视频数组
  198. if (detail.value.resultImages && Array.isArray(detail.value.resultImages))
  199. detail.value.resultImages.forEach((u: string) => list.push({ type: "image", url: u }));
  200. if (detail.value.resultVideos && Array.isArray(detail.value.resultVideos))
  201. detail.value.resultVideos.forEach((u: string) => list.push({ type: "video", url: u }));
  202. return list.filter(Boolean);
  203. });
  204. const imageList = computed(() => mediaList.value.filter(m => m.type === "image").map(m => m.url));
  205. const getImageIndex = (url: string) => {
  206. return imageList.value.indexOf(url);
  207. };
  208. const previewImage = (url: string, index: any) => {
  209. // el-image 的 preview-src-list 会自动处理预览
  210. };
  211. const playVideo = (url: string) => {
  212. previewVideo.value = url;
  213. videoDialogVisible.value = true;
  214. };
  215. onMounted(async () => {
  216. if (!activityId.value || !userId.value) return;
  217. loading.value = true;
  218. try {
  219. const res = await getPersonCaseDetail({
  220. activityId: Number(activityId.value),
  221. userId: Number(userId.value)
  222. });
  223. detail.value = res?.data ?? res ?? null;
  224. } catch {
  225. detail.value = null;
  226. } finally {
  227. loading.value = false;
  228. }
  229. });
  230. </script>
  231. <style scoped lang="scss">
  232. .case-detail {
  233. min-height: 100%;
  234. padding: 16px;
  235. background: #ffffff;
  236. }
  237. .header {
  238. display: flex;
  239. gap: 16px;
  240. align-items: center;
  241. margin-bottom: 16px;
  242. }
  243. .title {
  244. margin: 0;
  245. font-size: 18px;
  246. font-weight: 600;
  247. }
  248. .detail-list {
  249. display: flex;
  250. flex-direction: column;
  251. gap: 16px;
  252. }
  253. .detail-item {
  254. display: flex;
  255. flex-direction: column;
  256. gap: 8px;
  257. }
  258. .detail-label {
  259. font-size: 14px;
  260. font-weight: 500;
  261. color: #606266;
  262. }
  263. .detail-value {
  264. font-size: 14px;
  265. color: #303133;
  266. word-break: break-all;
  267. }
  268. .result-section {
  269. margin-top: 24px;
  270. }
  271. .result-title {
  272. margin-bottom: 12px;
  273. font-size: 16px;
  274. font-weight: 600;
  275. }
  276. .result-desc {
  277. font-size: 14px;
  278. line-height: 1.6;
  279. color: #303133;
  280. word-break: break-all;
  281. white-space: pre-wrap;
  282. }
  283. .media-grid {
  284. display: grid;
  285. grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  286. gap: 12px;
  287. margin-top: 8px;
  288. }
  289. .media-item {
  290. position: relative;
  291. width: 200px;
  292. height: 200px;
  293. overflow: hidden;
  294. cursor: pointer;
  295. background: #f5f7fa;
  296. border-radius: 8px;
  297. }
  298. .image-item {
  299. width: 200px;
  300. height: 200px;
  301. }
  302. .media-image {
  303. display: block;
  304. width: 100%;
  305. height: 100%;
  306. object-fit: cover;
  307. }
  308. .video-item {
  309. position: relative;
  310. display: flex;
  311. align-items: center;
  312. justify-content: center;
  313. width: 200px;
  314. height: 200px;
  315. background: #000000;
  316. }
  317. .media-placeholder {
  318. display: flex;
  319. align-items: center;
  320. justify-content: center;
  321. width: 100%;
  322. height: 100%;
  323. color: #909399;
  324. }
  325. .play-icon {
  326. font-size: 40px;
  327. }
  328. .video-overlay {
  329. position: absolute;
  330. inset: 0;
  331. display: flex;
  332. align-items: center;
  333. justify-content: center;
  334. pointer-events: none;
  335. background: rgb(0 0 0 / 30%);
  336. border-radius: 8px;
  337. }
  338. .play-icon-overlay {
  339. font-size: 48px;
  340. color: #ffffff;
  341. opacity: 0.9;
  342. }
  343. .image-slot {
  344. display: flex;
  345. align-items: center;
  346. justify-content: center;
  347. width: 100%;
  348. height: 100%;
  349. color: #909399;
  350. background: #f5f7fa;
  351. }
  352. .dialog-video {
  353. display: block;
  354. width: 100%;
  355. max-height: 70vh;
  356. }
  357. </style>