diff --git a/frontend/src/api/contests.ts b/frontend/src/api/contests.ts index b89dedc..11fe3d4 100644 --- a/frontend/src/api/contests.ts +++ b/frontend/src/api/contests.ts @@ -356,7 +356,8 @@ export interface ContestWork { workNo?: string; title: string; description?: string; - files?: string[]; + /** 字符串 URL 列表或绘本分页快照(pageNo/imageUrl/text/audioUrl)等,可能与 JSON 字符串互转 */ + files?: string[] | Record[] | string; version: number; isLatest: boolean; status: "submitted" | "locked" | "reviewing" | "rejected" | "accepted"; diff --git a/frontend/src/composables/useContestWorkPreviewPages.ts b/frontend/src/composables/useContestWorkPreviewPages.ts new file mode 100644 index 0000000..6afe76b --- /dev/null +++ b/frontend/src/composables/useContestWorkPreviewPages.ts @@ -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(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 + 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 + 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 + 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(work.files as unknown) + const modelFileUrls = collectModelUrlsFromFiles(rawFiles) + + let previewUrls = parseJsonArray(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 => { + if (typeof f !== "object" || f === null) return false + const o = f as Record + 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) { + const previewResult = computed(() => buildContestWorkPreviewPages(workRef.value)) + const pages = computed(() => previewResult.value.pages) + const modelFileUrls = computed(() => previewResult.value.modelFileUrls) + return { pages, modelFileUrls, previewResult } +} diff --git a/frontend/src/views/activities/components/ReviewWorkModal.vue b/frontend/src/views/activities/components/ReviewWorkModal.vue index fc96000..98d38bb 100644 --- a/frontend/src/views/activities/components/ReviewWorkModal.vue +++ b/frontend/src/views/activities/components/ReviewWorkModal.vue @@ -71,6 +71,10 @@ +
{{ currentPageStoryText }}
+
+
@@ -252,7 +256,8 @@ import { EyeOutlined, } from "@ant-design/icons-vue"; 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 type { ContestReviewRule, ReviewDimension } from "@/api/contests"; import type { PresetComment } from "@/api/preset-comments"; @@ -281,7 +286,7 @@ const loading = ref(false); const submitLoading = ref(false); // 作品详情 -const workDetail = ref(null); +const workDetail = ref(null); // 评审规则 const reviewRule = ref(null); @@ -314,84 +319,43 @@ const drawerTitle = computed(() => { return `${contestName}作品评审`; }); -// 模型项目列表 - 解析 files 和 previewUrls 数组 +// 模型项目列表 - 与 WorkDetailModal 共用解析逻辑(绘本多页 / 3D) interface ModelItem { fileUrl: string; previewUrl: string; } +const previewWorkResult = computed(() => buildContestWorkPreviewPages(workDetail.value)); + const modelItems = computed(() => { - if (!workDetail.value) return []; - - // 解析 files 数组 - let files = workDetail.value.files || []; - if (typeof files === "string") { - try { - files = JSON.parse(files); - } 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), + const r = previewWorkResult.value; + const modelUrls = r.modelFileUrls; + const pgs = r.pages; + if (modelUrls.length > 0) { + return modelUrls.map((u, i) => ({ + fileUrl: u, + previewUrl: pgs[i]?.imageUrl || pgs[0]?.imageUrl || "", })); } - + if (pgs.length > 0) { + return pgs.map((p) => ({ fileUrl: "", previewUrl: p.imageUrl })); + } return []; }); -// 获取文件URL(处理相对路径) -const getFileUrl = (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}`; -}; +/** 当前预览页故事文案(与只读详情抽屉一致) */ +const currentPageStoryText = computed(() => { + const pgs = previewWorkResult.value.pages; + if (!pgs.length) return ""; + const t = pgs[currentFileIndex.value]?.text; + return t && String(t).trim() ? String(t).trim() : ""; +}); + +const currentPageAudioUrl = computed(() => { + const pgs = previewWorkResult.value.pages; + if (!pgs.length) return ""; + return pgs[currentFileIndex.value]?.audioUrl || ""; +}); // 当前3D模型URL const currentModelUrl = computed(() => { @@ -418,13 +382,6 @@ const totalScore = computed(() => { 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) => { 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-leave-active { diff --git a/frontend/src/views/contests/components/WorkDetailModal.vue b/frontend/src/views/contests/components/WorkDetailModal.vue index cea8ab7..01f42b6 100644 --- a/frontend/src/views/contests/components/WorkDetailModal.vue +++ b/frontend/src/views/contests/components/WorkDetailModal.vue @@ -31,8 +31,8 @@
作品预览 @@ -50,6 +50,18 @@
+
+ 上一页 + {{ currentPageIndex + 1 }} / {{ pageCount }} + + 下一页 + +
+
{{ currentPageBodyText }}
+ +
+
@@ -143,6 +155,11 @@ import { } from "@ant-design/icons-vue" import dayjs from "dayjs" import { worksApi, reviewsApi, type ContestWork } from "@/api/contests" +import { + buildContestWorkPreviewPages, + isContestWorkModelFile, + normalizeContestWorkAssetUrl +} from "@/composables/useContestWorkPreviewPages" interface Props { open: boolean @@ -163,6 +180,7 @@ const loading = ref(false) const workDetail = ref(null) const reviewRecords = ref([]) const showPreviewBtn = ref(false) +const currentPageIndex = ref(0) /** 评委展示名:接口扁平字段 judgeName,或嵌套 judge(与列表接口一致) */ const judgeNameText = (record: any) => { @@ -195,72 +213,62 @@ const drawerTitle = computed(() => { return "作品详情" }) -// 解析 files 字段(可能是 JSON 字符串) -const parsedFiles = computed(() => { - if (!workDetail.value) return [] - let files = workDetail.value.files || [] - if (typeof files === "string") { - try { - files = JSON.parse(files) - } catch { - files = [] - } - } - if (!Array.isArray(files)) files = [] - return files +/** 绘本多页 + 3D 模型 URL 统一解析 */ +const previewResult = computed(() => buildContestWorkPreviewPages(workDetail.value)) + +const previewPages = computed(() => previewResult.value.pages) +const pageCount = computed(() => previewPages.value.length) + +const currentPreviewImageUrl = computed(() => { + const pages = previewPages.value + if (pages.length === 0) return "" + const idx = Math.min(currentPageIndex.value, pages.length - 1) + return pages[idx]?.imageUrl || "" }) -// 预览图URL -const previewImageUrl = computed(() => { - if (!workDetail.value) return "" - // 优先使用预览图 - if (workDetail.value.previewUrl) { - return workDetail.value.previewUrl - } - // 其次从 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 || "" +const currentPageBodyText = computed(() => { + const pages = previewPages.value + if (pages.length === 0) return "" + const idx = Math.min(currentPageIndex.value, pages.length - 1) + const t = pages[idx]?.text + return t && String(t).trim() ? String(t).trim() : "" }) -// 检查URL或文件名是否是3D模型文件(支持带查询参数的URL) -const isModelFile = (urlOrFileName: string): boolean => { - // 移除查询参数后检查扩展名 - const pathWithoutQuery = urlOrFileName.split("?")[0] - return /\.(glb|gltf|obj|fbx|stl|zip)$/i.test(pathWithoutQuery) -} +const currentPageAudioUrl = computed(() => { + const pages = previewPages.value + if (pages.length === 0) return "" + const idx = Math.min(currentPageIndex.value, pages.length - 1) + 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模型文件 -const hasModelFile = computed(() => { - 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 -}) +const hasModelFile = computed(() => allModelUrlsForViewer.value.length > 0) -// 获取3D模型文件URL -const modelFileUrl = computed(() => { - if (!workDetail.value) return "" - // 优先从 files 数组中查找 - const modelFromFiles = parsedFiles.value.find((url: string) => isModelFile(url)) - if (modelFromFiles) return modelFromFiles - // 其次从 attachments 中查找 - const modelAtt = workDetail.value.attachments?.find( - (att) => isModelFile(att.fileName || "") || isModelFile(att.fileUrl || "") - ) - return modelAtt?.fileUrl || "" -}) +// 获取3D模型文件URL(首项) +const modelFileUrl = computed(() => allModelUrlsForViewer.value[0] || "") + +const goPrevPage = () => { + if (currentPageIndex.value > 0) currentPageIndex.value -= 1 +} + +const goNextPage = () => { + if (currentPageIndex.value < pageCount.value - 1) currentPageIndex.value += 1 +} // 格式化日期时间 const formatDateTime = (dateStr?: string) => { @@ -274,6 +282,7 @@ const fetchWorkDetail = async (id: number) => { try { const detail = await worksApi.getDetail(id) workDetail.value = detail + currentPageIndex.value = 0 // 获取评审记录 await fetchReviewRecords(id) } catch (error: any) { @@ -304,25 +313,18 @@ const handleImageError = (e: Event) => { // 跳转3D模型预览 const handleView3DModel = () => { const tenantCode = route.params.tenantCode as string - console.log("3D模型预览 - modelFileUrl:", modelFileUrl.value) - console.log("3D模型预览 - parsedFiles:", parsedFiles.value) - console.log("3D模型预览 - attachments:", workDetail.value?.attachments) - if (modelFileUrl.value) { - // 收集所有3D模型URL - const allModelUrls = parsedFiles.value.filter((url: string) => isModelFile(url)) - - // 使用 sessionStorage 存储模型URL(与学生端保持一致) + const allModelUrls = allModelUrlsForViewer.value + const primary = allModelUrls[0] || modelFileUrl.value + if (primary) { if (allModelUrls.length > 1) { sessionStorage.setItem("model-viewer-urls", JSON.stringify(allModelUrls)) sessionStorage.setItem("model-viewer-index", "0") sessionStorage.removeItem("model-viewer-url") } else { - sessionStorage.setItem("model-viewer-url", modelFileUrl.value) + sessionStorage.setItem("model-viewer-url", primary) sessionStorage.removeItem("model-viewer-urls") sessionStorage.removeItem("model-viewer-index") } - - // 使用 router.push 跳转 router.push({ 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-image { position: relative;