From 1931cab5042a0d1b89c01d2dafc1289cb7aa3224 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Tue, 14 Jul 2026 20:17:42 +0800 Subject: [PATCH] =?UTF-8?q?style:=20=E7=94=A8=E9=A1=B9=E7=9B=AEPrettier?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=A0=BC=E5=BC=8F=E5=8C=96products.ts?= =?UTF-8?q?=E5=92=8CGeneratePage.tsx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复CI Prettier检查失败——之前构建运维用默认参数格式化, 与项目实际配置不一致,导致2个文件仍然挂掉。 现在用项目本地npx prettier --write重新格式化。 --- apps/web/src/api/products.ts | 57 +- apps/web/src/pages/generate/GeneratePage.tsx | 764 +++++++++++-------- 2 files changed, 486 insertions(+), 335 deletions(-) diff --git a/apps/web/src/api/products.ts b/apps/web/src/api/products.ts index 1b53a37b2..594519d56 100644 --- a/apps/web/src/api/products.ts +++ b/apps/web/src/api/products.ts @@ -2,12 +2,12 @@ * 成品 / 视频相关 API * 后端无 /products 路由,实际从 /generation/tasks 端点获取数据 */ -import apiClient from './client'; -import { getGenerationTaskResults } from './editPlans'; -import type { GeneratedVideo } from './editPlans'; +import apiClient from "./client"; +import { getGenerationTaskResults } from "./editPlans"; +import type { GeneratedVideo } from "./editPlans"; /** 复核状态 */ -export type ReviewStatus = 'pending_review' | 'approved' | 'rejected'; +export type ReviewStatus = "pending_review" | "approved" | "rejected"; /** 成品条目 */ export interface ProductItem { @@ -18,7 +18,7 @@ export interface ProductItem { duration_seconds?: number; file_size?: number; resolution?: string; - status: 'processing' | 'completed' | 'failed'; + status: "processing" | "completed" | "failed"; /** 复核状态 */ review_status?: ReviewStatus; /** 所属项目 ID */ @@ -36,7 +36,7 @@ export interface ProductListParams { page?: number; page_size?: number; project_id?: string; - review_status?: ReviewStatus | 'all'; + review_status?: ReviewStatus | "all"; } /** 分页响应 */ @@ -50,7 +50,7 @@ export interface ProductListResponse { /** 批量下载任务状态 */ export interface BatchDownloadStatus { job_id: string; - status: 'processing' | 'completed' | 'failed'; + status: "processing" | "completed" | "failed"; /** 完成后返回的下载 URL */ download_url?: string; /** 进度百分比 */ @@ -63,18 +63,19 @@ export interface BatchDownloadStatus { function mapTaskToProductItem(task: GeneratedVideo): ProductItem { return { id: task.id, - title: task.name || '未命名视频', + title: task.name || "未命名视频", video_url: task.file_url, thumbnail_url: task.thumbnail_url, duration_seconds: task.duration, file_size: task.file_size, - resolution: task.width && task.height ? `${task.width}x${task.height}` : undefined, + resolution: + task.width && task.height ? `${task.width}x${task.height}` : undefined, status: - task.status === 'completed' - ? 'completed' - : task.status === 'failed' - ? 'failed' - : 'processing', + task.status === "completed" + ? "completed" + : task.status === "failed" + ? "failed" + : "processing", review_status: task.review_status as ReviewStatus | undefined, project_id: task.project_id, created_at: task.created_at, @@ -83,8 +84,10 @@ function mapTaskToProductItem(task: GeneratedVideo): ProductItem { } /** 获取成品列表(支持分页和筛选)— 实际从 generation tasks 获取 */ -export const getProducts = async (params?: ProductListParams): Promise => { - const response = await apiClient.get('/generation/tasks', { params }); +export const getProducts = async ( + params?: ProductListParams, +): Promise => { + const response = await apiClient.get("/generation/tasks", { params }); const tasks = response.data.items || response.data || []; return tasks.map(mapTaskToProductItem); }; @@ -102,18 +105,18 @@ export const deleteProduct = async (productId: string): Promise => { /** 获取成品下载链接 — 从 generation task results 获取 */ export const getProductDownloadUrl = async ( - productId: string + productId: string, ): Promise<{ url: string; expires_at: string }> => { const videos = await getGenerationTaskResults(productId); const video = videos[0]; - if (!video?.download_url) throw new Error('下载链接不可用'); - return { url: video.download_url, expires_at: '' }; + if (!video?.download_url) throw new Error("下载链接不可用"); + return { url: video.download_url, expires_at: "" }; }; /** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */ export const updateReviewStatus = async ( productId: string, - status: ReviewStatus + status: ReviewStatus, ): Promise => { // 后端暂无 /generation/tasks/{id}/review 端点 // 暂时返回当前状态,后续可扩展 @@ -122,17 +125,21 @@ export const updateReviewStatus = async ( }; /** 发起批量下载 — TODO: 后端暂无对应端点 */ -export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => { +export const batchDownload = async ( + videoIds: string[], +): Promise<{ job_id: string }> => { // 后端暂无 /generation/tasks/batch-download 端点 // 暂时返回模拟 job_id,后续可扩展 - console.warn('[batchDownload] 后端暂无批量下载端点', videoIds); + console.warn("[batchDownload] 后端暂无批量下载端点", videoIds); return { job_id: `mock-${Date.now()}` }; }; /** 查询批量下载状态 — TODO: 后端暂无对应端点 */ -export const getBatchDownloadStatus = async (jobId: string): Promise => { +export const getBatchDownloadStatus = async ( + jobId: string, +): Promise => { // 后端暂无 /generation/tasks/batch-download/{jobId} 端点 // 暂时返回模拟状态,后续可扩展 - console.warn('[getBatchDownloadStatus] 后端暂无批量下载状态端点', jobId); - return { job_id: jobId, status: 'processing', progress: 0 }; + console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId); + return { job_id: jobId, status: "processing", progress: 0 }; }; diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index bf750d9a8..ff57f4c33 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -4,9 +4,9 @@ * 左右布局:左侧 generate-form + 右侧 generate-preview * 保留所有现有业务逻辑(API 调用、URL 参数、CloneModal、TTS 等) */ -import React, { useState, useRef, useCallback, useEffect } from 'react'; -import { useQuery, useMutation } from '@tanstack/react-query'; -import { Typography, message, Select } from 'antd'; +import React, { useState, useRef, useCallback, useEffect } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { Typography, message, Select } from "antd"; import { AudioOutlined, ThunderboltOutlined, @@ -22,62 +22,62 @@ import { PlusOutlined, MinusOutlined, CloseOutlined, -} from '@ant-design/icons'; -import type { AssetItem } from '@/api/assets'; -import { getAssets, getAssetLibraries } from '@/api/assets'; +} from "@ant-design/icons"; +import type { AssetItem } from "@/api/assets"; +import { getAssets, getAssetLibraries } from "@/api/assets"; import { createEditPlan, generateEditPlan, updateEditPlan, getGenerationTaskResults, -} from '@/api/editPlans'; -import type { GeneratedVideo, EditPlanConfig } from '@/api/editPlans'; -import { getEditingTemplates } from '@/api/editingPlanner'; -import { getTitles } from '@/api/titles'; -import apiClient from '@/api/client'; -import { fetchPresetVoices } from '@/api/voices'; -import type { PresetVoiceItem } from '@/api/voices'; -import { formatDuration } from '@/api/voiceClone'; -import type { VoiceClone } from '@/api/voiceClone'; -import CloneModal from '@/components/voice/CloneModal'; -import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from '@/api/tts'; -import { getTags, createTag } from '@/api/tags'; -import { useCloneProgress } from '@/hooks/useCloneProgress'; -import { useSearchParams, useNavigate } from 'react-router-dom'; -import { getEditPlan } from '@/api/editPlans'; -import './generate.css'; +} from "@/api/editPlans"; +import type { GeneratedVideo, EditPlanConfig } from "@/api/editPlans"; +import { getEditingTemplates } from "@/api/editingPlanner"; +import { getTitles } from "@/api/titles"; +import apiClient from "@/api/client"; +import { fetchPresetVoices } from "@/api/voices"; +import type { PresetVoiceItem } from "@/api/voices"; +import { formatDuration } from "@/api/voiceClone"; +import type { VoiceClone } from "@/api/voiceClone"; +import CloneModal from "@/components/voice/CloneModal"; +import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"; +import { getTags, createTag } from "@/api/tags"; +import { useCloneProgress } from "@/hooks/useCloneProgress"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import { getEditPlan } from "@/api/editPlans"; +import "./generate.css"; const { Text } = Typography; /* ── 克隆声音状态配置 ── */ const CLONE_STATUS_CONFIG: Record = { - ready: { label: '就绪', color: 'var(--secondary-color, #10b981)' }, - processing: { label: '克隆中', color: 'var(--accent-color, #f59e0b)' }, - failed: { label: '失败', color: 'var(--error-color, #ef4444)' }, + ready: { label: "就绪", color: "var(--secondary-color, #10b981)" }, + processing: { label: "克隆中", color: "var(--accent-color, #f59e0b)" }, + failed: { label: "失败", color: "var(--error-color, #ef4444)" }, }; /* ── 模板渐变色映射(根据 mode 分配视觉样式) ── */ const MODE_GRADIENTS: Record = { - pip: 'linear-gradient(135deg, #fbbf24, #f59e0b)', - one_take: 'linear-gradient(135deg, #3b82f6, #1d4ed8)', - voice_over: 'linear-gradient(135deg, #6366f1, #4f46e5)', - voice_pip: 'linear-gradient(135deg, #10b981, #059669)', + pip: "linear-gradient(135deg, #fbbf24, #f59e0b)", + one_take: "linear-gradient(135deg, #3b82f6, #1d4ed8)", + voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)", + voice_pip: "linear-gradient(135deg, #10b981, #059669)", }; /* ── 配音预设卡片:从 API 动态生成,不再硬编码 ── */ const VOICE_GENDER_ICON: Record = { - female: '🎀', - male: '🎙️', - child: '🧒', - neutral: '✨', + female: "🎀", + male: "🎙️", + child: "🧒", + neutral: "✨", }; /* ── 步骤定义 ── */ const STEPS = [ - { key: 1, label: '选择模板' }, - { key: 2, label: '选择素材' }, - { key: 3, label: '选择标题' }, - { key: 4, label: '选择配音' }, - { key: 5, label: '确认生成' }, + { key: 1, label: "选择模板" }, + { key: 2, label: "选择素材" }, + { key: 3, label: "选择标题" }, + { key: 4, label: "选择配音" }, + { key: 5, label: "确认生成" }, ]; /* ── 标题选项从 API 加载,不再硬编码 ── */ @@ -93,9 +93,9 @@ const GeneratePage: React.FC = () => { const [currentStep, setCurrentStep] = useState(1); /* ── 模板(从 API 加载用户自制模板) ── */ - const [selectedTemplate, setSelectedTemplate] = useState(''); + const [selectedTemplate, setSelectedTemplate] = useState(""); const { data: userTemplates = [] } = useQuery({ - queryKey: ['generate-templates'], + queryKey: ["generate-templates"], queryFn: () => getEditingTemplates(), staleTime: 60_000, }); @@ -109,12 +109,12 @@ const GeneratePage: React.FC = () => { /* ── 素材 ── */ const [selectedMaterials, setSelectedMaterials] = useState([]); /* 素材选择模式:手动选择 / 自动匹配 */ - const [materialMode, setMaterialMode] = useState<'manual' | 'auto'>('manual'); + const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual"); /* ── 标题 ── */ - const [title, setTitle] = useState(''); + const [title, setTitle] = useState(""); const { data: userTitles = [] } = useQuery({ - queryKey: ['titles'], + queryKey: ["titles"], queryFn: () => getTitles(), staleTime: 30_000, }); @@ -127,23 +127,25 @@ const GeneratePage: React.FC = () => { }, [selectedTemplate, userTemplates]); /* ── 配音 ── */ - const [selectedVoice, setSelectedVoice] = useState(''); + const [selectedVoice, setSelectedVoice] = useState(""); const [playingVoice, setPlayingVoice] = useState(null); - const [voiceMode, setVoiceMode] = useState<'preset' | 'custom' | 'clone'>('preset'); - const [customVoiceText, setCustomVoiceText] = useState(''); + const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">( + "preset", + ); + const [customVoiceText, setCustomVoiceText] = useState(""); /* ── 生成数量 ── */ const [generateCount, setGenerateCount] = useState(1); /* ── 克隆声音 ── */ - const [selectedClonedVoice, setSelectedClonedVoice] = useState(''); + const [selectedClonedVoice, setSelectedClonedVoice] = useState(""); const [cloneModalOpen, setCloneModalOpen] = useState(false); const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress(); /* ── 高级设置(隐藏但保留) ── */ - const [videoRatio] = useState('16:9'); + const [videoRatio] = useState("16:9"); const [duration] = useState(30); - const [style] = useState('business'); + const [style] = useState("business"); const [autoSubtitles] = useState(true); const [bgm] = useState(true); @@ -153,16 +155,16 @@ const GeneratePage: React.FC = () => { const [generated, setGenerated] = useState(false); const [generateError, setGenerateError] = useState(null); const [generatedVideos, setGeneratedVideos] = useState([]); - const [videoUrl, setVideoUrl] = useState(''); - const [thumbnailUrl, setThumbnailUrl] = useState(''); + const [videoUrl, setVideoUrl] = useState(""); + const [thumbnailUrl, setThumbnailUrl] = useState(""); const progressTimer = useRef>(undefined); const audioRef = useRef(null); /* ── URL 参数:从剪辑计划跳转过来时携带 edit_plan_id + plan_config ── */ const [searchParams] = useSearchParams(); - const editPlanId = searchParams.get('edit_plan_id'); - const planConfigStr = searchParams.get('plan_config'); + const editPlanId = searchParams.get("edit_plan_id"); + const planConfigStr = searchParams.get("plan_config"); /** 解析 plan_config 并自动填充表单 */ useEffect(() => { @@ -189,7 +191,7 @@ const GeneratePage: React.FC = () => { } } } catch (err) { - console.warn('解析 plan_config 失败:', err); + console.warn("解析 plan_config 失败:", err); } }, [planConfigStr]); @@ -202,10 +204,12 @@ const GeneratePage: React.FC = () => { if (plan.name) setTitle(plan.name); const cfg = plan.config; if (cfg?.asset_ids) { - setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === 'string')); + setSelectedMaterials( + cfg.asset_ids.filter((v): v is string => typeof v === "string"), + ); } } catch (err) { - console.warn('加载剪辑计划配置失败:', err); + console.warn("加载剪辑计划配置失败:", err); } }; loadPlanConfig(); @@ -213,7 +217,7 @@ const GeneratePage: React.FC = () => { /* ── 预置音色 API ── */ const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({ - queryKey: ['preset-voices'], + queryKey: ["preset-voices"], queryFn: fetchPresetVoices, }); const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? []; @@ -223,27 +227,29 @@ const GeneratePage: React.FC = () => { const [ttsError, setTtsError] = useState(null); const [ttsJobId, setTtsJobId] = useState(null); /** 合成完成后保留的 job ID,用于"存为素材" */ - const [completedTtsJobId, setCompletedTtsJobId] = useState(null); + const [completedTtsJobId, setCompletedTtsJobId] = useState( + null, + ); /* ── 存为素材弹窗状态 ── */ const [saveModalOpen, setSaveModalOpen] = useState(false); - const [saveName, setSaveName] = useState(''); + const [saveName, setSaveName] = useState(""); const [saveTagIds, setSaveTagIds] = useState([]); - const [saveNewTag, setSaveNewTag] = useState(''); + const [saveNewTag, setSaveNewTag] = useState(""); /* ── 标签列表(用于存为素材弹窗) ── */ const { data: allTags = [] } = useQuery({ - queryKey: ['generate-save-tags'], + queryKey: ["generate-save-tags"], queryFn: getTags, staleTime: 30_000, }); /* ── 素材数据 API ── */ const { data: libraries = [] } = useQuery({ - queryKey: ['asset-libraries'], + queryKey: ["asset-libraries"], queryFn: getAssetLibraries, }); - const [selectedLibraryId, setSelectedLibraryId] = useState(''); + const [selectedLibraryId, setSelectedLibraryId] = useState(""); // 自动选中第一个素材库 useEffect(() => { @@ -252,8 +258,10 @@ const GeneratePage: React.FC = () => { } }, [libraries, selectedLibraryId]); - const { data: materials = [], isLoading: materialsLoading } = useQuery({ - queryKey: ['generate-assets', selectedLibraryId], + const { data: materials = [], isLoading: materialsLoading } = useQuery< + AssetItem[] + >({ + queryKey: ["generate-assets", selectedLibraryId], queryFn: () => getAssets(selectedLibraryId), enabled: !!selectedLibraryId, }); @@ -262,9 +270,9 @@ const GeneratePage: React.FC = () => { (voice: VoiceClone) => { addClone(voice); setCloneModalOpen(false); - message.success('音色克隆成功!'); + message.success("音色克隆成功!"); }, - [addClone] + [addClone], ); /* ── 事件 ── */ @@ -279,13 +287,13 @@ const GeneratePage: React.FC = () => { } audioRef.current?.pause(); if (!previewUrl) { - message.warning('该音色暂无试听音频'); + message.warning("该音色暂无试听音频"); return; } const audio = new Audio(previewUrl); audioRef.current = audio; audio.play().catch(() => { - message.error('播放失败,请检查网络'); + message.error("播放失败,请检查网络"); }); audio.onended = () => { setPlayingVoice(null); @@ -293,7 +301,7 @@ const GeneratePage: React.FC = () => { }; setPlayingVoice(voiceId); }, - [playingVoice] + [playingVoice], ); /* ── TTS mutation ── */ @@ -301,10 +309,10 @@ const GeneratePage: React.FC = () => { mutationFn: synthesizeSpeech, onSuccess: (data) => { setTtsJobId(data.job_id); - message.info('语音合成已提交,等待处理…'); + message.info("语音合成已提交,等待处理…"); }, onError: () => { - setTtsError('语音合成请求失败,请重试'); + setTtsError("语音合成请求失败,请重试"); }, }); @@ -318,23 +326,23 @@ const GeneratePage: React.FC = () => { try { const status = await getTTSJobStatus(ttsJobId); if (cancelled) return; - if (status.status === 'completed') { + if (status.status === "completed") { setCustomAudioUrl(status.output_audio_url); setCompletedTtsJobId(ttsJobId); setTtsJobId(null); setTtsError(null); - message.success('语音合成完成!'); + message.success("语音合成完成!"); return; } - if (status.status === 'failed' || status.status === 'cancelled') { - setTtsError(status.error_message || '语音合成失败'); + if (status.status === "failed" || status.status === "cancelled") { + setTtsError(status.error_message || "语音合成失败"); setTtsJobId(null); return; } timer = setTimeout(poll, 2000); } catch { if (!cancelled) { - setTtsError('查询合成状态失败'); + setTtsError("查询合成状态失败"); setTtsJobId(null); } } @@ -350,7 +358,7 @@ const GeneratePage: React.FC = () => { /** 触发自定义文本 TTS 合成 */ const handleSynthesizeVoice = useCallback(() => { if (!customVoiceText.trim()) { - message.warning('请先输入配音文案'); + message.warning("请先输入配音文案"); return; } setTtsError(null); @@ -358,7 +366,7 @@ const GeneratePage: React.FC = () => { synthesizeMutation.mutate({ text: customVoiceText.trim(), voice_id: selectedVoice || undefined, - language: 'zh-CN', + language: "zh-CN", }); }, [customVoiceText, selectedVoice, synthesizeMutation]); @@ -370,12 +378,12 @@ const GeneratePage: React.FC = () => { message.success({ content: ( - 已保存到配音素材库!{' '} + 已保存到配音素材库!{" "} 去素材库查看 @@ -385,22 +393,22 @@ const GeneratePage: React.FC = () => { duration: 5, }); setSaveModalOpen(false); - setSaveName(''); + setSaveName(""); setSaveTagIds([]); - setSaveNewTag(''); + setSaveNewTag(""); setCompletedTtsJobId(null); setCustomAudioUrl(null); }, onError: (err: Error) => { - message.error(`保存失败:${err.message || '请重试'}`); + message.error(`保存失败:${err.message || "请重试"}`); }, }); /** 打开存为素材弹窗 */ const handleOpenSaveModal = useCallback(() => { - setSaveName(''); + setSaveName(""); setSaveTagIds([]); - setSaveNewTag(''); + setSaveNewTag(""); setSaveModalOpen(true); }, []); @@ -429,37 +437,37 @@ const GeneratePage: React.FC = () => { try { const created = await createTag(trimmed); setSaveTagIds((prev) => [...prev, created.id]); - setSaveNewTag(''); + setSaveNewTag(""); } catch { message.error(`创建标签"${trimmed}"失败`); } }, - [allTags, saveTagIds] + [allTags, saveTagIds], ); /** 保存成功后跳转到素材库 */ const handleGoToLibrary = useCallback(() => { - navigate('/app/voice-materials'); + navigate("/app/voice-materials"); }, [navigate]); const handleGenerate = useCallback(async () => { - console.log('[handleGenerate] 开始生成, 参数:', { + console.log("[handleGenerate] 开始生成, 参数:", { title, selectedTemplate, selectedMaterials, voiceMode, }); if (!title.trim()) { - message.warning('请先选择或输入标题'); + message.warning("请先选择或输入标题"); return; } - if (materialMode === 'manual' && selectedMaterials.length === 0) { - message.warning('请至少选择一个素材'); + if (materialMode === "manual" && selectedMaterials.length === 0) { + message.warning("请至少选择一个素材"); return; } - if (voiceMode === 'clone' && !selectedClonedVoice) { - message.warning('请先选择一个克隆音色'); + if (voiceMode === "clone" && !selectedClonedVoice) { + message.warning("请先选择一个克隆音色"); return; } @@ -471,16 +479,20 @@ const GeneratePage: React.FC = () => { try { const voiceConfig: Pick< EditPlanConfig, - 'voice_id' | 'voice_clone_profile_id' | 'custom_audio_url' | 'custom_text' + | "voice_id" + | "voice_clone_profile_id" + | "custom_audio_url" + | "custom_text" > = {}; - if (voiceMode === 'preset') { + if (voiceMode === "preset") { voiceConfig.voice_id = selectedVoice || undefined; - } else if (voiceMode === 'clone') { + } else if (voiceMode === "clone") { voiceConfig.voice_clone_profile_id = selectedClonedVoice || undefined; - } else if (voiceMode === 'custom') { + } else if (voiceMode === "custom") { voiceConfig.voice_id = selectedVoice || undefined; if (customAudioUrl) voiceConfig.custom_audio_url = customAudioUrl; - if (customVoiceText.trim()) voiceConfig.custom_text = customVoiceText.trim(); + if (customVoiceText.trim()) + voiceConfig.custom_text = customVoiceText.trim(); } const plan = await createEditPlan({ @@ -502,16 +514,18 @@ const GeneratePage: React.FC = () => { }); // 后端要求计划处于 editing 状态才能触发渲染,自动转换状态 - await updateEditPlan(plan.id, { status: 'editing' }); + await updateEditPlan(plan.id, { status: "editing" }); await generateEditPlan(plan.id); const poll = async () => { try { - const status = await apiClient.get(`/edit-plans/${plan.id}/generation-status`); + const status = await apiClient.get( + `/edit-plans/${plan.id}/generation-status`, + ); const data = status.data; - if (data.plan_status === 'completed') { + if (data.plan_status === "completed") { setProgress(100); setGenerating(false); setGenerated(true); @@ -519,21 +533,25 @@ const GeneratePage: React.FC = () => { // 获取生成的视频结果 if (data.generation_task_id) { try { - const videos = await getGenerationTaskResults(data.generation_task_id); + const videos = await getGenerationTaskResults( + data.generation_task_id, + ); setGeneratedVideos(videos); if (videos.length > 0) { - setVideoUrl(videos[0].file_url || videos[0].download_url || ''); - setThumbnailUrl(videos[0].thumbnail_url || ''); + setVideoUrl( + videos[0].file_url || videos[0].download_url || "", + ); + setThumbnailUrl(videos[0].thumbnail_url || ""); } } catch (err) { - console.error('[获取生成结果失败]', err); + console.error("[获取生成结果失败]", err); } } - message.success('视频生成完成!'); + message.success("视频生成完成!"); return; } - if (data.plan_status === 'failed') { + if (data.plan_status === "failed") { setGenerating(false); // 提取后端返回的错误详情,便于排查 // 注意:后端返回的 error_message/error/message 可能是对象而非字符串 @@ -541,25 +559,27 @@ const GeneratePage: React.FC = () => { data.error_message || data.error || data.message || - (data.clips || []).find((c: { status: string }) => c.status === 'failed') - ?.error_message || - '视频生成失败,请联系管理员或重试'; + (data.clips || []).find( + (c: { status: string }) => c.status === "failed", + )?.error_message || + "视频生成失败,请联系管理员或重试"; // 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等嵌套结构) const safeExtract = (val: unknown): string => { - if (typeof val === 'string') return val; - if (typeof val === 'object' && val !== null) { + if (typeof val === "string") return val; + if (typeof val === "object" && val !== null) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定 const obj = val as Record; - if (typeof obj.message === 'string') return obj.message; - if (typeof obj.msg === 'string') return obj.msg; - if (typeof obj.detail === 'string') return obj.detail; - if (obj.message && typeof obj.message === 'object') return safeExtract(obj.message); + if (typeof obj.message === "string") return obj.message; + if (typeof obj.msg === "string") return obj.msg; + if (typeof obj.detail === "string") return obj.detail; + if (obj.message && typeof obj.message === "object") + return safeExtract(obj.message); return JSON.stringify(val); } - return String(val ?? ''); + return String(val ?? ""); }; const errorMsg = safeExtract(rawMsg); - console.error('[生成失败] planId:', plan.id, '响应:', data); + console.error("[生成失败] planId:", plan.id, "响应:", data); setGenerateError(errorMsg); message.error(errorMsg); return; @@ -567,24 +587,30 @@ const GeneratePage: React.FC = () => { const clips = data.clips || []; const total = clips.length || 1; - const done = clips.filter((c: { status: string }) => c.status === 'completed').length; + const done = clips.filter( + (c: { status: string }) => c.status === "completed", + ).length; setProgress(Math.round((done / total) * 100)); - progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType< - typeof setInterval - >; + progressTimer.current = setTimeout( + poll, + 2000, + ) as unknown as ReturnType; } catch (pollErr) { // 轮询接口本身出错(网络/鉴权等),记录并继续轮询一次 - console.error('[轮询出错] planId:', plan.id, pollErr); - progressTimer.current = setTimeout(poll, 3000) as unknown as ReturnType< - typeof setInterval - >; + console.error("[轮询出错] planId:", plan.id, pollErr); + progressTimer.current = setTimeout( + poll, + 3000, + ) as unknown as ReturnType; } }; - progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType; + progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType< + typeof setInterval + >; } catch (err: unknown) { - console.error('[handleGenerate] 生成失败:', err); + console.error("[handleGenerate] 生成失败:", err); setGenerating(false); // 提取 axios 响应中的后端错误信息 const axiosErr = err as { @@ -600,20 +626,21 @@ const GeneratePage: React.FC = () => { }; // 安全提取错误消息:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等) const extractString = (val: unknown): string => { - if (typeof val === 'string') return val; - if (typeof val === 'object' && val !== null) { + if (typeof val === "string") return val; + if (typeof val === "object" && val !== null) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定 const obj = val as Record; - if (typeof obj.message === 'string') return obj.message; - if (typeof obj.msg === 'string') return obj.msg; - if (typeof obj.detail === 'string') return obj.detail; + if (typeof obj.message === "string") return obj.message; + if (typeof obj.msg === "string") return obj.msg; + if (typeof obj.detail === "string") return obj.detail; // 嵌套对象:递归提取 - if (typeof obj.message === 'object' && obj.message !== null) + if (typeof obj.message === "object" && obj.message !== null) return extractString(obj.message); - if (typeof obj.msg === 'object' && obj.msg !== null) return extractString(obj.msg); + if (typeof obj.msg === "object" && obj.msg !== null) + return extractString(obj.msg); return JSON.stringify(val); } - return ''; + return ""; }; const backendMsg = extractString(axiosErr.response?.data?.message) || @@ -621,49 +648,75 @@ const GeneratePage: React.FC = () => { extractString(axiosErr.response?.data?.detail) || extractString(axiosErr.response?.data?.msg) || axiosErr.message || - ''; - console.error('[handleGenerate] 错误信息:', backendMsg, '完整错误:', axiosErr); + ""; + console.error( + "[handleGenerate] 错误信息:", + backendMsg, + "完整错误:", + axiosErr, + ); // 确保 errorMsg 一定是字符串(后端可能返回 {code, message} 嵌套对象) const safeExtractErr = (val: unknown): string => { - if (typeof val === 'string') return val; - if (typeof val === 'object' && val !== null) { + if (typeof val === "string") return val; + if (typeof val === "object" && val !== null) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定 const obj = val as Record; - if (typeof obj.message === 'string') return obj.message; - if (typeof obj.msg === 'string') return obj.msg; - if (typeof obj.detail === 'string') return obj.detail; + if (typeof obj.message === "string") return obj.message; + if (typeof obj.msg === "string") return obj.msg; + if (typeof obj.detail === "string") return obj.detail; // 嵌套对象:递归提取 - if (typeof obj.message === 'object') return safeExtractErr(obj.message); + if (typeof obj.message === "object") + return safeExtractErr(obj.message); return JSON.stringify(val); } - return String(val ?? ''); + return String(val ?? ""); }; const rawError = safeExtractErr(backendMsg); // 将技术错误翻译为用户友好提示(不暴露状态机、字段名等内部概念) const translateError = (msg: string): string => { - if (!msg) return '生成失败,请检查网络后重试或联系管理员'; + if (!msg) return "生成失败,请检查网络后重试或联系管理员"; // 状态机相关错误 - if (msg.includes('editing') || msg.includes('draft') || msg.includes('状态')) { - return '正在准备生成,请稍候再试'; + if ( + msg.includes("editing") || + msg.includes("draft") || + msg.includes("状态") + ) { + return "正在准备生成,请稍候再试"; } // 参数校验错误 - if (msg.includes('template_id') || msg.includes('not found') || msg.includes('不存在')) { - return '所选模板或素材不可用,请重新选择'; + if ( + msg.includes("template_id") || + msg.includes("not found") || + msg.includes("不存在") + ) { + return "所选模板或素材不可用,请重新选择"; } - if (msg.includes('asset') && (msg.includes('not found') || msg.includes('missing'))) { - return '素材数据异常,请返回素材库重新检查'; + if ( + msg.includes("asset") && + (msg.includes("not found") || msg.includes("missing")) + ) { + return "素材数据异常,请返回素材库重新检查"; } // 网络/超时 - if (msg.includes('timeout') || msg.includes('network') || msg.includes('ECONN')) { - return '网络连接超时,请检查网络后重试'; + if ( + msg.includes("timeout") || + msg.includes("network") || + msg.includes("ECONN") + ) { + return "网络连接超时,请检查网络后重试"; } // 配额/限制 - if (msg.includes('quota') || msg.includes('limit') || msg.includes('exceed')) { - return '已达到生成次数上限,请稍后再试或联系客服'; + if ( + msg.includes("quota") || + msg.includes("limit") || + msg.includes("exceed") + ) { + return "已达到生成次数上限,请稍后再试或联系客服"; } // 兜底:返回原始消息(如果已经是中文人话)或默认提示 - if (msg.length > 0 && msg.length < 100 && !msg.includes('{')) return msg; - return '生成失败,请稍后重试或联系管理员'; + if (msg.length > 0 && msg.length < 100 && !msg.includes("{")) + return msg; + return "生成失败,请稍后重试或联系管理员"; }; const finalMsg = translateError(rawError); setGenerateError(finalMsg); @@ -696,17 +749,17 @@ const GeneratePage: React.FC = () => { // 优先使用 download_url(签名 URL),回退到 file_url const url = video.download_url || video.file_url; if (url) { - const a = document.createElement('a'); + const a = document.createElement("a"); a.href = url; - a.download = video.name || 'generated-video.mp4'; - a.target = '_blank'; + a.download = video.name || "generated-video.mp4"; + a.target = "_blank"; document.body.appendChild(a); a.click(); document.body.removeChild(a); } } catch (err) { - console.error('[下载失败]', err); - message.error('下载失败,请重试'); + console.error("[下载失败]", err); + message.error("下载失败,请重试"); } }, [generatedVideos]); @@ -717,7 +770,7 @@ const GeneratePage: React.FC = () => { const shareUrl = video.file_url || window.location.href; try { await navigator.clipboard.writeText(shareUrl); - message.success('视频链接已复制到剪贴板'); + message.success("视频链接已复制到剪贴板"); } catch { // fallback: 显示 URL 让用户手动复制 message.info(`视频链接: ${shareUrl}`); @@ -727,21 +780,31 @@ const GeneratePage: React.FC = () => { /* ── 步骤导航 ── */ const goNext = useCallback(() => { if (currentStep === 1 && !selectedTemplate) { - message.warning('请先选择一个模板'); + message.warning("请先选择一个模板"); return; } - if (currentStep === 2 && materialMode === 'manual' && selectedMaterials.length === 0) { - message.warning('请至少选择一个素材'); + if ( + currentStep === 2 && + materialMode === "manual" && + selectedMaterials.length === 0 + ) { + message.warning("请至少选择一个素材"); return; } if (currentStep === 3 && !title.trim()) { - message.warning('请选择或输入标题'); + message.warning("请选择或输入标题"); return; } if (currentStep < 5) { setCurrentStep((s) => s + 1); } - }, [currentStep, selectedTemplate, selectedMaterials.length, title, materialMode]); + }, [ + currentStep, + selectedTemplate, + selectedMaterials.length, + title, + materialMode, + ]); const goPrev = useCallback(() => { if (currentStep > 1) { @@ -751,15 +814,15 @@ const GeneratePage: React.FC = () => { /* ── 辅助 ── */ const getTemplateName = () => - userTemplates.find((t) => t.id === selectedTemplate)?.name ?? '未选择'; + userTemplates.find((t) => t.id === selectedTemplate)?.name ?? "未选择"; const getVoiceName = () => { - if (voiceMode === 'clone') { + if (voiceMode === "clone") { const cv = clonedVoices.find((v) => v.id === selectedClonedVoice); - return cv ? cv.name : '未选择'; + return cv ? cv.name : "未选择"; } const pv = presetVoices.find((v) => v.voice_id === selectedVoice); - return pv ? pv.name : '未选择'; + return pv ? pv.name : "未选择"; }; /* ================================================================ @@ -773,7 +836,7 @@ const GeneratePage: React.FC = () => { {userTemplates.length === 0 ? (

暂无可用模板

-

+

请先在「剪辑计划编辑器」中创建模板

@@ -782,13 +845,13 @@ const GeneratePage: React.FC = () => { {userTemplates.map((tpl) => (
setSelectedTemplate(tpl.id)} role="button" tabIndex={0} aria-pressed={selectedTemplate === tpl.id} onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSelectedTemplate(tpl.id); } @@ -810,9 +873,9 @@ const GeneratePage: React.FC = () => { {tpl.tags.length > 0 && (
@@ -821,10 +884,10 @@ const GeneratePage: React.FC = () => { key={tag} style={{ fontSize: 11, - padding: '1px 6px', + padding: "1px 6px", borderRadius: 6, - background: 'var(--bg-secondary)', - color: 'var(--text-secondary)', + background: "var(--bg-secondary)", + color: "var(--text-secondary)", }} > {tag} @@ -847,15 +910,15 @@ const GeneratePage: React.FC = () => { {/* ── 模式切换 Tab ── */}