|
|
@@ -0,0 +1,247 @@
|
|
|
+<template>
|
|
|
+ <div class="case-detail">
|
|
|
+ <div class="header">
|
|
|
+ <el-button @click="goBack"> 返回 </el-button>
|
|
|
+ <h2 class="title">案例详情</h2>
|
|
|
+ </div>
|
|
|
+ <el-card v-loading="loading">
|
|
|
+ <template v-if="detail">
|
|
|
+ <el-descriptions :column="2" border>
|
|
|
+ <el-descriptions-item label="所属活动">
|
|
|
+ {{ detail.activityName || detail.activityTitle || "-" }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="用户昵称">
|
|
|
+ {{ detail.nickname ?? "------" }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="姓名">
|
|
|
+ {{ detail.name ?? "------" }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="联系方式">
|
|
|
+ {{ detail.phone || "-" }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="报名时间" :span="2">
|
|
|
+ {{ formatTime(detail.registrationTime || detail.signUpTime) }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ </el-descriptions>
|
|
|
+
|
|
|
+ <div class="result-section">
|
|
|
+ <div class="result-title">成果展示</div>
|
|
|
+ <el-descriptions :column="2" border>
|
|
|
+ <el-descriptions-item label="更新时间" :span="2">
|
|
|
+ {{ formatTime(detail.updateTime || detail.updatedAt) }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="成果描述" :span="2">
|
|
|
+ <div v-if="detail.resultDesc || detail.description" class="result-desc">
|
|
|
+ {{ detail.resultDesc || detail.description }}
|
|
|
+ </div>
|
|
|
+ <span v-else>-</span>
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="图片与视频" :span="2">
|
|
|
+ <div v-if="mediaList.length > 0" class="media-grid">
|
|
|
+ <template v-for="(item, index) in mediaList" :key="index">
|
|
|
+ <div v-if="item.type === 'video'" class="media-item video-item" @click="playVideo(item.url)">
|
|
|
+ <div class="media-placeholder">
|
|
|
+ <el-icon class="play-icon">
|
|
|
+ <VideoPlay />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else class="media-item image-item" @click="previewImage(item.url, index)">
|
|
|
+ <el-image
|
|
|
+ :src="item.url"
|
|
|
+ fit="cover"
|
|
|
+ class="media-image"
|
|
|
+ :preview-src-list="imageList"
|
|
|
+ :initial-index="getImageIndex(item.url)"
|
|
|
+ >
|
|
|
+ <template #error>
|
|
|
+ <div class="image-slot">
|
|
|
+ <el-icon><Picture /></el-icon>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-image>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ <span v-else>-</span>
|
|
|
+ </el-descriptions-item>
|
|
|
+ </el-descriptions>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <el-empty v-else-if="!loading" description="暂无数据" />
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <el-dialog v-model="videoDialogVisible" title="视频预览" width="640px" destroy-on-close @close="previewVideo = ''">
|
|
|
+ <video v-if="previewVideo" :src="previewVideo" controls autoplay class="dialog-video" />
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts" name="caseDetail">
|
|
|
+import { ref, onMounted, computed } from "vue";
|
|
|
+import { useRoute, useRouter } from "vue-router";
|
|
|
+import { Picture, VideoPlay } from "@element-plus/icons-vue";
|
|
|
+import { getPersonCaseDetail } from "@/api/modules/operationManagement";
|
|
|
+
|
|
|
+const route = useRoute();
|
|
|
+const router = useRouter();
|
|
|
+const loading = ref(false);
|
|
|
+const detail = ref<any>(null);
|
|
|
+const videoDialogVisible = ref(false);
|
|
|
+const previewVideo = ref("");
|
|
|
+
|
|
|
+const id = computed(() => route.query.id as string);
|
|
|
+
|
|
|
+const goBack = () => {
|
|
|
+ router.push({ path: "/operationManagement/cases" });
|
|
|
+};
|
|
|
+
|
|
|
+const formatTime = (time: string | null | undefined) => {
|
|
|
+ if (!time) return "-";
|
|
|
+ try {
|
|
|
+ const date = new Date(time);
|
|
|
+ const y = date.getFullYear();
|
|
|
+ const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
|
+ const d = String(date.getDate()).padStart(2, "0");
|
|
|
+ const h = String(date.getHours()).padStart(2, "0");
|
|
|
+ const min = String(date.getMinutes()).padStart(2, "0");
|
|
|
+ const s = String(date.getSeconds()).padStart(2, "0");
|
|
|
+ return `${y}/${m}/${d} ${h}:${min}:${s}`;
|
|
|
+ } catch {
|
|
|
+ return time;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+type MediaItem = { type: "image" | "video"; url: string };
|
|
|
+
|
|
|
+const mediaList = computed<MediaItem[]>(() => {
|
|
|
+ if (!detail.value) return [];
|
|
|
+ const list: MediaItem[] = [];
|
|
|
+ const raw = detail.value.mediaList ?? detail.value.images ?? detail.value.videos ?? [];
|
|
|
+ const arr = Array.isArray(raw) ? raw : [raw];
|
|
|
+ for (const it of arr) {
|
|
|
+ if (typeof it === "string") list.push({ type: "image", url: it });
|
|
|
+ else if (it?.url) list.push({ type: it.type === "video" ? "video" : "image", url: it.url });
|
|
|
+ }
|
|
|
+ if (detail.value.resultImages && Array.isArray(detail.value.resultImages))
|
|
|
+ detail.value.resultImages.forEach((u: string) => list.push({ type: "image", url: u }));
|
|
|
+ if (detail.value.resultVideos && Array.isArray(detail.value.resultVideos))
|
|
|
+ detail.value.resultVideos.forEach((u: string) => list.push({ type: "video", url: u }));
|
|
|
+ return list.filter(Boolean);
|
|
|
+});
|
|
|
+
|
|
|
+const imageList = computed(() => mediaList.value.filter(m => m.type === "image").map(m => m.url));
|
|
|
+
|
|
|
+const getImageIndex = (url: string) => {
|
|
|
+ return imageList.value.indexOf(url);
|
|
|
+};
|
|
|
+
|
|
|
+const previewImage = (url: string, index: number) => {
|
|
|
+ // el-image 的 preview-src-list 会自动处理预览
|
|
|
+};
|
|
|
+
|
|
|
+const playVideo = (url: string) => {
|
|
|
+ previewVideo.value = url;
|
|
|
+ videoDialogVisible.value = true;
|
|
|
+};
|
|
|
+
|
|
|
+onMounted(async () => {
|
|
|
+ if (!id.value) return;
|
|
|
+ loading.value = true;
|
|
|
+ try {
|
|
|
+ const res = await getPersonCaseDetail({ id: id.value });
|
|
|
+ detail.value = res?.data ?? res ?? null;
|
|
|
+ } catch {
|
|
|
+ detail.value = null;
|
|
|
+ } finally {
|
|
|
+ loading.value = false;
|
|
|
+ }
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.case-detail {
|
|
|
+ min-height: 100%;
|
|
|
+ padding: 16px;
|
|
|
+ background: #ffffff;
|
|
|
+}
|
|
|
+.header {
|
|
|
+ display: flex;
|
|
|
+ gap: 16px;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 16px;
|
|
|
+}
|
|
|
+.title {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+.result-section {
|
|
|
+ margin-top: 24px;
|
|
|
+}
|
|
|
+.result-title {
|
|
|
+ margin-bottom: 12px;
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+.result-desc {
|
|
|
+ line-height: 1.6;
|
|
|
+ word-break: break-all;
|
|
|
+ white-space: pre-wrap;
|
|
|
+}
|
|
|
+.media-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(3, 1fr);
|
|
|
+ gap: 12px;
|
|
|
+ margin-top: 8px;
|
|
|
+}
|
|
|
+.media-item {
|
|
|
+ position: relative;
|
|
|
+ aspect-ratio: 1;
|
|
|
+ overflow: hidden;
|
|
|
+ cursor: pointer;
|
|
|
+ background: #f5f7fa;
|
|
|
+ border-radius: 8px;
|
|
|
+}
|
|
|
+.image-item {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+.media-image {
|
|
|
+ display: block;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: cover;
|
|
|
+}
|
|
|
+.video-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background: #000000;
|
|
|
+}
|
|
|
+.media-placeholder {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+.play-icon {
|
|
|
+ font-size: 40px;
|
|
|
+}
|
|
|
+.image-slot {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ color: #909399;
|
|
|
+ background: #f5f7fa;
|
|
|
+}
|
|
|
+.dialog-video {
|
|
|
+ display: block;
|
|
|
+ width: 100%;
|
|
|
+ max-height: 70vh;
|
|
|
+}
|
|
|
+</style>
|