通过 VITE_AUTO_FILL_TEST 环境变量控制,在 .env.test 中启用, 使测试环境构建后登录框也能自动填充测试账号,方便测试人员使用。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
230 lines
7.4 KiB
TypeScript
230 lines
7.4 KiB
TypeScript
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 }
|
||
}
|