feat: 赛事作品多页预览解析与抽屉分页展示
Made-with: Cursor
This commit is contained in:
parent
cc5a5fb4e3
commit
88ca6264a1
@ -356,7 +356,8 @@ export interface ContestWork {
|
|||||||
workNo?: string;
|
workNo?: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
files?: string[];
|
/** 字符串 URL 列表或绘本分页快照(pageNo/imageUrl/text/audioUrl)等,可能与 JSON 字符串互转 */
|
||||||
|
files?: string[] | Record<string, unknown>[] | string;
|
||||||
version: number;
|
version: number;
|
||||||
isLatest: boolean;
|
isLatest: boolean;
|
||||||
status: "submitted" | "locked" | "reviewing" | "rejected" | "accepted";
|
status: "submitted" | "locked" | "reviewing" | "rejected" | "accepted";
|
||||||
|
|||||||
229
frontend/src/composables/useContestWorkPreviewPages.ts
Normal file
229
frontend/src/composables/useContestWorkPreviewPages.ts
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import type { Ref } from "vue"
|
||||||
|
import { computed } from "vue"
|
||||||
|
import type { ContestWork } from "@/api/contests"
|
||||||
|
|
||||||
|
/** 单页预览:图片、故事文案、配音(与后端 UGC 快照字段对齐) */
|
||||||
|
export interface ContestWorkPreviewPage {
|
||||||
|
imageUrl: string
|
||||||
|
text?: string
|
||||||
|
/** 单页配音 URL(后端 files 快照中的 audioUrl),无则省略 */
|
||||||
|
audioUrl?: string
|
||||||
|
pageNo?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContestWorkPreviewResult {
|
||||||
|
pages: ContestWorkPreviewPage[]
|
||||||
|
/** 从 files 中解析出的 3D 等模型资源 URL(已规范化),顺序与出现顺序一致 */
|
||||||
|
modelFileUrls: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 OSS 相对路径等规范化为可展示的绝对 URL(与 ReviewWorkModal 行为一致) */
|
||||||
|
export function normalizeContestWorkAssetUrl(fileUrl: string): string {
|
||||||
|
if (!fileUrl) return ""
|
||||||
|
if (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) {
|
||||||
|
return fileUrl
|
||||||
|
}
|
||||||
|
const baseURL = import.meta.env.VITE_API_BASE_URL || ""
|
||||||
|
if (fileUrl.startsWith("/api") && baseURL.includes("/api")) {
|
||||||
|
const urlWithoutApi = baseURL.replace(/\/api$/, "")
|
||||||
|
return `${urlWithoutApi}${fileUrl}`
|
||||||
|
}
|
||||||
|
return `${baseURL}${fileUrl.startsWith("/") ? "" : "/"}${fileUrl}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonArray<T>(raw: unknown): T[] {
|
||||||
|
if (raw == null) return []
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(raw)
|
||||||
|
return Array.isArray(p) ? p : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.isArray(raw) ? raw : []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否为 3D 模型等资源(与 WorkDetailModal 扩展名约定一致) */
|
||||||
|
export function isContestWorkModelFile(urlOrFileName: string): boolean {
|
||||||
|
const pathWithoutQuery = urlOrFileName.split("?")[0]
|
||||||
|
return /\.(glb|gltf|obj|fbx|stl|zip)$/i.test(pathWithoutQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageFileName(url: string): boolean {
|
||||||
|
const pathWithoutQuery = url.split("?")[0]
|
||||||
|
return /\.(jpg|jpeg|png|gif|webp)$/i.test(pathWithoutQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstImageAttachmentUrl(work: ContestWork): string {
|
||||||
|
const att = work.attachments?.find(
|
||||||
|
(a) =>
|
||||||
|
a.fileType?.startsWith("image/") ||
|
||||||
|
/\.(jpg|jpeg|png|gif|webp)$/i.test(a.fileName || "")
|
||||||
|
)
|
||||||
|
return att?.fileUrl ? normalizeContestWorkAssetUrl(att.fileUrl) : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectModelUrlsFromFiles(rawFiles: unknown[]): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
for (const item of rawFiles) {
|
||||||
|
if (typeof item === "string") {
|
||||||
|
if (isContestWorkModelFile(item)) {
|
||||||
|
out.push(normalizeContestWorkAssetUrl(item))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (typeof item === "object" && item !== null) {
|
||||||
|
const o = item as Record<string, unknown>
|
||||||
|
const fu = typeof o.fileUrl === "string" ? o.fileUrl : ""
|
||||||
|
if (fu && isContestWorkModelFile(fu)) {
|
||||||
|
out.push(normalizeContestWorkAssetUrl(fu))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPageMetaFromFileEntry(fileAtIndex: unknown): {
|
||||||
|
text?: string
|
||||||
|
audioUrl?: string
|
||||||
|
pageNo?: number
|
||||||
|
} {
|
||||||
|
if (typeof fileAtIndex !== "object" || fileAtIndex === null) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const o = fileAtIndex as Record<string, unknown>
|
||||||
|
const text = typeof o.text === "string" && o.text.trim() ? o.text : undefined
|
||||||
|
const audioUrl =
|
||||||
|
typeof o.audioUrl === "string" && o.audioUrl.trim()
|
||||||
|
? normalizeContestWorkAssetUrl(o.audioUrl)
|
||||||
|
: undefined
|
||||||
|
const pageNo =
|
||||||
|
typeof o.pageNo === "number"
|
||||||
|
? o.pageNo
|
||||||
|
: typeof o.pageNo === "string" && o.pageNo.trim()
|
||||||
|
? Number(o.pageNo)
|
||||||
|
: undefined
|
||||||
|
return { text, audioUrl, pageNo }
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFileMetaByPageNo(
|
||||||
|
rawFiles: unknown[],
|
||||||
|
pageNo1: number
|
||||||
|
): { text?: string; audioUrl?: string } {
|
||||||
|
for (const f of rawFiles) {
|
||||||
|
if (typeof f !== "object" || f === null) continue
|
||||||
|
const o = f as Record<string, unknown>
|
||||||
|
const pn = o.pageNo
|
||||||
|
const n = typeof pn === "number" ? pn : typeof pn === "string" ? Number(pn) : NaN
|
||||||
|
if (n === pageNo1) {
|
||||||
|
const text = typeof o.text === "string" && o.text.trim() ? o.text : undefined
|
||||||
|
const audioUrl =
|
||||||
|
typeof o.audioUrl === "string" && o.audioUrl.trim()
|
||||||
|
? normalizeContestWorkAssetUrl(o.audioUrl)
|
||||||
|
: undefined
|
||||||
|
return { text, audioUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析赛事作品的多页预览:合并 previewUrls 与结构化 files(pageNo / imageUrl / text / audioUrl)。
|
||||||
|
* 单图、仅附件、仅 3D 等场景通过 pages 数量与 modelFileUrls 组合表达。
|
||||||
|
*/
|
||||||
|
export function buildContestWorkPreviewPages(
|
||||||
|
work: ContestWork | null | undefined
|
||||||
|
): ContestWorkPreviewResult {
|
||||||
|
const empty: ContestWorkPreviewResult = { pages: [], modelFileUrls: [] }
|
||||||
|
if (!work) return empty
|
||||||
|
|
||||||
|
const rawFiles = parseJsonArray<unknown>(work.files as unknown)
|
||||||
|
const modelFileUrls = collectModelUrlsFromFiles(rawFiles)
|
||||||
|
|
||||||
|
let previewUrls = parseJsonArray<string>(work.previewUrls as unknown).map((u) =>
|
||||||
|
String(u).trim()
|
||||||
|
)
|
||||||
|
if (previewUrls.length === 0 && work.previewUrl) {
|
||||||
|
previewUrls = [work.previewUrl]
|
||||||
|
}
|
||||||
|
previewUrls = previewUrls.filter(Boolean).map((u) => normalizeContestWorkAssetUrl(u))
|
||||||
|
|
||||||
|
if (previewUrls.length > 0) {
|
||||||
|
const pages: ContestWorkPreviewPage[] = previewUrls.map((imageUrl, i) => {
|
||||||
|
const fromIndex = readPageMetaFromFileEntry(rawFiles[i])
|
||||||
|
let text = fromIndex.text
|
||||||
|
let audioUrl = fromIndex.audioUrl
|
||||||
|
let pageNo = fromIndex.pageNo ?? i + 1
|
||||||
|
|
||||||
|
if (!text || !audioUrl) {
|
||||||
|
const byNo = findFileMetaByPageNo(rawFiles, i + 1)
|
||||||
|
if (!text && byNo.text) text = byNo.text
|
||||||
|
if (!audioUrl && byNo.audioUrl) audioUrl = byNo.audioUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
return { imageUrl, text, audioUrl, pageNo }
|
||||||
|
})
|
||||||
|
return { pages, modelFileUrls }
|
||||||
|
}
|
||||||
|
|
||||||
|
const structured = rawFiles.filter((f): f is Record<string, unknown> => {
|
||||||
|
if (typeof f !== "object" || f === null) return false
|
||||||
|
const o = f as Record<string, unknown>
|
||||||
|
return typeof o.imageUrl === "string" && String(o.imageUrl).trim() !== ""
|
||||||
|
})
|
||||||
|
|
||||||
|
if (structured.length > 0) {
|
||||||
|
const sorted = [...structured].sort((a, b) => {
|
||||||
|
const pa = Number(a.pageNo) || 0
|
||||||
|
const pb = Number(b.pageNo) || 0
|
||||||
|
return pa - pb
|
||||||
|
})
|
||||||
|
const pages: ContestWorkPreviewPage[] = sorted.map((o) => {
|
||||||
|
const imageUrl = normalizeContestWorkAssetUrl(String(o.imageUrl))
|
||||||
|
const text = typeof o.text === "string" && o.text.trim() ? o.text : undefined
|
||||||
|
const audioUrl =
|
||||||
|
typeof o.audioUrl === "string" && o.audioUrl.trim()
|
||||||
|
? normalizeContestWorkAssetUrl(o.audioUrl)
|
||||||
|
: undefined
|
||||||
|
const pageNo =
|
||||||
|
typeof o.pageNo === "number"
|
||||||
|
? o.pageNo
|
||||||
|
: typeof o.pageNo === "string"
|
||||||
|
? Number(o.pageNo) || undefined
|
||||||
|
: undefined
|
||||||
|
return { imageUrl, text, audioUrl, pageNo }
|
||||||
|
})
|
||||||
|
return { pages, modelFileUrls }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of rawFiles) {
|
||||||
|
if (typeof s === "string" && isImageFileName(s)) {
|
||||||
|
return {
|
||||||
|
pages: [{ imageUrl: normalizeContestWorkAssetUrl(s) }],
|
||||||
|
modelFileUrls,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (work.previewUrl) {
|
||||||
|
return {
|
||||||
|
pages: [{ imageUrl: normalizeContestWorkAssetUrl(work.previewUrl) }],
|
||||||
|
modelFileUrls,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstImg = firstImageAttachmentUrl(work)
|
||||||
|
if (firstImg) {
|
||||||
|
return { pages: [{ imageUrl: firstImg }], modelFileUrls }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pages: [], modelFileUrls }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useContestWorkPreviewPages(workRef: Ref<ContestWork | null | undefined>) {
|
||||||
|
const previewResult = computed(() => buildContestWorkPreviewPages(workRef.value))
|
||||||
|
const pages = computed(() => previewResult.value.pages)
|
||||||
|
const modelFileUrls = computed(() => previewResult.value.modelFileUrls)
|
||||||
|
return { pages, modelFileUrls, previewResult }
|
||||||
|
}
|
||||||
@ -71,6 +71,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="currentPageStoryText" class="preview-story-text">{{ currentPageStoryText }}</div>
|
||||||
|
<div v-if="currentPageAudioUrl" class="preview-page-voice">
|
||||||
|
<audio :src="currentPageAudioUrl" controls preload="metadata" class="voice-audio" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 作品附件 -->
|
<!-- 作品附件 -->
|
||||||
@ -252,7 +256,8 @@ import {
|
|||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
} from "@ant-design/icons-vue";
|
} from "@ant-design/icons-vue";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { worksApi, reviewsApi, contestsApi } from "@/api/contests";
|
import { worksApi, reviewsApi, type ContestWork } from "@/api/contests";
|
||||||
|
import { buildContestWorkPreviewPages } from "@/composables/useContestWorkPreviewPages";
|
||||||
import { presetCommentsApi } from "@/api/preset-comments";
|
import { presetCommentsApi } from "@/api/preset-comments";
|
||||||
import type { ContestReviewRule, ReviewDimension } from "@/api/contests";
|
import type { ContestReviewRule, ReviewDimension } from "@/api/contests";
|
||||||
import type { PresetComment } from "@/api/preset-comments";
|
import type { PresetComment } from "@/api/preset-comments";
|
||||||
@ -281,7 +286,7 @@ const loading = ref(false);
|
|||||||
const submitLoading = ref(false);
|
const submitLoading = ref(false);
|
||||||
|
|
||||||
// 作品详情
|
// 作品详情
|
||||||
const workDetail = ref<any>(null);
|
const workDetail = ref<ContestWork | null>(null);
|
||||||
|
|
||||||
// 评审规则
|
// 评审规则
|
||||||
const reviewRule = ref<ContestReviewRule | null>(null);
|
const reviewRule = ref<ContestReviewRule | null>(null);
|
||||||
@ -314,84 +319,43 @@ const drawerTitle = computed(() => {
|
|||||||
return `${contestName}作品评审`;
|
return `${contestName}作品评审`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 模型项目列表 - 解析 files 和 previewUrls 数组
|
// 模型项目列表 - 与 WorkDetailModal 共用解析逻辑(绘本多页 / 3D)
|
||||||
interface ModelItem {
|
interface ModelItem {
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previewWorkResult = computed(() => buildContestWorkPreviewPages(workDetail.value));
|
||||||
|
|
||||||
const modelItems = computed<ModelItem[]>(() => {
|
const modelItems = computed<ModelItem[]>(() => {
|
||||||
if (!workDetail.value) return [];
|
const r = previewWorkResult.value;
|
||||||
|
const modelUrls = r.modelFileUrls;
|
||||||
// 解析 files 数组
|
const pgs = r.pages;
|
||||||
let files = workDetail.value.files || [];
|
if (modelUrls.length > 0) {
|
||||||
if (typeof files === "string") {
|
return modelUrls.map((u, i) => ({
|
||||||
try {
|
fileUrl: u,
|
||||||
files = JSON.parse(files);
|
previewUrl: pgs[i]?.imageUrl || pgs[0]?.imageUrl || "",
|
||||||
} catch {
|
|
||||||
files = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!Array.isArray(files)) files = [];
|
|
||||||
|
|
||||||
// 解析 previewUrls 数组
|
|
||||||
let previewUrls = workDetail.value.previewUrls || [];
|
|
||||||
if (typeof previewUrls === "string") {
|
|
||||||
try {
|
|
||||||
previewUrls = JSON.parse(previewUrls);
|
|
||||||
} catch {
|
|
||||||
previewUrls = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!Array.isArray(previewUrls)) previewUrls = [];
|
|
||||||
|
|
||||||
// 如果没有 previewUrls 但有单个 previewUrl,使用它
|
|
||||||
if (previewUrls.length === 0 && workDetail.value.previewUrl) {
|
|
||||||
previewUrls = [workDetail.value.previewUrl];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 过滤出3D模型文件
|
|
||||||
const modelFiles = files.filter((f: any) => {
|
|
||||||
const url = typeof f === "object" && f?.fileUrl ? f.fileUrl : f;
|
|
||||||
return url && is3DModel(url);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 构建模型项目列表
|
|
||||||
if (modelFiles.length > 0) {
|
|
||||||
return modelFiles.map((f: any, index: number) => {
|
|
||||||
const fileUrl = typeof f === "object" && f?.fileUrl ? f.fileUrl : f;
|
|
||||||
const previewUrl = previewUrls[index] || previewUrls[0] || "";
|
|
||||||
return {
|
|
||||||
fileUrl: getFileUrl(fileUrl),
|
|
||||||
previewUrl: previewUrl ? getFileUrl(previewUrl) : "",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有3D模型文件,但有预览图,也显示
|
|
||||||
if (previewUrls.length > 0) {
|
|
||||||
return previewUrls.map((url: string) => ({
|
|
||||||
fileUrl: "",
|
|
||||||
previewUrl: getFileUrl(url),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
if (pgs.length > 0) {
|
||||||
|
return pgs.map((p) => ({ fileUrl: "", previewUrl: p.imageUrl }));
|
||||||
|
}
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取文件URL(处理相对路径)
|
/** 当前预览页故事文案(与只读详情抽屉一致) */
|
||||||
const getFileUrl = (fileUrl: string): string => {
|
const currentPageStoryText = computed(() => {
|
||||||
if (!fileUrl) return "";
|
const pgs = previewWorkResult.value.pages;
|
||||||
if (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) {
|
if (!pgs.length) return "";
|
||||||
return fileUrl;
|
const t = pgs[currentFileIndex.value]?.text;
|
||||||
}
|
return t && String(t).trim() ? String(t).trim() : "";
|
||||||
const baseURL = import.meta.env.VITE_API_BASE_URL || "";
|
});
|
||||||
if (fileUrl.startsWith("/api") && baseURL.includes("/api")) {
|
|
||||||
const urlWithoutApi = baseURL.replace(/\/api$/, "");
|
const currentPageAudioUrl = computed(() => {
|
||||||
return `${urlWithoutApi}${fileUrl}`;
|
const pgs = previewWorkResult.value.pages;
|
||||||
}
|
if (!pgs.length) return "";
|
||||||
return `${baseURL}${fileUrl.startsWith("/") ? "" : "/"}${fileUrl}`;
|
return pgs[currentFileIndex.value]?.audioUrl || "";
|
||||||
};
|
});
|
||||||
|
|
||||||
// 当前3D模型URL
|
// 当前3D模型URL
|
||||||
const currentModelUrl = computed(() => {
|
const currentModelUrl = computed(() => {
|
||||||
@ -418,13 +382,6 @@ const totalScore = computed(() => {
|
|||||||
return simpleScore.value || 0;
|
return simpleScore.value || 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 判断是否是3D模型文件
|
|
||||||
const is3DModel = (fileName: string) => {
|
|
||||||
if (!fileName) return false;
|
|
||||||
const ext = fileName.toLowerCase();
|
|
||||||
return ['.glb', '.gltf', '.obj', '.fbx', '.stl'].some(e => ext.endsWith(e));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 格式化日期
|
// 格式化日期
|
||||||
const formatDate = (dateStr?: string) => {
|
const formatDate = (dateStr?: string) => {
|
||||||
if (!dateStr) return "-";
|
if (!dateStr) return "-";
|
||||||
@ -799,6 +756,27 @@ $primary: #0958d9;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-story-text {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-page-voice {
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
.voice-audio {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 淡入淡出动画
|
// 淡入淡出动画
|
||||||
.fade-enter-active,
|
.fade-enter-active,
|
||||||
.fade-leave-active {
|
.fade-leave-active {
|
||||||
|
|||||||
@ -31,8 +31,8 @@
|
|||||||
<div class="preview-container">
|
<div class="preview-container">
|
||||||
<div class="preview-image" @mouseenter="showPreviewBtn = true" @mouseleave="showPreviewBtn = false">
|
<div class="preview-image" @mouseenter="showPreviewBtn = true" @mouseleave="showPreviewBtn = false">
|
||||||
<img
|
<img
|
||||||
v-if="previewImageUrl"
|
v-if="currentPreviewImageUrl"
|
||||||
:src="previewImageUrl"
|
:src="currentPreviewImageUrl"
|
||||||
alt="作品预览"
|
alt="作品预览"
|
||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
/>
|
/>
|
||||||
@ -50,6 +50,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="pageCount > 1" class="preview-pagination">
|
||||||
|
<a-button type="link" :disabled="currentPageIndex === 0" @click="goPrevPage">上一页</a-button>
|
||||||
|
<span class="page-indicator">{{ currentPageIndex + 1 }} / {{ pageCount }}</span>
|
||||||
|
<a-button type="link" :disabled="currentPageIndex >= pageCount - 1" @click="goNextPage">
|
||||||
|
下一页
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="currentPageBodyText" class="preview-page-text">{{ currentPageBodyText }}</div>
|
||||||
|
<!-- 配音:有单页 audioUrl 时展示;后续可在此扩展完整配音能力 -->
|
||||||
|
<div v-if="currentPageAudioUrl" class="preview-page-voice">
|
||||||
|
<audio :src="currentPageAudioUrl" controls preload="metadata" class="voice-audio" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -143,6 +155,11 @@ import {
|
|||||||
} from "@ant-design/icons-vue"
|
} from "@ant-design/icons-vue"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { worksApi, reviewsApi, type ContestWork } from "@/api/contests"
|
import { worksApi, reviewsApi, type ContestWork } from "@/api/contests"
|
||||||
|
import {
|
||||||
|
buildContestWorkPreviewPages,
|
||||||
|
isContestWorkModelFile,
|
||||||
|
normalizeContestWorkAssetUrl
|
||||||
|
} from "@/composables/useContestWorkPreviewPages"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean
|
open: boolean
|
||||||
@ -163,6 +180,7 @@ const loading = ref(false)
|
|||||||
const workDetail = ref<ContestWork | null>(null)
|
const workDetail = ref<ContestWork | null>(null)
|
||||||
const reviewRecords = ref<any[]>([])
|
const reviewRecords = ref<any[]>([])
|
||||||
const showPreviewBtn = ref(false)
|
const showPreviewBtn = ref(false)
|
||||||
|
const currentPageIndex = ref(0)
|
||||||
|
|
||||||
/** 评委展示名:接口扁平字段 judgeName,或嵌套 judge(与列表接口一致) */
|
/** 评委展示名:接口扁平字段 judgeName,或嵌套 judge(与列表接口一致) */
|
||||||
const judgeNameText = (record: any) => {
|
const judgeNameText = (record: any) => {
|
||||||
@ -195,72 +213,62 @@ const drawerTitle = computed(() => {
|
|||||||
return "作品详情"
|
return "作品详情"
|
||||||
})
|
})
|
||||||
|
|
||||||
// 解析 files 字段(可能是 JSON 字符串)
|
/** 绘本多页 + 3D 模型 URL 统一解析 */
|
||||||
const parsedFiles = computed(() => {
|
const previewResult = computed(() => buildContestWorkPreviewPages(workDetail.value))
|
||||||
if (!workDetail.value) return []
|
|
||||||
let files = workDetail.value.files || []
|
const previewPages = computed(() => previewResult.value.pages)
|
||||||
if (typeof files === "string") {
|
const pageCount = computed(() => previewPages.value.length)
|
||||||
try {
|
|
||||||
files = JSON.parse(files)
|
const currentPreviewImageUrl = computed(() => {
|
||||||
} catch {
|
const pages = previewPages.value
|
||||||
files = []
|
if (pages.length === 0) return ""
|
||||||
}
|
const idx = Math.min(currentPageIndex.value, pages.length - 1)
|
||||||
}
|
return pages[idx]?.imageUrl || ""
|
||||||
if (!Array.isArray(files)) files = []
|
|
||||||
return files
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 预览图URL
|
const currentPageBodyText = computed(() => {
|
||||||
const previewImageUrl = computed(() => {
|
const pages = previewPages.value
|
||||||
if (!workDetail.value) return ""
|
if (pages.length === 0) return ""
|
||||||
// 优先使用预览图
|
const idx = Math.min(currentPageIndex.value, pages.length - 1)
|
||||||
if (workDetail.value.previewUrl) {
|
const t = pages[idx]?.text
|
||||||
return workDetail.value.previewUrl
|
return t && String(t).trim() ? String(t).trim() : ""
|
||||||
}
|
|
||||||
// 其次从 files 数组中查找图片
|
|
||||||
const imageFromFiles = parsedFiles.value.find(
|
|
||||||
(url: string) => /\.(jpg|jpeg|png|gif|webp)$/i.test(url)
|
|
||||||
)
|
|
||||||
if (imageFromFiles) return imageFromFiles
|
|
||||||
// 最后从 attachments 中查找
|
|
||||||
const imageAttachment = workDetail.value.attachments?.find(
|
|
||||||
(att) => att.fileType?.startsWith("image/") || /\.(jpg|jpeg|png|gif|webp)$/i.test(att.fileName || "")
|
|
||||||
)
|
|
||||||
return imageAttachment?.fileUrl || ""
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 检查URL或文件名是否是3D模型文件(支持带查询参数的URL)
|
const currentPageAudioUrl = computed(() => {
|
||||||
const isModelFile = (urlOrFileName: string): boolean => {
|
const pages = previewPages.value
|
||||||
// 移除查询参数后检查扩展名
|
if (pages.length === 0) return ""
|
||||||
const pathWithoutQuery = urlOrFileName.split("?")[0]
|
const idx = Math.min(currentPageIndex.value, pages.length - 1)
|
||||||
return /\.(glb|gltf|obj|fbx|stl|zip)$/i.test(pathWithoutQuery)
|
return pages[idx]?.audioUrl || ""
|
||||||
}
|
})
|
||||||
|
|
||||||
|
/** 用于 3D 查看器:优先 composable 解析的模型 URL,否则附件中的模型 */
|
||||||
|
const allModelUrlsForViewer = computed(() => {
|
||||||
|
const fromWork = previewResult.value.modelFileUrls
|
||||||
|
if (fromWork.length > 0) return fromWork
|
||||||
|
const att = workDetail.value?.attachments
|
||||||
|
if (!att?.length) return []
|
||||||
|
return att
|
||||||
|
.filter(
|
||||||
|
(a) =>
|
||||||
|
isContestWorkModelFile(a.fileName || "") || isContestWorkModelFile(a.fileUrl || "")
|
||||||
|
)
|
||||||
|
.map((a) => normalizeContestWorkAssetUrl(a.fileUrl || ""))
|
||||||
|
.filter(Boolean)
|
||||||
|
})
|
||||||
|
|
||||||
// 是否有3D模型文件
|
// 是否有3D模型文件
|
||||||
const hasModelFile = computed(() => {
|
const hasModelFile = computed(() => allModelUrlsForViewer.value.length > 0)
|
||||||
if (!workDetail.value) return false
|
|
||||||
// 检查 files 数组
|
|
||||||
const hasInFiles = parsedFiles.value.some((url: string) => isModelFile(url))
|
|
||||||
if (hasInFiles) return true
|
|
||||||
// 检查 attachments 数组
|
|
||||||
const hasInAttachments = workDetail.value.attachments?.some(
|
|
||||||
(att) => isModelFile(att.fileName || "") || isModelFile(att.fileUrl || "")
|
|
||||||
)
|
|
||||||
return hasInAttachments || false
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取3D模型文件URL
|
// 获取3D模型文件URL(首项)
|
||||||
const modelFileUrl = computed(() => {
|
const modelFileUrl = computed(() => allModelUrlsForViewer.value[0] || "")
|
||||||
if (!workDetail.value) return ""
|
|
||||||
// 优先从 files 数组中查找
|
const goPrevPage = () => {
|
||||||
const modelFromFiles = parsedFiles.value.find((url: string) => isModelFile(url))
|
if (currentPageIndex.value > 0) currentPageIndex.value -= 1
|
||||||
if (modelFromFiles) return modelFromFiles
|
}
|
||||||
// 其次从 attachments 中查找
|
|
||||||
const modelAtt = workDetail.value.attachments?.find(
|
const goNextPage = () => {
|
||||||
(att) => isModelFile(att.fileName || "") || isModelFile(att.fileUrl || "")
|
if (currentPageIndex.value < pageCount.value - 1) currentPageIndex.value += 1
|
||||||
)
|
}
|
||||||
return modelAtt?.fileUrl || ""
|
|
||||||
})
|
|
||||||
|
|
||||||
// 格式化日期时间
|
// 格式化日期时间
|
||||||
const formatDateTime = (dateStr?: string) => {
|
const formatDateTime = (dateStr?: string) => {
|
||||||
@ -274,6 +282,7 @@ const fetchWorkDetail = async (id: number) => {
|
|||||||
try {
|
try {
|
||||||
const detail = await worksApi.getDetail(id)
|
const detail = await worksApi.getDetail(id)
|
||||||
workDetail.value = detail
|
workDetail.value = detail
|
||||||
|
currentPageIndex.value = 0
|
||||||
// 获取评审记录
|
// 获取评审记录
|
||||||
await fetchReviewRecords(id)
|
await fetchReviewRecords(id)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -304,25 +313,18 @@ const handleImageError = (e: Event) => {
|
|||||||
// 跳转3D模型预览
|
// 跳转3D模型预览
|
||||||
const handleView3DModel = () => {
|
const handleView3DModel = () => {
|
||||||
const tenantCode = route.params.tenantCode as string
|
const tenantCode = route.params.tenantCode as string
|
||||||
console.log("3D模型预览 - modelFileUrl:", modelFileUrl.value)
|
const allModelUrls = allModelUrlsForViewer.value
|
||||||
console.log("3D模型预览 - parsedFiles:", parsedFiles.value)
|
const primary = allModelUrls[0] || modelFileUrl.value
|
||||||
console.log("3D模型预览 - attachments:", workDetail.value?.attachments)
|
if (primary) {
|
||||||
if (modelFileUrl.value) {
|
|
||||||
// 收集所有3D模型URL
|
|
||||||
const allModelUrls = parsedFiles.value.filter((url: string) => isModelFile(url))
|
|
||||||
|
|
||||||
// 使用 sessionStorage 存储模型URL(与学生端保持一致)
|
|
||||||
if (allModelUrls.length > 1) {
|
if (allModelUrls.length > 1) {
|
||||||
sessionStorage.setItem("model-viewer-urls", JSON.stringify(allModelUrls))
|
sessionStorage.setItem("model-viewer-urls", JSON.stringify(allModelUrls))
|
||||||
sessionStorage.setItem("model-viewer-index", "0")
|
sessionStorage.setItem("model-viewer-index", "0")
|
||||||
sessionStorage.removeItem("model-viewer-url")
|
sessionStorage.removeItem("model-viewer-url")
|
||||||
} else {
|
} else {
|
||||||
sessionStorage.setItem("model-viewer-url", modelFileUrl.value)
|
sessionStorage.setItem("model-viewer-url", primary)
|
||||||
sessionStorage.removeItem("model-viewer-urls")
|
sessionStorage.removeItem("model-viewer-urls")
|
||||||
sessionStorage.removeItem("model-viewer-index")
|
sessionStorage.removeItem("model-viewer-index")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 router.push 跳转
|
|
||||||
router.push({
|
router.push({
|
||||||
path: `/${tenantCode}/workbench/model-viewer`,
|
path: `/${tenantCode}/workbench/model-viewer`,
|
||||||
})
|
})
|
||||||
@ -411,6 +413,51 @@ watch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
|
||||||
|
.page-indicator {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 52px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-btn-link) {
|
||||||
|
color: #0958d9;
|
||||||
|
padding: 0 4px;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
color: #bfbfbf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-page-text {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-page-voice {
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
.voice-audio {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.preview-container {
|
.preview-container {
|
||||||
.preview-image {
|
.preview-image {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user