Compare commits

...

1 Commits

Author SHA1 Message Date
xiaoxia-agent e5cdb93c78 fix(viral-video) P0: 修复GET /models响应包装导致页面白屏,数组操作加防御
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m25s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m32s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m43s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m30s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m9s
AI Code Review / AI Code Review (pull_request) Successful in 7m10s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 11m56s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m42s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 14m20s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 33m4s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 2m22s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 5m21s
根因:后端 GET /api/v1/viral-video/models 返回 {"models": [...]} 包装对象,
前端 getViralVideoModels() 声明返回 ViralVideoModel[] 并在 .then 直接对 r.data 调 .some(),
实际 r.data 是对象而非数组,导致 TypeError: s.some is not a function 白屏。

修复:
- 新增 ViralVideoModelsResponse 类型
- getViralVideoModels() 改为泛型 ViralVideoModelsResponse,解析时兼容数组/包装两种返回
- 模型加载 useEffect:list/supported_resolutions 加 Array.isArray 防御
- 模型切换 onChange:m.supported_resolutions 加防御兜底 [480p,720p,1080p]
- 清晰度 Select 过滤池:curM?.supported_resolutions 加 Array.isArray 守卫
- options 渲染:filter 掉非对象空项,label fallback 到 key
- max_duration 加 typeof === 'number' 防御
2026-10-03 19:35:15 +08:00
3 changed files with 53 additions and 20 deletions
+10 -2
View File
@@ -10,6 +10,7 @@ import type {
GenerateCopyRequest,
ConfirmCopyRequest,
ViralVideoModel,
ViralVideoModelsResponse,
} from "./types"
/** 创建爆款视频任务 */
@@ -64,9 +65,16 @@ export function estimateViralVideoCredits(params: {
.then((r) => r.data)
}
/** 获取支持的视频模型列表(GET /viral-video/models) */
/** 获取支持的视频模型列表(GET /viral-video/models)。后端返回 {models: [...]} 包装 */
export function getViralVideoModels() {
return apiClient.get<ViralVideoModel[]>("/viral-video/models").then((r) => r.data)
return apiClient.get<ViralVideoModelsResponse>("/viral-video/models").then((r) => {
const data = r.data as ViralVideoModelsResponse | ViralVideoModel[] | null | undefined
if (Array.isArray(data)) return data
if (data && Array.isArray((data as ViralVideoModelsResponse).models)) {
return (data as ViralVideoModelsResponse).models
}
return []
})
}
/** ── 三步拆分:前端 mock 辅助函数(后端新接口上线后可替换) ── */
+6 -1
View File
@@ -287,11 +287,16 @@ export interface ViralVideoModel {
supports_audio: boolean
supported_resolutions: string[]
max_duration: number
/** 计费模式(可选):per_second / per_video 等,后端预留字段 */
/** 计费模式(可选):per_second / per_video / token 等 */
billing_mode?: string
is_default?: boolean
}
/** GET /viral-video/models 响应包装 */
export interface ViralVideoModelsResponse {
models: ViralVideoModel[]
}
/** v1.6 阶段3请求:用户确认/编辑口播文案后开始单次 Seedance 出片(POST /viral-video/{id}/confirm-copy) */
export interface ConfirmCopyRequest {
/** 用户编辑后的口播文案;为空则使用 AI 生成的 voiceover_script */
@@ -550,20 +550,30 @@ const ViralVideoPage: React.FC = () => {
useEffect(() => {
getViralVideoModels()
.then((list) => {
if (!list || list.length === 0) return
setVideoModels(list)
// 防御:确保 list 是数组
const safeList = Array.isArray(list) ? list : []
if (safeList.length === 0) return
setVideoModels(safeList)
// 如果当前选中的模型不在返回列表里,切换到默认模型并联动修正参数
setTask((t) => {
const inList = list.some((m) => m.key === t.videoModel)
const inList = safeList.some((m) => m && typeof m === "object" && m.key === t.videoModel)
if (inList) return t
const def = list.find((m) => m.is_default) || list[0]
const sortedRes = def.supported_resolutions.slice().sort((a, b) => {
const def =
safeList.find((m) => m && typeof m === "object" && m.is_default) || safeList[0]
if (!def) return t
const defRes = Array.isArray(def.supported_resolutions)
? def.supported_resolutions
: ["480p", "720p", "1080p"]
const sortedRes = defRes.slice().sort((a, b) => {
const ra = RESOLUTION_ORDER.indexOf(a)
const rb = RESOLUTION_ORDER.indexOf(b)
return (rb === -1 ? -1 : rb) - (ra === -1 ? -1 : ra)
})
const newRes = sortedRes[0] || "480p"
const newDur = Math.min(t.duration, def.max_duration)
const newDur = Math.min(
t.duration,
typeof def.max_duration === "number" ? def.max_duration : 15,
)
return { ...t, videoModel: def.key, quality: newRes, duration: newDur }
})
})
@@ -2379,27 +2389,35 @@ const ViralVideoPage: React.FC = () => {
style={{ width: "100%" }}
value={task.videoModel}
onChange={(v) => {
const m = videoModels.find((x) => x.key === v)
const m = videoModels.find((x) => x && x.key === v)
if (!m) {
setTask({ videoModel: v })
return
}
// 切换模型时自动联动修正分辨率/时长
const sortedRes = m.supported_resolutions.slice().sort((a, b) => {
// 切换模型时自动联动修正分辨率/时长(防御:确保 supported_resolutions 为数组)
const mRes = Array.isArray(m.supported_resolutions)
? m.supported_resolutions
: ["480p", "720p", "1080p"]
const sortedRes = mRes.slice().sort((a, b) => {
const ra = RESOLUTION_ORDER.indexOf(a)
const rb = RESOLUTION_ORDER.indexOf(b)
return (rb === -1 ? -1 : rb) - (ra === -1 ? -1 : ra)
})
const newRes = m.supported_resolutions.includes(task.quality)
const newRes = mRes.includes(task.quality)
? task.quality
: sortedRes[0] || "480p"
const newDur = Math.min(task.duration, m.max_duration)
const newDur = Math.min(
task.duration,
typeof m.max_duration === "number" ? m.max_duration : 15,
)
setTask({ videoModel: v, quality: newRes, duration: newDur })
}}
options={videoModels.map((m) => ({
value: m.key,
label: m.display_name,
}))}
options={videoModels
.filter((m) => m && typeof m === "object" && m.key)
.map((m) => ({
value: m.key,
label: m.display_name || m.key,
}))}
/>
</div>
<div className="vv-form-row">
@@ -2410,8 +2428,10 @@ const ViralVideoPage: React.FC = () => {
value={task.quality}
onChange={(v) => setTask({ quality: v })}
options={(() => {
const curM = videoModels.find((m) => m.key === task.videoModel)
const supported = curM?.supported_resolutions
const curM = videoModels.find((m) => m && m.key === task.videoModel)
const supported = Array.isArray(curM?.supported_resolutions)
? curM!.supported_resolutions
: null
const pool =
supported && supported.length > 0
? supported