|
|
@@ -33,42 +33,20 @@
|
|
|
<div class="detail-item">
|
|
|
<div class="detail-label">成果描述 : {{ items.achievementDesc || "-" }}</div>
|
|
|
</div>
|
|
|
- <div class="detail-item" v-if="items.mediaUrlList && items.mediaUrlList.length > 0">
|
|
|
+ <div class="detail-item" v-if="parseAchievementMedia(items).length > 0">
|
|
|
<div class="detail-label">图片与视频 :</div>
|
|
|
<div class="media-grid">
|
|
|
- <template v-for="(item, mediaIndex) in items.mediaUrlList" :key="mediaIndex">
|
|
|
- <!-- 视频:包含 | 分隔符 -->
|
|
|
- <div v-if="isVideoUrl(item)" class="media-item video-item" @click="playVideo(getVideoUrl(item))">
|
|
|
- <el-image v-if="getVideoCoverUrl(item)" :src="getVideoCoverUrl(item)" fit="cover" class="media-image">
|
|
|
- <template #error>
|
|
|
- <div class="media-placeholder">
|
|
|
- <el-icon class="play-icon">
|
|
|
- <VideoPlay />
|
|
|
- </el-icon>
|
|
|
- </div>
|
|
|
- </template>
|
|
|
- </el-image>
|
|
|
- <div v-else class="media-placeholder">
|
|
|
- <el-icon class="play-icon">
|
|
|
- <VideoPlay />
|
|
|
- </el-icon>
|
|
|
- </div>
|
|
|
- <!-- 视频播放图标覆盖层 -->
|
|
|
+ <template v-for="(m, mediaIndex) in parseAchievementMedia(items)" :key="mediaIndex + m.url">
|
|
|
+ <div v-if="m.type === 'video'" class="media-item video-item" @click="playVideo(m.url)">
|
|
|
+ <video class="video-thumb" :src="thumbVideoSrc(m.url)" muted playsinline preload="metadata" />
|
|
|
<div class="video-overlay">
|
|
|
<el-icon class="play-icon-overlay">
|
|
|
<VideoPlay />
|
|
|
</el-icon>
|
|
|
</div>
|
|
|
</div>
|
|
|
- <!-- 图片:不包含 | 分隔符 -->
|
|
|
- <div v-else class="media-item image-item" @click="previewImage(item, items.mediaUrlList, mediaIndex)">
|
|
|
- <el-image
|
|
|
- :src="item"
|
|
|
- fit="cover"
|
|
|
- class="media-image"
|
|
|
- :preview-src-list="getImageListForItem(items.mediaUrlList)"
|
|
|
- :initial-index="getImageIndex(item, items.mediaUrlList)"
|
|
|
- >
|
|
|
+ <div v-else class="media-item image-item" @click.stop="openImagePreview(items, m.url)">
|
|
|
+ <el-image :src="m.url" fit="cover" class="media-image">
|
|
|
<template #error>
|
|
|
<div class="image-slot">
|
|
|
<el-icon>
|
|
|
@@ -98,6 +76,12 @@
|
|
|
:autoplay="true"
|
|
|
@closed="previewVideo = ''"
|
|
|
/>
|
|
|
+
|
|
|
+ <PcImagePreviewViewer
|
|
|
+ v-model:visible="imagePreviewVisible"
|
|
|
+ :url-list="imagePreviewUrlList"
|
|
|
+ :initial-index="imagePreviewInitialIndex"
|
|
|
+ />
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
@@ -107,6 +91,75 @@ import { useRoute, useRouter } from "vue-router";
|
|
|
import { Picture, VideoPlay } from "@element-plus/icons-vue";
|
|
|
import { getPersonCaseDetail } from "@/api/modules/operationManagement";
|
|
|
import PcVideoPreviewDialog from "@/components/pcMediaPreview/PcVideoPreviewDialog.vue";
|
|
|
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
|
|
|
+
|
|
|
+/** 与 query 无关,仅看路径扩展名(忽略 ? 后参数) */
|
|
|
+function pathLooksVideo(url: string): boolean {
|
|
|
+ const path = String(url || "")
|
|
|
+ .split("?")[0]
|
|
|
+ .split("#")[0]
|
|
|
+ .toLowerCase();
|
|
|
+ return /\.(mp4|m3u8|mov|webm|mkv|mpeg|mpg|avi|3gp)$/i.test(path);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 列表里视频缩略用:尽量用视频自身首帧,不用下一张独立图片当 poster(避免与右侧图片格长得完全一样)。
|
|
|
+ * 对常见 MP4 等追加 `#t=0.001` 促使解码第一帧;HLS(m3u8)或已有 hash 的地址不改动。
|
|
|
+ */
|
|
|
+function thumbVideoSrc(url: string): string {
|
|
|
+ const s = String(url || "").trim();
|
|
|
+ if (!s || s.includes("#")) return s;
|
|
|
+ const pathOnly = s.split("?")[0].toLowerCase();
|
|
|
+ if (/\.m3u8$/i.test(pathOnly)) return s;
|
|
|
+ return `${s}#t=0.001`;
|
|
|
+}
|
|
|
+
|
|
|
+type ParsedMedia = { type: "image" | "video"; url: string };
|
|
|
+
|
|
|
+/**
|
|
|
+ * 成果媒体:仅按英文分号 `;` 切割多条 URL(与后端 mediaUrls / mediaUrlList 一致)。
|
|
|
+ * 每条按扩展名区分视频 / 图片,列表项一一对应网格。
|
|
|
+ */
|
|
|
+function parseAchievementMedia(row: Record<string, unknown>): ParsedMedia[] {
|
|
|
+ const chunks: string[] = [];
|
|
|
+ const pushSplit = (raw: unknown) => {
|
|
|
+ if (raw == null || raw === "") return;
|
|
|
+ if (typeof raw === "string") {
|
|
|
+ raw
|
|
|
+ .split(";")
|
|
|
+ .map(s => s.trim())
|
|
|
+ .filter(Boolean)
|
|
|
+ .forEach(s => chunks.push(s));
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ pushSplit(row.mediaUrls);
|
|
|
+ const list = row.mediaUrlList;
|
|
|
+ const arr = Array.isArray(list) ? list : list != null ? [list] : [];
|
|
|
+ for (const it of arr) {
|
|
|
+ if (typeof it === "string") pushSplit(it);
|
|
|
+ else if (it && typeof it === "object" && "url" in it) pushSplit((it as { url?: string }).url);
|
|
|
+ }
|
|
|
+
|
|
|
+ const seen = new Set<string>();
|
|
|
+ const tokens = chunks.filter(u => (seen.has(u) ? false : (seen.add(u), true)));
|
|
|
+
|
|
|
+ return tokens.map(u => ({
|
|
|
+ type: pathLooksVideo(u) ? ("video" as const) : ("image" as const),
|
|
|
+ url: u
|
|
|
+ }));
|
|
|
+}
|
|
|
+
|
|
|
+function getImagePreviewList(row: Record<string, unknown>): string[] {
|
|
|
+ return parseAchievementMedia(row)
|
|
|
+ .filter(m => m.type === "image")
|
|
|
+ .map(m => m.url);
|
|
|
+}
|
|
|
+
|
|
|
+function getImagePreviewIndex(url: string, row: Record<string, unknown>): number {
|
|
|
+ const imgs = getImagePreviewList(row);
|
|
|
+ return Math.max(0, imgs.indexOf(url));
|
|
|
+}
|
|
|
|
|
|
const route = useRoute();
|
|
|
const router = useRouter();
|
|
|
@@ -114,6 +167,9 @@ const loading = ref(false);
|
|
|
const detail = ref<any>(null);
|
|
|
const videoDialogVisible = ref(false);
|
|
|
const previewVideo = ref("");
|
|
|
+const imagePreviewVisible = ref(false);
|
|
|
+const imagePreviewUrlList = ref<string[]>([]);
|
|
|
+const imagePreviewInitialIndex = ref(0);
|
|
|
|
|
|
const activityId = computed(() => route.query.activityId as string);
|
|
|
const userId = computed(() => route.query.userId as string);
|
|
|
@@ -138,147 +194,13 @@ const formatTime = (time: string | null | undefined) => {
|
|
|
}
|
|
|
};
|
|
|
|
|
|
-type MediaItem = { type: "image" | "video"; url: string; coverUrl?: string };
|
|
|
-
|
|
|
-const mediaList = computed<MediaItem[]>(() => {
|
|
|
- if (!detail.value) return [];
|
|
|
- const list: MediaItem[] = [];
|
|
|
-
|
|
|
- // 优先处理 achievementList[0].mediaUrlList
|
|
|
- if (detail.value.achievementList && detail.value.achievementList[0]?.mediaUrlList) {
|
|
|
- const mediaUrlList = detail.value.achievementList[0].mediaUrlList;
|
|
|
- const arr = Array.isArray(mediaUrlList) ? mediaUrlList : [mediaUrlList];
|
|
|
- for (const it of arr) {
|
|
|
- if (typeof it === "string") {
|
|
|
- // 检查是否包含 | 分隔符(视频格式:xxx.mp4 | XXX.jpg)
|
|
|
- if (it.includes("|")) {
|
|
|
- const parts = it
|
|
|
- .split("|")
|
|
|
- .map(s => s.trim())
|
|
|
- .filter(s => s);
|
|
|
- if (parts.length >= 2) {
|
|
|
- // 第一部分是视频URL,第二部分是封面URL
|
|
|
- list.push({ type: "video", url: parts[0], coverUrl: parts[1] });
|
|
|
- } else if (parts.length === 1) {
|
|
|
- // 只有视频URL,没有封面
|
|
|
- list.push({ type: "video", url: parts[0] });
|
|
|
- }
|
|
|
- } else {
|
|
|
- // 如果是字符串,根据文件扩展名判断类型
|
|
|
- const isVideo = /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it);
|
|
|
- list.push({ type: isVideo ? "video" : "image", url: it });
|
|
|
- }
|
|
|
- } else if (it?.url) {
|
|
|
- // 如果是对象,使用 type 字段或根据 url 判断
|
|
|
- const isVideo = it.type === "video" || /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it.url);
|
|
|
- list.push({ type: isVideo ? "video" : "image", url: it.url, coverUrl: it.coverUrl });
|
|
|
- } else if (it?.mediaUrl) {
|
|
|
- // 处理 mediaUrl 字段
|
|
|
- const isVideo = it.mediaType === "video" || it.type === "video" || /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it.mediaUrl);
|
|
|
- list.push({ type: isVideo ? "video" : "image", url: it.mediaUrl, coverUrl: it.coverUrl });
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 处理 mediaList 字段(可能是数组或对象数组)
|
|
|
- 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") {
|
|
|
- // 检查是否包含 | 分隔符(视频格式:xxx.mp4 | XXX.jpg)
|
|
|
- if (it.includes("|")) {
|
|
|
- const parts = it
|
|
|
- .split("|")
|
|
|
- .map(s => s.trim())
|
|
|
- .filter(s => s);
|
|
|
- if (parts.length >= 2) {
|
|
|
- // 第一部分是视频URL,第二部分是封面URL
|
|
|
- list.push({ type: "video", url: parts[0], coverUrl: parts[1] });
|
|
|
- } else if (parts.length === 1) {
|
|
|
- // 只有视频URL,没有封面
|
|
|
- list.push({ type: "video", url: parts[0] });
|
|
|
- }
|
|
|
- } else {
|
|
|
- const isVideo = /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it);
|
|
|
- list.push({ type: isVideo ? "video" : "image", url: it });
|
|
|
- }
|
|
|
- } else if (it?.url) {
|
|
|
- const isVideo = it.type === "video" || /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it.url);
|
|
|
- list.push({ type: isVideo ? "video" : "image", url: it.url, coverUrl: it.coverUrl });
|
|
|
- } else if (it?.mediaUrl) {
|
|
|
- // 处理 mediaUrl 字段
|
|
|
- const isVideo = it.mediaType === "video" || it.type === "video" || /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it.mediaUrl);
|
|
|
- list.push({ type: isVideo ? "video" : "image", url: it.mediaUrl, coverUrl: it.coverUrl });
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 处理单独的图片和视频数组
|
|
|
- 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));
|
|
|
-
|
|
|
-// 判断是否为视频URL(包含 | 分隔符)
|
|
|
-const isVideoUrl = (url: string) => {
|
|
|
- if (typeof url !== "string") return false;
|
|
|
- return url.includes("|");
|
|
|
-};
|
|
|
-
|
|
|
-// 获取视频URL(从 "视频链接|封面链接" 中提取视频链接)
|
|
|
-const getVideoUrl = (url: string) => {
|
|
|
- if (typeof url !== "string") return "";
|
|
|
- if (url.includes("|")) {
|
|
|
- return url.split("|")[0].trim();
|
|
|
- }
|
|
|
- return url;
|
|
|
-};
|
|
|
-
|
|
|
-// 获取视频封面URL(从 "视频链接|封面链接" 中提取封面链接)
|
|
|
-const getVideoCoverUrl = (url: string) => {
|
|
|
- if (typeof url !== "string") return "";
|
|
|
- if (url.includes("|")) {
|
|
|
- const parts = url.split("|");
|
|
|
- if (parts.length >= 2) {
|
|
|
- return parts[1].trim();
|
|
|
- }
|
|
|
- }
|
|
|
- return "";
|
|
|
-};
|
|
|
-
|
|
|
-// 获取指定 mediaUrlList 中的图片列表(用于预览)
|
|
|
-const getImageListForItem = (mediaUrlList: any[]) => {
|
|
|
- if (!mediaUrlList || !Array.isArray(mediaUrlList)) return [];
|
|
|
- return mediaUrlList
|
|
|
- .filter(item => {
|
|
|
- // 过滤出图片类型(不包含 | 分隔符的)
|
|
|
- if (typeof item === "string") {
|
|
|
- return !item.includes("|");
|
|
|
- }
|
|
|
- return false;
|
|
|
- })
|
|
|
- .map(item => {
|
|
|
- if (typeof item === "string") {
|
|
|
- return item.trim();
|
|
|
- }
|
|
|
- return "";
|
|
|
- })
|
|
|
- .filter(url => url);
|
|
|
-};
|
|
|
-
|
|
|
-// 获取图片在指定 mediaUrlList 中的索引
|
|
|
-const getImageIndex = (url: string, mediaUrlList: any[]) => {
|
|
|
- const imageList = getImageListForItem(mediaUrlList);
|
|
|
- return imageList.indexOf(url);
|
|
|
-};
|
|
|
-
|
|
|
-const previewImage = (url: string, mediaUrlList: any[], index: any) => {
|
|
|
- // el-image 的 preview-src-list 会自动处理预览
|
|
|
-};
|
|
|
+function openImagePreview(row: Record<string, unknown>, url: string) {
|
|
|
+ const list = getImagePreviewList(row);
|
|
|
+ if (!list.length) return;
|
|
|
+ imagePreviewUrlList.value = list;
|
|
|
+ imagePreviewInitialIndex.value = getImagePreviewIndex(url, row);
|
|
|
+ imagePreviewVisible.value = true;
|
|
|
+}
|
|
|
|
|
|
const playVideo = (url: string) => {
|
|
|
previewVideo.value = url;
|
|
|
@@ -386,18 +308,17 @@ onMounted(async () => {
|
|
|
justify-content: center;
|
|
|
width: 200px;
|
|
|
height: 200px;
|
|
|
- background: #000000;
|
|
|
+ background: #1a1a1a;
|
|
|
}
|
|
|
-.media-placeholder {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
+.video-thumb {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ display: block;
|
|
|
width: 100%;
|
|
|
height: 100%;
|
|
|
- color: #909399;
|
|
|
-}
|
|
|
-.play-icon {
|
|
|
- font-size: 40px;
|
|
|
+ pointer-events: none;
|
|
|
+ object-fit: cover;
|
|
|
+ background: #1a1a1a;
|
|
|
}
|
|
|
.video-overlay {
|
|
|
position: absolute;
|