feat: 赛事作品多页预览解析与抽屉分页展示

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-08 17:35:05 +08:00
parent cc5a5fb4e3
commit 88ca6264a1
4 changed files with 404 additions and 149 deletions

View File

@ -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";

View 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 filespageNo / 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 }
}

View File

@ -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 || "";
}; });
// 3DURL // 3DURL
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 {

View File

@ -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 || ""
}) })
// URL3DURL 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
})
// 3DURL // 3DURL
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) {
// 3DURL
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;