diff --git a/apps/web/src/api/scripts/index.ts b/apps/web/src/api/scripts/index.ts new file mode 100644 index 000000000..f9d5b5405 --- /dev/null +++ b/apps/web/src/api/scripts/index.ts @@ -0,0 +1,2 @@ +export * from "./scripts" +export * from "./types" diff --git a/apps/web/src/api/scripts/scripts.ts b/apps/web/src/api/scripts/scripts.ts new file mode 100644 index 000000000..4ee28f146 --- /dev/null +++ b/apps/web/src/api/scripts/scripts.ts @@ -0,0 +1,37 @@ +/** + * 文案库 API + * 对接后端 /api/v1/scripts(CRUD + 列表解包) + */ +import apiClient from "../client" +import type { + ScriptItem, + ScriptListResponse, + CreateScriptRequest, + UpdateScriptRequest, +} from "./types" + +/** 获取文案列表 — 必须解包 items(后端返回 {items,total})*/ +export const getScripts = async (): Promise => { + const response = await apiClient.get("/scripts") + const data = response.data as unknown + if (Array.isArray(data)) return data + const items = (data as { items?: ScriptItem[] })?.items + return Array.isArray(items) ? items : [] +} + +/** 新建文案 */ +export const createScript = async (data: CreateScriptRequest): Promise => { + const response = await apiClient.post("/scripts", data) + return response.data +} + +/** 更新文案 */ +export const updateScript = async (id: string, data: UpdateScriptRequest): Promise => { + const response = await apiClient.put(`/scripts/${id}`, data) + return response.data +} + +/** 删除文案 */ +export const deleteScript = async (id: string): Promise => { + await apiClient.delete(`/scripts/${id}`) +} diff --git a/apps/web/src/api/scripts/types.ts b/apps/web/src/api/scripts/types.ts new file mode 100644 index 000000000..8fa0f30e4 --- /dev/null +++ b/apps/web/src/api/scripts/types.ts @@ -0,0 +1,24 @@ +/** + * 文案库 API — 类型定义 + * 对接后端 /api/v1/scripts + */ +export interface ScriptItem { + id: string + title: string + content: string + char_count: number + created_at: string + updated_at?: string +} + +export interface ScriptListResponse { + items: ScriptItem[] + total: number +} + +export interface CreateScriptRequest { + title: string + content: string +} + +export type UpdateScriptRequest = Partial diff --git a/apps/web/src/api/tts/types.ts b/apps/web/src/api/tts/types.ts index 39e1046d5..c3e686017 100644 --- a/apps/web/src/api/tts/types.ts +++ b/apps/web/src/api/tts/types.ts @@ -103,6 +103,7 @@ export interface TTSPreviewRequest { voice_id: string speed?: number pitch?: number + emotion?: string // 情绪参数:natural/excited/calm/friendly } /** TTS 试听响应 */ diff --git a/apps/web/src/components/layout/MainLayout.css b/apps/web/src/components/layout/MainLayout.css index b2302d8da..890f417b3 100644 --- a/apps/web/src/components/layout/MainLayout.css +++ b/apps/web/src/components/layout/MainLayout.css @@ -5,7 +5,7 @@ .xx-app-shell 全屏 flex 容器 ├── header (xx-top-nav) 顶部导航(Header.tsx 管理) └── .xx-app-body 水平 flex 行 - ├── .xx-app-sidebar 左侧侧边栏(240px / 64px 折叠) + ├── .xx-app-sidebar 左侧侧边栏(128px / 64px 折叠) └── .xx-app-content 主内容区(自适应) 所有尺寸/颜色均使用 global.css 设计系统变量 @@ -31,7 +31,7 @@ /* ── 侧边栏 ───────────────────────────────────────────────── */ .xx-app-sidebar { - width: 240px; + width: 128px; flex-shrink: 0; position: sticky; top: 0; @@ -103,6 +103,12 @@ padding: var(--space-sm); } +/* 展开态(侧边栏 128px)水平 padding 收窄,为菜单文字留出完整一行空间 */ +.xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-content { + padding-left: var(--space-xs); + padding-right: var(--space-xs); +} + /* ── 主内容区 ─────────────────────────────────────────────── */ .xx-app-content { flex: 1; @@ -136,7 +142,7 @@ /* 展开态恢复完整宽度 */ .xx-app-sidebar:not(.xx-collapsed) { - width: 240px; + width: 128px; } .xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-toggle { @@ -159,7 +165,7 @@ top: 56px; /* 移动端 Header 高度 */ left: 0; bottom: 0; - width: 240px; + width: 128px; transform: translateX(-100%); transition: transform var(--transition-slow); box-shadow: none; diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index 848da13bd..55dd53928 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -2,7 +2,7 @@ * MainLayout - 主布局组件(Task 1.2) * * 三栏布局:左侧侧边栏 + 顶部导航栏 + 主内容区 - * - 侧边栏:240px 固定宽度,可折叠至 64px 图标栏 + * - 侧边栏:128px 固定宽度,可折叠至 64px 图标栏 * - 顶部导航:复用 Header 组件(68px 固定高度) * - 主内容区:自适应填充剩余空间 * - 响应式:移动端(<768px)隐藏侧边栏 diff --git a/apps/web/src/components/layout/Sidebar.css b/apps/web/src/components/layout/Sidebar.css index 4a7e957a2..590c23af7 100644 --- a/apps/web/src/components/layout/Sidebar.css +++ b/apps/web/src/components/layout/Sidebar.css @@ -30,7 +30,7 @@ /* 分组标题 */ .xx-sidebar-group-title { - padding: var(--space-sm) var(--space-md) var(--space-xs); + padding: var(--space-sm) var(--space-sm) var(--space-xs); font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); color: var(--text-tertiary); @@ -56,9 +56,9 @@ .xx-sidebar-menu-item { display: flex; align-items: center; - gap: var(--space-sm); - padding: var(--space-sm) var(--space-md); - margin: 0 var(--space-xs); + gap: var(--space-xs); + padding: var(--space-sm) var(--space-xs); + margin: 0 var(--space-xxs); border-radius: var(--radius-sm); cursor: pointer; color: var(--text-secondary); @@ -97,12 +97,12 @@ align-items: center; justify-content: center; flex-shrink: 0; - width: 36px; - height: 36px; - border-radius: 10px; + width: 28px; + height: 28px; + border-radius: 8px; background: #f1f5f9; color: var(--text-secondary); - font-size: 18px; + font-size: 16px; line-height: 1; transition: 0.15s ease; } @@ -119,7 +119,9 @@ /* ── 菜单项文字 ───────────────────────────────────────────── */ .xx-sidebar-menu-label { flex: 1; + min-width: 0; overflow: hidden; + white-space: nowrap; text-overflow: ellipsis; } @@ -133,8 +135,16 @@ /* 折叠时菜单项居中,仅图标 */ .xx-sidebar-nav--collapsed .xx-sidebar-menu-item { justify-content: center; - padding: var(--space-sm); - margin: 0 var(--space-xxs); + padding: var(--space-xs); + margin: 0; +} + +/* 折叠态图标恢复更大尺寸居中 */ +.xx-sidebar-nav--collapsed .xx-sidebar-menu-icon { + width: 32px; + height: 32px; + border-radius: 8px; + font-size: 16px; } /* 折叠时隐藏分组标题 */ diff --git a/apps/web/src/config/navigation.ts b/apps/web/src/config/navigation.ts index 0105d5742..6279ca871 100644 --- a/apps/web/src/config/navigation.ts +++ b/apps/web/src/config/navigation.ts @@ -58,6 +58,12 @@ export const NAV_ITEMS: NavItem[] = [ path: "/app/titles", icon: React.createElement(FileTextOutlined), }, + { + key: "scripts", + label: "文案库", + path: "/app/scripts", + icon: React.createElement(EditOutlined), + }, { key: "voices", label: "配音库", @@ -173,6 +179,12 @@ export const NAV_GROUPS: NavGroup[] = [ path: "/app/titles", icon: React.createElement(FileTextOutlined), }, + { + key: "scripts", + label: "文案库", + path: "/app/scripts", + icon: React.createElement(EditOutlined), + }, { key: "products", label: "成品库", diff --git a/apps/web/src/pages/admin/Admin.css b/apps/web/src/pages/admin/Admin.css index 6eb0fc565..cf549b1a2 100644 --- a/apps/web/src/pages/admin/Admin.css +++ b/apps/web/src/pages/admin/Admin.css @@ -11,7 +11,7 @@ .admin-coming-soon-page { padding: 32px; - max-width: 1400px; + max-width: 1680px; margin: 0 auto; } diff --git a/apps/web/src/pages/ai-avatar/AiAvatar.css b/apps/web/src/pages/ai-avatar/AiAvatar.css index 794ecf42e..209b67fd7 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatar.css +++ b/apps/web/src/pages/ai-avatar/AiAvatar.css @@ -1021,3 +1021,158 @@ font-size: 36px; margin-bottom: 8px; } + +/* ============================================================ + #1809 ④⑤⑥ B-roll 弹窗:选库行 + 文案句子列表 + 自动估算提示 + ============================================================ */ + +/* 宽弹窗:左右两栏 + 句子列表需要更大空间 */ +.aa-modal--wide { + max-width: 960px; + width: 94%; +} + +.aa-broll-modal-body .aa-broll-right { + width: 300px; +} + +/* 左侧素材库选择行 */ +.aa-broll-lib-row { + margin-bottom: 10px; +} + +.aa-broll-lib-row .aa-select { + width: 100%; + height: 36px; + border: 1px solid var(--border-color, #e2e2ea); + border-radius: 8px; + padding: 0 10px; + font-size: 13px; + background: #fff; + color: #1a1a2e; + outline: none; +} + +/* 无缩略图时的素材占位 */ +.aa-broll-asset-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +/* 文案句子列表 */ +.aa-sentence-list { + max-height: 240px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; + padding-right: 4px; +} + +.aa-sentence-item { + display: flex; + align-items: flex-start; + gap: 8px; + width: 100%; + text-align: left; + border: 1px solid var(--border-color, #e2e2ea); + border-radius: 8px; + background: #fff; + padding: 8px 10px; + cursor: pointer; + transition: all 0.15s; +} + +.aa-sentence-item:hover { + border-color: #c0c0d0; + background: #f8f8fc; +} + +.aa-sentence-item.active { + border-color: #7c3aed; + background: #f3edff; +} + +.aa-sentence-item__idx { + flex-shrink: 0; + width: 20px; + height: 20px; + border-radius: 50%; + background: #f0f0f5; + color: #6b6b80; + font-size: 11px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; +} + +.aa-sentence-item.active .aa-sentence-item__idx { + background: #7c3aed; + color: #fff; +} + +.aa-sentence-item__text { + flex: 1; + font-size: 12px; + line-height: 1.5; + color: #1a1a2e; + word-break: break-all; +} + +.aa-sentence-item__time { + flex-shrink: 0; + font-size: 10px; + color: #8c8ca1; + margin-top: 2px; +} + +.aa-sentence-empty { + font-size: 12px; + color: #8c8ca1; + background: #f8f8fc; + border-radius: 8px; + padding: 10px; +} + +/* 选择/估算提示 */ +.aa-broll-hint { + font-size: 12px; + color: #059669; + background: #f8f8fc; + border-radius: 6px; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 4px; +} + +/* ─ 对口型生成弹窗 Spinner ── */ +.aa-lipsync-spinner { + width: 48px; + height: 48px; + border: 4px solid #f0f0f5; + border-top-color: #6366f1; + border-radius: 50%; + animation: aa-spin 0.8s linear infinite; +} + +@keyframes aa-spin { + to { + transform: rotate(360deg); + } +} + +.aa-btn--danger { + background: #ff4d4f; + color: #fff; + border: none; +} + +.aa-btn--danger:hover { + background: #ff7875; +} diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx index 4544f5e04..14a43197a 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -3,6 +3,7 @@ * 5列水平面板布局 */ import React, { useState, useCallback, useEffect, useRef } from "react" +import { message } from "antd" import "./AiAvatar.css" import { useAiAvatar } from "./hooks/useAiAvatar" import { PanelVideoSelector } from "./components/PanelVideoSelector" @@ -12,8 +13,19 @@ import PanelTitleConfig from "./components/PanelTitleConfig" import PanelCoverAndGenerate from "./components/PanelCoverAndGenerate" import { ModalAssetPicker } from "./components/ModalAssetPicker" import ModalBRollEditor from "./components/ModalBRollEditor" -import { getScripts, createLipsyncJob, getLipsyncJob, submitRender } from "./api/aiAvatar" -import { getAssetsByKind } from "@/api/assets" +import { + getScripts, + getAssetById, + createLipsyncJob, + getLipsyncJob, + submitRender, + generateSmartCover, +} from "./api/aiAvatar" +import { + normalizeEmotion, + buildTitleConfigPayload, + buildCoverConfigPayload, +} from "./utils/contract" /** 面板折叠状态 */ type PanelKey = "video" | "voice" | "script" | "title" | "cover" @@ -28,8 +40,14 @@ const AiAvatarPage: React.FC = () => { cover: false, }) - /* ── 素材库弹窗 ── */ - const [bRollAssets, setBRollAssets] = useState([]) + /* ── 对口型生成弹窗 ── */ + const [showLipsyncModal, setShowLipsyncModal] = useState(false) + const [lipsyncStatus, setLipsyncStatus] = useState<"generating" | "completed" | "failed">( + "generating", + ) + const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("") + /* ── 智能封面加载态 ── */ + const [smartCoverLoading, setSmartCoverLoading] = useState(false) /* ── 对口型轮询 ── */ const lipsyncTimerRef = useRef | null>(null) @@ -40,13 +58,54 @@ const AiAvatarPage: React.FC = () => { /* ── 对口型 ── */ const handleGenerateLipsync = useCallback(async () => { - if (!state.selectedVideo || !state.selectedVoice || !state.scriptText) return + // ② 缺项明确提示(#1809):不再静默 return + const video = state.selectedVideo + const voice = state.selectedVoice + const text = state.scriptText.trim() + const missing: string[] = [] + if (!video) missing.push("出镜视频") + if (!voice) missing.push("音色") + if (!text) missing.push("文案") + if (missing.length > 0 || !video || !voice) { + message.warning(`请先选择${missing.join("、")}`) + return + } try { - const job = await createLipsyncJob({ - voice_id: state.selectedVoice.voice_id, - script_text: state.scriptText, - video_asset_id: state.selectedVideo.id, + // 显示生成弹窗 + setShowLipsyncModal(true) + setLipsyncStatus("generating") + setLipsyncErrorMessage("") + + // ① 先按素材 id 拿 file_url(#1809 补充:对齐后端新参数 video_url) + console.log("[对口型] 开始生成:", { + videoId: video.id, + voiceId: voice.voice_id, + voiceType: voice.type, + textLen: state.scriptText.length, }) + const asset = await getAssetById(video.id) + console.log("[对口型] getAssetById 响应:", { + id: asset?.id, + file_url: asset?.file_url?.substring(0, 100), + }) + const videoUrl = asset?.file_url + if (!videoUrl) { + console.error("[对口型] file_url 为空,asset:", asset) + setShowLipsyncModal(false) + message.error("获取出镜视频播放地址失败,请重新选择素材") + return + } + // ② 模式A TTS直生:video_url + voice_id + script_text,语速/情绪英文枚举透传(#1822) + const payload = { + voice_id: voice.voice_id, + script_text: state.scriptText, + video_url: videoUrl, + speed: state.speed, // 语速 0.5~2.0 + emotion: normalizeEmotion(state.emotion), // natural/excited/calm/friendly + } + console.log("[对口型] createLipsyncJob 请求:", payload) + const job = await createLipsyncJob(payload) + console.log("[对口型] createLipsyncJob 响应:", { id: job.id, status: job.status }) state.setLipsyncJob(job) // 开始轮询 if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) @@ -54,18 +113,49 @@ const AiAvatarPage: React.FC = () => { try { const updated = await getLipsyncJob(job.id) state.setLipsyncJob(updated) - if (updated.status === "completed" || updated.status === "failed") { + console.log("[对口型] 轮询状态:", { + id: updated.id, + status: updated.status, + error: updated.error_message, + }) + if (updated.status === "completed") { if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) + setLipsyncStatus("completed") + setTimeout(() => { + setShowLipsyncModal(false) + message.success("对口型视频生成完成") + }, 1000) + } else if (updated.status === "failed") { + if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) + setLipsyncStatus("failed") + setLipsyncErrorMessage(updated.error_message || "对口型生成失败") } - } catch { - // 忽略轮询错误 + } catch (err) { + console.error("[对口型] 轮询错误:", err) } }, 3000) } catch (err) { - console.error("对口型任务创建失败:", err) + console.error("[对口型] 创建失败:", { + status: (err as { response?: { status?: number } })?.response?.status, + data: (err as { response?: { data?: unknown } })?.response?.data, + message: err instanceof Error ? err.message : String(err), + }) + setShowLipsyncModal(false) + message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试") } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.selectedVideo, state.selectedVoice, state.scriptText]) + }, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion]) + + // 取消对口型生成 + const handleCancelLipsync = useCallback(() => { + if (lipsyncTimerRef.current) { + clearInterval(lipsyncTimerRef.current) + lipsyncTimerRef.current = null + } + setShowLipsyncModal(false) + setLipsyncStatus("generating") + setLipsyncErrorMessage("") + }, []) // 清理轮询 useEffect(() => { @@ -74,28 +164,29 @@ const AiAvatarPage: React.FC = () => { } }, []) - /* ── 加载 B-roll 素材 ── */ - useEffect(() => { - getAssetsByKind("video", { limit: 50 }) - .then(setBRollAssets) - .catch(() => {}) - }, []) - /* ── 生成视频 ── */ const handleGenerate = useCallback(async () => { - if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") return + // ② 前置条件提示(#1809) + if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") { + message.warning("请先生成对口型视频,待对口型完成后再提交渲染") + return + } state.setIsGenerating(true) try { await submitRender({ lipsync_job_id: state.lipsyncJob.id, script_id: state.script?.id, b_roll_segments: state.bRollSegments as never, - title_config: state.titleConfig as unknown as Record, - cover_config: state.coverConfig as unknown as Record, + // 字段映射:build_title_drawtext_filter 真实口径 text/font_size/font_color/position/... + title_config: buildTitleConfigPayload(state.titleConfig), + // cover_config:智能封面 cover_url + 截帧 timestamp + cover_config: buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url), resolution: state.resolution, }) + message.success("渲染任务已提交,可在视频管理中查看进度") } catch (err) { console.error("渲染任务提交失败:", err) + message.error(err instanceof Error ? err.message : "渲染任务提交失败,请重试") } finally { state.setIsGenerating(false) } @@ -109,6 +200,37 @@ const AiAvatarPage: React.FC = () => { state.resolution, ]) + /* ── 智能封面:调后端 MediaKit 选帧接口(#1822) ── */ + const handleSmartCover = useCallback(async () => { + // 基于对口型成片抽帧,必须先完成对口型 + const videoUrl = state.lipsyncJob?.output_video_url + if (state.lipsyncJob?.status !== "completed" || !videoUrl) { + message.warning("请先生成对口型视频,完成后再智能获取封面") + return + } + setSmartCoverLoading(true) + try { + const res = await generateSmartCover(videoUrl, 5) + if (res.cover_url) { + state.setCoverConfig((prev) => ({ + ...prev, + mode: "auto_frame", + smart_cover_url: res.cover_url, + thumbnail_url: res.cover_url, + })) + message.success("智能封面已生成") + } else { + message.error(res.message || "智能封面生成失败,请稍后重试") + } + } catch (err) { + console.error("智能封面生成失败:", err) + message.error(err instanceof Error ? err.message : "智能封面生成失败,请重试") + } finally { + setSmartCoverLoading(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.lipsyncJob]) + /* ── 配置汇总 ── */ const summary = { videoName: state.selectedVideo?.name || null, @@ -137,6 +259,7 @@ const AiAvatarPage: React.FC = () => { selectedVideo={state.selectedVideo} onSelectVideo={() => state.setShowAssetPicker(true)} onRemoveVideo={state.removeVideo} + titleConfig={state.titleConfig} /> @@ -206,6 +329,9 @@ const AiAvatarPage: React.FC = () => { onCoverConfigChange={(partial) => state.setCoverConfig((prev) => ({ ...prev, ...partial })) } + onSmartCover={handleSmartCover} + smartCoverLoading={smartCoverLoading} + canSmartCover={state.lipsyncJob?.status === "completed"} resolution={state.resolution} onResolutionChange={state.setResolution} isGenerating={state.isGenerating} @@ -241,11 +367,80 @@ const AiAvatarPage: React.FC = () => { open={state.showBRollModal} onClose={() => state.setShowBRollModal(false)} existingSegments={state.bRollSegments} - availableAssets={bRollAssets} + scriptText={state.scriptText} + outputDuration={state.lipsyncJob?.output_duration ?? 0} onConfirm={state.addBRollSegment} onRemove={state.removeBRollSegment} /> )} + + {/* 对口型生成弹窗 */} + {showLipsyncModal && ( +
+
e.stopPropagation()}> +
+ 对口型生成 + +
+
+ {lipsyncStatus === "generating" && ( + <> +
+
+ 对口型视频生成中… +
+
+ 请勿关闭页面,完成后将自动提示 +
+ + )} + {lipsyncStatus === "completed" && ( + <> +
+
+ 对口型视频生成完成 +
+ + )} + {lipsyncStatus === "failed" && ( + <> +
+
+ 对口型生成失败 +
+ {lipsyncErrorMessage && ( +
+ {lipsyncErrorMessage} +
+ )} + + )} +
+
+ {lipsyncStatus === "generating" && ( + + )} + {lipsyncStatus !== "generating" && ( + + )} +
+
+
+ )}
) } @@ -264,8 +459,8 @@ const ScriptSelectModalLazy: React.FC<{ if (!open) return setLoading(true) getScripts() - .then(setScripts) - .catch(() => {}) + .then((items) => setScripts(Array.isArray(items) ? items : [])) + .catch(() => setScripts([])) .finally(() => setLoading(false)) }, [open]) diff --git a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts index 24acab0ed..87d544150 100644 --- a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts +++ b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts @@ -1,13 +1,17 @@ /** - * AI数字人 — API 封装 + * AI数字人 — API 封装(#1822 契约对齐) */ import apiClient from "@/api/client" import type { Script, LipsyncJob, RenderJob, BRollSegment } from "../types" /* ── 文案库 ── */ export const getScripts = async (): Promise => { - const response = await apiClient.get("/scripts") - return response.data + const response = await apiClient.get<{ items?: Script[] } | Script[]>("/scripts") + // 后端列表返回 { items, total } 分页对象,做兼容解包 + 数组防御(#1809 白屏修复) + const data = response.data as unknown + if (Array.isArray(data)) return data + const items = (data as { items?: Script[] })?.items + return Array.isArray(items) ? items : [] } export const getScriptById = async (id: string): Promise