diff --git a/.gitea/workflows/acr-cleanup.yml b/.gitea/workflows/acr-cleanup.yml index 82791d4c2..d752e4531 100644 --- a/.gitea/workflows/acr-cleanup.yml +++ b/.gitea/workflows/acr-cleanup.yml @@ -36,12 +36,11 @@ jobs: GITEA_REPO: xiaoxia/xiaoxia-saas steps: - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash # ====== Cron模式:获取staging运行中镜像作为白名单 ====== - name: Get staging running images (whitelist) diff --git a/.gitea/workflows/daily-check.yml b/.gitea/workflows/daily-check.yml index e0d26842a..72864174f 100644 --- a/.gitea/workflows/daily-check.yml +++ b/.gitea/workflows/daily-check.yml @@ -1,4 +1,5 @@ name: Daily Health Check +# 注意:使用 curl step_checkout.sh 方式以兼容 docker runner on: schedule: @@ -23,47 +24,9 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY - + curl -sH "Authorization: token $GITHUB_TOKEN" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \ + | bash - name: Production health check & smoke test id: smoke shell: sh @@ -132,47 +95,9 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY - + curl -sH "Authorization: token $GITHUB_TOKEN" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \ + | bash - name: Run API smoke test on staging id: smoke shell: sh @@ -284,47 +209,9 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY - + curl -sH "Authorization: token $GITHUB_TOKEN" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \ + | bash - name: Run Playwright E2E on staging id: e2e shell: sh diff --git a/apps/api/app/services/ai_service.py b/apps/api/app/services/ai_service.py index 0d1bca0a1..13e465b2f 100755 --- a/apps/api/app/services/ai_service.py +++ b/apps/api/app/services/ai_service.py @@ -14,10 +14,12 @@ from __future__ import annotations import json import logging -import math -import random from typing import Any, Dict, List, Optional +from packages.domain.ai_parsing import generate_titles_fallback as _generate_titles_fallback_base +from packages.domain.ai_parsing import keyword_match_fallback as _semantic_match_fallback_base +from packages.domain.ai_parsing import parse_semantic_match_response as _parse_semantic_match_base +from packages.domain.ai_parsing import parse_titles_from_response as _parse_titles_from_response from packages.shared.ai_client import get_doubao_client logger = logging.getLogger(__name__) @@ -64,85 +66,9 @@ def _generate_titles_fallback( style: str = "viral", count: int = 5, ) -> List[str]: - """本地降级:基于模板规则生成标题. - - 当豆包 API 不可用或调用失败时使用,保证接口始终有返回。 - """ + """本地降级:基于模板规则生成标题(薄包装,转发到 ai_parsing 模块).""" style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"]) - examples = style_info["examples"] - - # 从描述中提取关键词(取前几个词) - keywords = [w for w in description.strip().split() if len(w) > 1][:3] - keyword = keywords[0] if keywords else "精彩内容" - - # 基于模板生成 - templates = [ - f"「{keyword}」{examples[0][:10]}...", - f"{keyword}:{examples[1]}", - f"关于{keyword},你不知道的3件事", - f"{keyword}入门指南,新手必看", - f"深度解析:{keyword}背后的秘密", - f"{keyword}怎么做?手把手教你", - f"干货分享 | {keyword}全攻略", - f"建议收藏:{keyword}实用技巧", - f"{keyword}避坑指南,别再踩雷了", - f"一分钟搞懂{keyword}", - ] - - random.shuffle(templates) - return templates[: min(count, len(templates))] - - -def _parse_titles_from_response(content: str) -> List[str]: - """从模型返回中解析标题列表. - - 支持多种返回格式: - - JSON 数组: ["标题1", "标题2"] - - 编号列表: 1. 标题1 / 2. 标题2 - - 换行分隔: 标题1\n标题2 - - 带破折号: - 标题1 - """ - if not content: - return [] - - # 尝试解析 JSON - try: - # 清理可能的 markdown 代码块标记 - cleaned = content.strip() - if cleaned.startswith("```"): - cleaned = cleaned.strip("`") - if cleaned.lower().startswith("json"): - cleaned = cleaned[4:] - cleaned = cleaned.strip() - - data = json.loads(cleaned) - if isinstance(data, list): - return [str(item).strip() for item in data if str(item).strip()] - if isinstance(data, dict) and "titles" in data: - titles = data["titles"] - if isinstance(titles, list): - return [str(t).strip() for t in titles if str(t).strip()] - except (json.JSONDecodeError, ValueError): - pass - - # 尝试按行解析 - titles: List[str] = [] - for line in content.strip().split("\n"): - line = line.strip() - if not line: - continue - # 去掉编号前缀 "1. " "1、" "(1)" - import re - - line = re.sub(r"^[\d]+[\.、\))]\s*", "", line) - # 去掉破折号前缀 "- " "• " - line = re.sub(r"^[-•·]\s*", "", line) - # 去掉引号 - line = line.strip('"').strip("'").strip("「」") - if line and len(line) < 100: # 过滤过长的行 - titles.append(line) - - return titles + return _generate_titles_fallback_base(description, style_info, count) def generate_smart_titles( @@ -241,132 +167,19 @@ def _semantic_match_fallback( description: str, assets: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: - """本地降级:基于关键词的简单匹配. - - 计算描述中的关键词与素材名称/标签/描述的重叠度, - 作为匹配度评分。0-1分。 - """ - import re - - # 提取关键词(中文按2字以上片段,英文按单词) - desc = description.lower() - # 简单分词:提取2字以上的中文字符串和英文单词 - keywords = set() - # 英文单词 - for word in re.findall(r"[a-zA-Z]{3,}", desc): - keywords.add(word) - # 中文2-4字片段 - for i in range(len(desc)): - for j in range(i + 2, min(i + 5, len(desc) + 1)): - fragment = desc[i:j] - if all("\u4e00" <= c <= "\u9fff" for c in fragment): - keywords.add(fragment) - - if not keywords: - # 没有关键词时给所有素材中等分数 - for asset in assets: - asset["match_score"] = 0.5 - asset["match_reason"] = "fallback_default" - return assets - - results = [] - for asset in assets: - # 组合素材的文本信息:名称 + 标签 + 描述 - asset_text_parts = [ - str(asset.get("name", "")).lower(), - " ".join(str(t) for t in asset.get("tags", [])).lower(), - str(asset.get("description", "")).lower(), - ] - asset_text = " | ".join(asset_text_parts) - - # 计算匹配度:命中关键词占比 + 稀有关键词加权 - hit_count = 0 - hit_keywords = [] - for kw in keywords: - if kw in asset_text: - hit_count += 1 - hit_keywords.append(kw) - - # 基础匹配度 = 命中关键词数 / 总关键词数(开根号平滑) - base_score = math.sqrt(hit_count / len(keywords)) if keywords else 0.5 - - # 名称命中加分(名称匹配更重要) - name = str(asset.get("name", "")).lower() - name_hits = sum(1 for kw in hit_keywords if kw in name) - name_bonus = min(0.2, name_hits * 0.05) - - score = min(1.0, base_score * 0.8 + name_bonus) - score = round(score, 3) - - results.append( - { - **asset, - "match_score": score, - "match_reason": "fallback_keyword", - } - ) - - # 按匹配度降序 - results.sort(key=lambda x: x["match_score"], reverse=True) - return results + """本地降级:基于关键词的简单匹配(薄包装,转发到 ai_parsing 模块).""" + return _semantic_match_fallback_base(description, assets) def _parse_semantic_match_response( content: str, asset_ids: List[str], ) -> Optional[Dict[str, float]]: - """从模型返回中解析素材匹配度. - - 期望格式:JSON 对象 {asset_id: score} 或 {"matches": [{asset_id, score}]} - score 范围 0-1。 - """ - if not content: + """从模型返回中解析素材匹配度(薄包装,转发到 ai_parsing 模块).""" + result = _parse_semantic_match_base(content, asset_ids) + if result is None: return None - - # 尝试解析 JSON - try: - cleaned = content.strip() - if cleaned.startswith("```"): - cleaned = cleaned.strip("`") - if cleaned.lower().startswith("json"): - cleaned = cleaned[4:] - cleaned = cleaned.strip() - - data = json.loads(cleaned) - - result: Dict[str, float] = {} - - # 格式1: {"asset_id1": 0.8, "asset_id2": 0.6} - if isinstance(data, dict): - if "matches" in data and isinstance(data["matches"], list): - # 格式2: {"matches": [{"asset_id": "...", "score": 0.8}]} - for item in data["matches"]: - if isinstance(item, dict): - aid = item.get("asset_id") or item.get("id") - score = item.get("score", 0) - if aid and isinstance(score, (int, float)): - result[str(aid)] = max(0.0, min(1.0, float(score))) - else: - for key, value in data.items(): - if isinstance(value, (int, float)): - result[str(key)] = max(0.0, min(1.0, float(value))) - - # 格式3: [{"asset_id": "...", "score": 0.8}] - elif isinstance(data, list): - for item in data: - if isinstance(item, dict): - aid = item.get("asset_id") or item.get("id") - score = item.get("score", 0) - if aid and isinstance(score, (int, float)): - result[str(aid)] = max(0.0, min(1.0, float(score))) - - if len(result) >= max(1, len(asset_ids) // 2): # 至少一半素材有评分才算成功 - return result - - except (json.JSONDecodeError, ValueError): - pass - - return None + return dict(result) def semantic_match_assets( diff --git a/apps/api/app/services/plan_generator_service.py b/apps/api/app/services/plan_generator_service.py index 9830717ba..07d1ec2bf 100755 --- a/apps/api/app/services/plan_generator_service.py +++ b/apps/api/app/services/plan_generator_service.py @@ -27,13 +27,12 @@ from packages.domain.edit_plan_clip import EditPlanClip from packages.domain.edit_template import EditTemplate from packages.domain.editing_mode import EditingMode from packages.domain.plan_generator_utils import ( - DEFAULT_CLIP_DURATION, create_clips_from_configs, distribute_assets, generate_default_clips, map_clip_types_for_mode, ) -from packages.domain.template_clip_config import ClipType, TemplateClipConfig +from packages.domain.template_clip_config import TemplateClipConfig logger = logging.getLogger(__name__) diff --git a/apps/api/app/services/smart_asset_selector.py b/apps/api/app/services/smart_asset_selector.py index d0b28594c..6b2c17856 100755 --- a/apps/api/app/services/smart_asset_selector.py +++ b/apps/api/app/services/smart_asset_selector.py @@ -21,13 +21,8 @@ from __future__ import annotations import logging -from packages.domain.asset_scoring import MEDIUM_BUCKET_MAX as _MEDIUM_BUCKET_MAX -from packages.domain.asset_scoring import MIN_QUALITY_SCORE as _MIN_QUALITY_SCORE -from packages.domain.asset_scoring import OPTIMAL_DURATION_MAX as _OPTIMAL_DURATION_MAX -from packages.domain.asset_scoring import OPTIMAL_DURATION_MIN as _OPTIMAL_DURATION_MIN -from packages.domain.asset_scoring import SHORT_BUCKET_MAX as _SHORT_BUCKET_MAX -from packages.domain.asset_scoring import TARGET_HEIGHT as _TARGET_HEIGHT -from packages.domain.asset_scoring import TARGET_WIDTH as _TARGET_WIDTH +from packages.domain.asset_scoring import MEDIUM_BUCKET_MAX as _MEDIUM_BUCKET_MAX # noqa: F401 - re-export for tests +from packages.domain.asset_scoring import SHORT_BUCKET_MAX as _SHORT_BUCKET_MAX # noqa: F401 - re-export for tests from packages.domain.asset_scoring import ( AssetScoreDetail, SmartSelectResult, diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index df4b05940..5e0f08a86 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -1,15 +1,21 @@ /** * 模板编辑器 — 制作/编辑剪辑模板 * 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px) + * + * 主组件仅保留 Hook 组装与整体布局 + * 全局配置 → hooks/useGlobalSettings + * 配音素材 → hooks/useVoiceMaterials + * 撤销重做 → hooks/useUndoRedo + * 抽屉管理 → hooks/useEditorDrawers + * 播放控制 → hooks/usePlaybackControl + * 片段操作 → hooks/useClipOperations + * 模板管理 → hooks/useTemplateManagement */ import React, { useState } from "react" import { useSearchParams } from "react-router-dom" -import { useQuery } from "@tanstack/react-query" import { MODE_LABELS } from "@/api/editing-planner" import { MODE_LIST } from "./constants" -import type { MediaAsset, TitleConfig } from "@/api/template-editor" -import { ensureDefaultLibrary, getAssetsByKind, type AssetItem } from "@/api/assets" -import { getOrCreateDefaultProject } from "@/api/projects" +import type { MediaAsset } from "@/api/template-editor" import MediaPanel from "./components/MediaPanel" import PreviewPlayer from "./components/PreviewPlayer" @@ -26,36 +32,12 @@ import { useEditorDrawers } from "./hooks/useEditorDrawers" import { usePlaybackControl } from "./hooks/usePlaybackControl" import { useClipOperations } from "./hooks/useClipOperations" import { useTemplateManagement, FILTER_CATEGORIES } from "./hooks/useTemplateManagement" +import { useGlobalSettings } from "./hooks/useGlobalSettings" +import { useVoiceMaterials } from "./hooks/useVoiceMaterials" -import type { - ClipData, - WatermarkConfig, - IntroOutroConfig, - PipConfig, - FilterConfig, - ChromaKeyConfig, - StickerConfig, - CoverConfig, -} from "./types" -import { - DEFAULT_WATERMARK, - DEFAULT_INTRO_OUTRO, - DEFAULT_PIP_CONFIG, - DEFAULT_FILTER_CONFIG, - DEFAULT_CHROMA_KEY_CONFIG, - DEFAULT_STICKER_CONFIG, - DEFAULT_COVER_CONFIG, -} from "./types" - -import type { SubtitleStyleConfig } from "./types/subtitle" -import { DEFAULT_SUBTITLE_STYLE } from "./types/subtitle" -import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm" +import type { ClipData } from "./types" import "./EditingPlanner.css" -/* ──────────── 常量 ──────────── */ - -/* ──────────── 组件 ──────────── */ - const EditingPlanner: React.FC = () => { const [searchParams] = useSearchParams() const urlTemplateId = searchParams.get("templateId") || "" @@ -72,50 +54,29 @@ const EditingPlanner: React.FC = () => { reset: resetClips, } = useUndoRedo([]) - /* ── 全局配置 state ── */ - const [titleConfig, setTitleConfig] = useState({ - ai_auto_select: false, - content: "", - position: "bottom", - font_preset: "思源黑体", - font_size: 28, - font_color: "#ffffff", - }) - - const [subtitleSettings, setSubtitleSettings] = useState({ - ...DEFAULT_SUBTITLE_STYLE, - }) - - const [bgmSettings, setBgmSettings] = useState({ - ...DEFAULT_BGM_MIX_CONFIG, - }) - - const [watermarkSettings, setWatermarkSettings] = useState({ - ...DEFAULT_WATERMARK, - }) - const [introOutroSettings, setIntroOutroSettings] = useState({ - ...DEFAULT_INTRO_OUTRO, - }) - - const [pipSettings, setPipSettings] = useState({ - ...DEFAULT_PIP_CONFIG, - }) - - const [filterSettings, setFilterSettings] = useState({ - ...DEFAULT_FILTER_CONFIG, - }) - - const [chromaKeySettings, setChromaKeySettings] = useState({ - ...DEFAULT_CHROMA_KEY_CONFIG, - }) - - const [stickerSettings, setStickerSettings] = useState({ - ...DEFAULT_STICKER_CONFIG, - }) - - const [coverConfig, setCoverConfig] = useState({ - ...DEFAULT_COVER_CONFIG, - }) + /* ── 全局配置 ── */ + const { + titleConfig, + setTitleConfig, + subtitleSettings, + setSubtitleSettings, + bgmSettings, + setBgmSettings, + watermarkSettings, + setWatermarkSettings, + introOutroSettings, + setIntroOutroSettings, + pipSettings, + setPipSettings, + filterSettings, + setFilterSettings, + chromaKeySettings, + setChromaKeySettings, + stickerSettings, + setStickerSettings, + coverConfig, + setCoverConfig, + } = useGlobalSettings() /* ── 右侧栏 Tab ── */ const [rightTab, setRightTab] = useState<"properties" | "clips">("properties") @@ -128,18 +89,12 @@ const EditingPlanner: React.FC = () => { setSelectedAssetIds(ids) } - /* ── 配音素材(queryKey 与 VoiceMaterialLibrary 共享缓存) ── */ - const voiceMaterialsQuery = useQuery({ - queryKey: ["assets", "voice"], - queryFn: async () => { - const project = await getOrCreateDefaultProject() - await ensureDefaultLibrary({ project_id: project.id, kind: "voice" }) - const assets = await getAssetsByKind("voice") - return assets - }, - staleTime: 30_000, - }) - const voiceMaterials: AssetItem[] = voiceMaterialsQuery.data ?? [] + /* ── 配音素材 ── */ + const { + voiceMaterials, + loading: voiceMaterialsLoading, + refetch: refetchVoiceMaterials, + } = useVoiceMaterials() /* ── 派生计算 ── */ const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0) @@ -179,31 +134,6 @@ const EditingPlanner: React.FC = () => { coverConfig, }) - /* ── 配置变更 handlers ── */ - const handleWatermarkChange = (config: WatermarkConfig) => { - setWatermarkSettings(config) - } - - const handleIntroOutroChange = (config: IntroOutroConfig) => { - setIntroOutroSettings(config) - } - - const handlePipChange = (config: PipConfig) => { - setPipSettings(config) - } - - const handleFilterChange = (config: FilterConfig) => { - setFilterSettings(config) - } - - const handleChromaKeyChange = (config: ChromaKeyConfig) => { - setChromaKeySettings(config) - } - - const handleStickerChange = (config: StickerConfig) => { - setStickerSettings(config) - } - /* ──────────── 渲染 ──────────── */ return ( @@ -294,15 +224,15 @@ const EditingPlanner: React.FC = () => { totalDuration={totalDuration} currentMode={tpl.currentMode} onSubtitleSettingsChange={(partial) => - setSubtitleSettings((prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig) + setSubtitleSettings((prev) => ({ ...prev, ...partial })) } onBgmSettingsChange={(partial) => setBgmSettings((prev) => ({ ...prev, ...partial }))} onClipUpdate={clipOps.handleClipUpdate} onOpenBgmDrawer={() => drawers.setBgmDrawerOpen(true)} onOpenSubtitleDrawer={() => drawers.setSubtitleDrawerOpen(true)} voiceMaterials={voiceMaterials} - voiceMaterialsLoading={voiceMaterialsQuery.isLoading} - onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()} + voiceMaterialsLoading={voiceMaterialsLoading} + onRefreshVoiceMaterials={refetchVoiceMaterials} onClipVoiceSelect={clipOps.handleClipVoiceSelect} onOpenTransitionDrawer={drawers.openTransitionDrawer} onOpenSpeedDrawer={drawers.openSpeedDrawer} @@ -382,28 +312,28 @@ const EditingPlanner: React.FC = () => { onCloseTtsDrawer={() => drawers.setTtsDrawerOpen(false)} watermarkDrawerOpen={drawers.watermarkDrawerOpen} watermarkSettings={watermarkSettings} - onWatermarkChange={handleWatermarkChange} + onWatermarkChange={setWatermarkSettings} onCloseWatermarkDrawer={() => drawers.setWatermarkDrawerOpen(false)} introOutroDrawerOpen={drawers.introOutroDrawerOpen} introOutroSettings={introOutroSettings} - onIntroOutroChange={handleIntroOutroChange} + onIntroOutroChange={setIntroOutroSettings} onCloseIntroOutroDrawer={() => drawers.setIntroOutroDrawerOpen(false)} pipDrawerOpen={drawers.pipDrawerOpen} pipSettings={pipSettings} totalDuration={totalDuration} - onPipChange={handlePipChange} + onPipChange={setPipSettings} onClosePipDrawer={() => drawers.setPipDrawerOpen(false)} filterDrawerOpen={drawers.filterDrawerOpen} filterSettings={filterSettings} - onFilterChange={handleFilterChange} + onFilterChange={setFilterSettings} onCloseFilterDrawer={() => drawers.setFilterDrawerOpen(false)} chromaKeyDrawerOpen={drawers.chromaKeyDrawerOpen} chromaKeySettings={chromaKeySettings} - onChromaKeyChange={handleChromaKeyChange} + onChromaKeyChange={setChromaKeySettings} onCloseChromaKeyDrawer={() => drawers.setChromaKeyDrawerOpen(false)} stickerDrawerOpen={drawers.stickerDrawerOpen} stickerSettings={stickerSettings} - onStickerChange={handleStickerChange} + onStickerChange={setStickerSettings} onCloseStickerDrawer={() => drawers.setStickerDrawerOpen(false)} /> diff --git a/apps/web/src/pages/editing-planner/components/EditingDrawers.tsx b/apps/web/src/pages/editing-planner/components/EditingDrawers.tsx index 8b980d015..87c0a34b0 100644 --- a/apps/web/src/pages/editing-planner/components/EditingDrawers.tsx +++ b/apps/web/src/pages/editing-planner/components/EditingDrawers.tsx @@ -3,189 +3,32 @@ * 纯渲染层,业务逻辑和 state 留在父组件 */ import React from "react" -import type { TemplateCategory } from "@/api/editing-planner" -import type { - ClipData, - TransitionConfig, - SpeedConfig, - TtsConfig, - WatermarkConfig, - IntroOutroConfig, - PipConfig, - FilterConfig, - ChromaKeyConfig, - StickerConfig, -} from "../types" -import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types" -import type { SubtitleStyleConfig } from "../types/subtitle" -import type { BgmMixConfig } from "@/api/bgm" import SaveModal from "./SaveModal" -import BgmSelector from "./BgmSelector" -import SubtitleStylePanel from "./SubtitleStylePanel" -import TransitionSelector from "./TransitionSelector" -import SpeedPanel from "./SpeedPanel" -import TtsPanel from "./TtsPanel" -import WatermarkPanel from "./WatermarkPanel" -import IntroOutroPanel from "./IntroOutroPanel" -import PipConfigPanel from "./PipConfigPanel" -import FilterPanel from "./FilterPanel" -import GreenScreenPanel from "./GreenScreenPanel" -import StickerPanel from "./StickerPanel" +import { ClipLevelDrawers } from "./editing-drawers/ClipLevelDrawers" +import { GlobalDrawers } from "./editing-drawers/GlobalDrawers" +import type { EditingDrawersProps } from "./editing-drawers/types" -interface EditingDrawersProps { - /* 保存弹窗 */ - saveModalOpen: boolean - saveLoading: boolean - isUpdate: boolean - draftName: string - draftCategory: string - draftTags: string - categories: TemplateCategory[] - estimatedDuration: number - onNameChange: (name: string) => void - onCategoryChange: (cat: string) => void - onTagsChange: (tags: string) => void - onSave: () => Promise - onCancelSave: () => void - /* BGM */ - bgmDrawerOpen: boolean - bgmSettings: BgmMixConfig - onCloseBgmDrawer: () => void - onChangeBgmSettings: (config: BgmMixConfig) => void - /* 字幕 */ - subtitleDrawerOpen: boolean - subtitleSettings: SubtitleStyleConfig - onCloseSubtitleDrawer: () => void - onChangeSubtitleSettings: (config: SubtitleStyleConfig) => void - /* 转场 */ - transitionDrawerOpen: boolean - transitionTargetClipId: string | null - onCloseTransitionDrawer: () => void - onTransitionChange: (config: TransitionConfig) => void - /* 调速 */ - speedDrawerOpen: boolean - speedTargetClipId: string | null - onCloseSpeedDrawer: () => void - onSpeedChange: (config: SpeedConfig) => void - onApplySpeedAll: (config: SpeedConfig) => void - /* TTS 配音 */ - ttsDrawerOpen: boolean - ttsTargetClipId: string | null - onCloseTtsDrawer: () => void - onTtsChange: (config: TtsConfig) => void - /* 水印 */ - watermarkDrawerOpen: boolean - watermarkSettings: WatermarkConfig - onCloseWatermarkDrawer: () => void - onWatermarkChange: (config: WatermarkConfig) => void - /* 片头片尾 */ - introOutroDrawerOpen: boolean - introOutroSettings: IntroOutroConfig - onCloseIntroOutroDrawer: () => void - onIntroOutroChange: (config: IntroOutroConfig) => void - /* 混剪 */ - pipDrawerOpen: boolean - pipSettings: PipConfig - onClosePipDrawer: () => void - onPipChange: (config: PipConfig) => void - /* 滤镜调色 */ - filterDrawerOpen: boolean - filterSettings: FilterConfig - onCloseFilterDrawer: () => void - onFilterChange: (config: FilterConfig) => void - /* 绿幕抠像 */ - chromaKeyDrawerOpen: boolean - chromaKeySettings: ChromaKeyConfig - onCloseChromaKeyDrawer: () => void - onChromaKeyChange: (config: ChromaKeyConfig) => void - /* 贴纸 */ - stickerDrawerOpen: boolean - stickerSettings: StickerConfig - onCloseStickerDrawer: () => void - onStickerChange: (config: StickerConfig) => void - /* 共享数据 */ - clips: ClipData[] - totalDuration: number -} - -const EditingDrawers: React.FC = ({ - saveModalOpen, - saveLoading, - isUpdate, - draftName, - draftCategory, - draftTags, - categories, - estimatedDuration, - onNameChange, - onCategoryChange, - onTagsChange, - onSave, - onCancelSave, - bgmDrawerOpen, - bgmSettings, - onCloseBgmDrawer, - onChangeBgmSettings, - subtitleDrawerOpen, - subtitleSettings, - onCloseSubtitleDrawer, - onChangeSubtitleSettings, - transitionDrawerOpen, - transitionTargetClipId, - onCloseTransitionDrawer, - onTransitionChange, - speedDrawerOpen, - speedTargetClipId, - onCloseSpeedDrawer, - onSpeedChange, - onApplySpeedAll, - ttsDrawerOpen, - ttsTargetClipId, - onCloseTtsDrawer, - onTtsChange, - watermarkDrawerOpen, - watermarkSettings, - onCloseWatermarkDrawer, - onWatermarkChange, - introOutroDrawerOpen, - introOutroSettings, - onCloseIntroOutroDrawer, - onIntroOutroChange, - pipDrawerOpen, - pipSettings, - onClosePipDrawer, - onPipChange, - filterDrawerOpen, - filterSettings, - onCloseFilterDrawer, - onFilterChange, - chromaKeyDrawerOpen, - chromaKeySettings, - onCloseChromaKeyDrawer, - onChromaKeyChange, - stickerDrawerOpen, - stickerSettings, - onCloseStickerDrawer, - onStickerChange, - clips, - totalDuration, -}) => { - const transitionConfig = transitionTargetClipId - ? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION) - : DEFAULT_TRANSITION - const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场" - - const speedConfig = speedTargetClipId - ? (clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED) - : DEFAULT_SPEED - - const ttsConfig = ttsTargetClipId - ? (clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG) - : DEFAULT_TTS_CONFIG +const EditingDrawers: React.FC = (props) => { + const { + saveModalOpen, + saveLoading, + isUpdate, + draftName, + draftCategory, + draftTags, + categories, + estimatedDuration, + onNameChange, + onCategoryChange, + onTagsChange, + onSave, + onCancelSave, + clips, + } = props return ( <> - {/* ═══ 保存弹窗 ═══ */} + {/* 保存弹窗 */} = ({ onCancel={onCancelSave} /> - {/* ═══ BGM 选择器 Drawer ═══ */} - - {/* ═══ 字幕样式配置 Drawer ═══ */} - - - {/* ═══ 转场特效选择器 Drawer ═══ */} - - - {/* ═══ 片段调速面板 Drawer ═══ */} - {speedTargetClipId && ( - - )} - - {/* ═══ TTS 配音面板 Drawer ═══ */} - {ttsTargetClipId && ( - - )} - - {/* ═══ 水印配置面板 ═══ */} - - - {/* ═══ 片头片尾配置面板 ═══ */} - - - {/* ═══ 混剪配置面板 ═══ */} - - - {/* ═══ 滤镜调色面板 ═══ */} - - - {/* ═══ 绿幕抠像面板 ═══ */} - - - {/* ═══ 贴纸面板 ═══ */} - ) diff --git a/apps/web/src/pages/editing-planner/components/editing-drawers/ClipLevelDrawers.tsx b/apps/web/src/pages/editing-planner/components/editing-drawers/ClipLevelDrawers.tsx new file mode 100644 index 000000000..5d4997d40 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/editing-drawers/ClipLevelDrawers.tsx @@ -0,0 +1,74 @@ +import React from "react" +import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../../types" +import TransitionSelector from "../TransitionSelector" +import SpeedPanel from "../SpeedPanel" +import TtsPanel from "../TtsPanel" +import type { ClipLevelDrawersProps } from "./types" + +/** + * 片段级抽屉(转场/调速/TTS) + * 这些抽屉针对特定片段,需要 targetClipId 来定位和读取当前配置 + */ +export const ClipLevelDrawers: React.FC = ({ + clips, + transitionDrawerOpen, + transitionTargetClipId, + onCloseTransitionDrawer, + onTransitionChange, + speedDrawerOpen, + speedTargetClipId, + onCloseSpeedDrawer, + onSpeedChange, + onApplySpeedAll, + ttsDrawerOpen, + ttsTargetClipId, + onCloseTtsDrawer, + onTtsChange, +}) => { + const transitionConfig = transitionTargetClipId + ? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION) + : DEFAULT_TRANSITION + const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场" + + const speedConfig = speedTargetClipId + ? (clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED) + : DEFAULT_SPEED + + const ttsConfig = ttsTargetClipId + ? (clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG) + : DEFAULT_TTS_CONFIG + + return ( + <> + {/* 转场特效选择器 */} + + + {/* 片段调速面板 */} + {speedTargetClipId && ( + + )} + + {/* TTS 配音面板 */} + {ttsTargetClipId && ( + + )} + + ) +} diff --git a/apps/web/src/pages/editing-planner/components/editing-drawers/GlobalDrawers.tsx b/apps/web/src/pages/editing-planner/components/editing-drawers/GlobalDrawers.tsx new file mode 100644 index 000000000..76afc7c80 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/editing-drawers/GlobalDrawers.tsx @@ -0,0 +1,119 @@ +import React from "react" +import BgmSelector from "../BgmSelector" +import SubtitleStylePanel from "../SubtitleStylePanel" +import WatermarkPanel from "../WatermarkPanel" +import IntroOutroPanel from "../IntroOutroPanel" +import PipConfigPanel from "../PipConfigPanel" +import FilterPanel from "../FilterPanel" +import GreenScreenPanel from "../GreenScreenPanel" +import StickerPanel from "../StickerPanel" +import type { GlobalDrawersProps, BgmDrawerProps, SubtitleDrawerProps } from "./types" + +/** + * 全局设置抽屉(BGM/字幕/水印/片头片尾/混剪/滤镜/绿幕/贴纸) + */ +export const GlobalDrawers: React.FC = ({ + bgmDrawerOpen, + bgmSettings, + onCloseBgmDrawer, + onChangeBgmSettings, + subtitleDrawerOpen, + subtitleSettings, + onCloseSubtitleDrawer, + onChangeSubtitleSettings, + totalDuration, + watermarkDrawerOpen, + watermarkSettings, + onCloseWatermarkDrawer, + onWatermarkChange, + introOutroDrawerOpen, + introOutroSettings, + onCloseIntroOutroDrawer, + onIntroOutroChange, + pipDrawerOpen, + pipSettings, + onClosePipDrawer, + onPipChange, + filterDrawerOpen, + filterSettings, + onCloseFilterDrawer, + onFilterChange, + chromaKeyDrawerOpen, + chromaKeySettings, + onCloseChromaKeyDrawer, + onChromaKeyChange, + stickerDrawerOpen, + stickerSettings, + onCloseStickerDrawer, + onStickerChange, +}) => { + return ( + <> + {/* BGM 选择器 */} + + + {/* 字幕样式配置 */} + + + {/* 水印配置面板 */} + + + {/* 片头片尾配置面板 */} + + + {/* 混剪配置面板 */} + + + {/* 滤镜调色面板 */} + + + {/* 绿幕抠像面板 */} + + + {/* 贴纸面板 */} + + + ) +} diff --git a/apps/web/src/pages/editing-planner/components/editing-drawers/types.ts b/apps/web/src/pages/editing-planner/components/editing-drawers/types.ts new file mode 100644 index 000000000..ee43e64a8 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/editing-drawers/types.ts @@ -0,0 +1,101 @@ +import type { TemplateCategory } from "@/api/editing-planner" +import type { + ClipData, + TransitionConfig, + SpeedConfig, + TtsConfig, + WatermarkConfig, + IntroOutroConfig, + PipConfig, + FilterConfig, + ChromaKeyConfig, + StickerConfig, +} from "../../types" +import type { SubtitleStyleConfig } from "../../types/subtitle" +import type { BgmMixConfig } from "@/api/bgm" + +/** 保存弹窗 Props */ +export interface SaveModalDrawerProps { + saveModalOpen: boolean + saveLoading: boolean + isUpdate: boolean + draftName: string + draftCategory: string + draftTags: string + categories: TemplateCategory[] + estimatedDuration: number + onNameChange: (name: string) => void + onCategoryChange: (cat: string) => void + onTagsChange: (tags: string) => void + onSave: () => Promise + onCancelSave: () => void +} + +/** BGM 抽屉 Props */ +export interface BgmDrawerProps { + bgmDrawerOpen: boolean + bgmSettings: BgmMixConfig + onCloseBgmDrawer: () => void + onChangeBgmSettings: (config: BgmMixConfig) => void +} + +/** 字幕抽屉 Props */ +export interface SubtitleDrawerProps { + subtitleDrawerOpen: boolean + subtitleSettings: SubtitleStyleConfig + onCloseSubtitleDrawer: () => void + onChangeSubtitleSettings: (config: SubtitleStyleConfig) => void +} + +/** 单个片段级抽屉通用 Props */ +export interface ClipLevelDrawersProps { + clips: ClipData[] + transitionDrawerOpen: boolean + transitionTargetClipId: string | null + onCloseTransitionDrawer: () => void + onTransitionChange: (config: TransitionConfig) => void + speedDrawerOpen: boolean + speedTargetClipId: string | null + onCloseSpeedDrawer: () => void + onSpeedChange: (config: SpeedConfig) => void + onApplySpeedAll: (config: SpeedConfig) => void + ttsDrawerOpen: boolean + ttsTargetClipId: string | null + onCloseTtsDrawer: () => void + onTtsChange: (config: TtsConfig) => void +} + +/** 全局设置抽屉 Props */ +export interface GlobalDrawersProps { + totalDuration: number + watermarkDrawerOpen: boolean + watermarkSettings: WatermarkConfig + onCloseWatermarkDrawer: () => void + onWatermarkChange: (config: WatermarkConfig) => void + introOutroDrawerOpen: boolean + introOutroSettings: IntroOutroConfig + onCloseIntroOutroDrawer: () => void + onIntroOutroChange: (config: IntroOutroConfig) => void + pipDrawerOpen: boolean + pipSettings: PipConfig + onClosePipDrawer: () => void + onPipChange: (config: PipConfig) => void + filterDrawerOpen: boolean + filterSettings: FilterConfig + onCloseFilterDrawer: () => void + onFilterChange: (config: FilterConfig) => void + chromaKeyDrawerOpen: boolean + chromaKeySettings: ChromaKeyConfig + onCloseChromaKeyDrawer: () => void + onChromaKeyChange: (config: ChromaKeyConfig) => void + stickerDrawerOpen: boolean + stickerSettings: StickerConfig + onCloseStickerDrawer: () => void + onStickerChange: (config: StickerConfig) => void +} + +export type EditingDrawersProps = SaveModalDrawerProps & + BgmDrawerProps & + SubtitleDrawerProps & + ClipLevelDrawersProps & + GlobalDrawersProps diff --git a/apps/web/src/pages/editing-planner/hooks/useEditPlanClips/index.ts b/apps/web/src/pages/editing-planner/hooks/useEditPlanClips/index.ts new file mode 100644 index 000000000..23a09faaa --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/useEditPlanClips/index.ts @@ -0,0 +1,83 @@ +import { useUndoRedo } from "../useUndoRedo" +import type { EditPlanClip } from "@/api/template-editor" +import { useEditPlanClipList } from "./useEditPlanClipList" +import { useEditPlanClipMutations } from "./useEditPlanClipMutations" + +/** + * 模板片段管理 Hook + * 对接后端 PR#389 片段 CRUD API + * + * 功能: + * - 加载/刷新片段列表 + * - 单个增删改查 + * - 批量删除 + * - 拖拽重排序 + * - 从素材批量导入 + * - 乐观更新 + 撤销重做 + */ +export function useEditPlanClips(planId: string | undefined) { + // 列表数据 + 选中状态 + const { + clips, + clipsTotal, + clipsLoading, + refetchClips, + selectedClipId, + setSelectedClipId, + selectedClip, + } = useEditPlanClipList(planId) + + // CRUD 操作 + const mutations = useEditPlanClipMutations({ + planId, + selectedClipId, + setSelectedClipId, + clipsLength: clips.length, + }) + + // 本地撤销重做(供拖拽等即时操作使用) + const { + state: localClips, + set: setLocalClips, + undo, + redo, + canUndo, + canRedo, + reset: resetLocalClips, + } = useUndoRedo([]) + + return { + // 数据 + clips, + clipsTotal, + clipsLoading, + selectedClipId, + selectedClip, + // 选中 + setSelectedClipId, + // 操作 + addClip: mutations.addClip, + updateClip: mutations.updateClip, + removeClip: mutations.removeClip, + batchRemoveClips: mutations.batchRemoveClips, + reorderClips: mutations.reorderClips, + importFromAssets: mutations.importFromAssets, + refetchClips, + // 状态 + isCreating: mutations.isCreating, + isUpdating: mutations.isUpdating, + isDeleting: mutations.isDeleting, + isReordering: mutations.isReordering, + isImporting: mutations.isImporting, + // 本地撤销重做 + localClips, + setLocalClips, + undo, + redo, + canUndo, + canRedo, + resetLocalClips, + } +} + +export default useEditPlanClips diff --git a/apps/web/src/pages/editing-planner/hooks/useEditPlanClips/useEditPlanClipList.ts b/apps/web/src/pages/editing-planner/hooks/useEditPlanClips/useEditPlanClipList.ts new file mode 100644 index 000000000..cfc5c6a09 --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/useEditPlanClips/useEditPlanClipList.ts @@ -0,0 +1,49 @@ +import { useState } from "react" +import { useQuery } from "@tanstack/react-query" +import type { EditPlanClip } from "@/api/template-editor" +import { getEditPlanClips } from "@/api/template-editor" + +const QUERY_KEY = "editPlanClips" + +interface UseEditPlanClipListResult { + clips: EditPlanClip[] + clipsTotal: number + clipsLoading: boolean + refetchClips: () => void + selectedClipId: string | null + setSelectedClipId: (id: string | null) => void + selectedClip: EditPlanClip | null +} + +/** + * 编辑计划片段列表 Hook + * 封装片段列表查询、选中状态 + */ +export function useEditPlanClipList(planId: string | undefined): UseEditPlanClipListResult { + const { + data: clipListData, + isLoading: clipsLoading, + refetch: refetchClips, + } = useQuery({ + queryKey: [QUERY_KEY, planId], + queryFn: () => getEditPlanClips(planId!, { limit: 500 }), + enabled: !!planId, + staleTime: 30_000, + }) + + const clips: EditPlanClip[] = clipListData?.items ?? [] + const clipsTotal = clipListData?.total ?? 0 + + const [selectedClipId, setSelectedClipId] = useState(null) + const selectedClip = clips.find((c) => c.id === selectedClipId) ?? null + + return { + clips, + clipsTotal, + clipsLoading, + refetchClips, + selectedClipId, + setSelectedClipId, + selectedClip, + } +} diff --git a/apps/web/src/pages/editing-planner/hooks/useEditPlanClips.ts b/apps/web/src/pages/editing-planner/hooks/useEditPlanClips/useEditPlanClipMutations.ts similarity index 59% rename from apps/web/src/pages/editing-planner/hooks/useEditPlanClips.ts rename to apps/web/src/pages/editing-planner/hooks/useEditPlanClips/useEditPlanClipMutations.ts index ec198cd6e..a1551c3b3 100644 --- a/apps/web/src/pages/editing-planner/hooks/useEditPlanClips.ts +++ b/apps/web/src/pages/editing-planner/hooks/useEditPlanClips/useEditPlanClipMutations.ts @@ -1,26 +1,12 @@ -/** - * 模板片段管理 Hook - * 对接后端 PR#389 片段 CRUD API,替代原来的 config.segments 模式 - * - * 功能: - * - 加载/刷新片段列表 - * - 单个增删改查 - * - 批量删除 - * - 拖拽重排序 - * - 从素材批量导入 - * - 乐观更新 + 撤销重做 - */ -import { useCallback, useState } from "react" +import { useCallback } from "react" import { message } from "antd" -import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query" +import { useQueryClient, useMutation } from "@tanstack/react-query" import type { - EditPlanClip, CreateEditPlanClipRequest, UpdateEditPlanClipRequest, ClipReorderItem, } from "@/api/template-editor" import { - getEditPlanClips, createEditPlanClip, updateEditPlanClip, deleteEditPlanClip, @@ -28,51 +14,37 @@ import { batchDeleteEditPlanClips, createClipsFromAssets, } from "@/api/template-editor" -import { useUndoRedo } from "./useUndoRedo" const QUERY_KEY = "editPlanClips" -export function useEditPlanClips(planId: string | undefined) { +interface UseEditPlanClipMutationsOptions { + planId: string | undefined + selectedClipId: string | null + setSelectedClipId: (id: string | null) => void + clipsLength: number +} + +/** + * 编辑计划片段 CRUD Hook + * 封装创建、更新、删除、批量删除、重排序、素材导入等操作 + */ +export function useEditPlanClipMutations({ + planId, + selectedClipId, + setSelectedClipId, + clipsLength, +}: UseEditPlanClipMutationsOptions) { const queryClient = useQueryClient() - /* ── 片段列表查询 ── */ - const { - data: clipListData, - isLoading: clipsLoading, - refetch: refetchClips, - } = useQuery({ - queryKey: [QUERY_KEY, planId], - queryFn: () => getEditPlanClips(planId!, { limit: 500 }), - enabled: !!planId, - staleTime: 30_000, - }) - - const clips: EditPlanClip[] = clipListData?.items ?? [] - const clipsTotal = clipListData?.total ?? 0 - - /* ── 选中片段 ── */ - const [selectedClipId, setSelectedClipId] = useState(null) - const selectedClip = clips.find((c) => c.id === selectedClipId) ?? null - - /* ── 本地撤销/重做(用于拖拽等即时操作的回退) ── */ - const { - state: localClips, - set: setLocalClips, - undo, - redo, - canUndo, - canRedo, - reset: resetLocalClips, - } = useUndoRedo([]) - - // 当服务端数据变化时同步本地 - // 注意:实际使用时以服务端为准,本地仅用于拖拽等临时操作 + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) + } /* ── 创建片段 ── */ const createMutation = useMutation({ mutationFn: (data: CreateEditPlanClipRequest) => createEditPlanClip(planId!, data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) + invalidate() message.success("片段已添加") }, onError: () => { @@ -83,10 +55,10 @@ export function useEditPlanClips(planId: string | undefined) { const addClip = useCallback( (data: Omit & { order?: number }) => { if (!planId) return - const order = data.order ?? clips.length + const order = data.order ?? clipsLength createMutation.mutate({ ...data, order }) }, - [planId, clips.length, createMutation], + [planId, clipsLength, createMutation], ) /* ── 更新片段 ── */ @@ -94,7 +66,7 @@ export function useEditPlanClips(planId: string | undefined) { mutationFn: ({ clipId, data }: { clipId: string; data: UpdateEditPlanClipRequest }) => updateEditPlanClip(planId!, clipId, data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) + invalidate() }, onError: () => { message.error("更新片段失败") @@ -113,7 +85,7 @@ export function useEditPlanClips(planId: string | undefined) { const deleteMutation = useMutation({ mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) + invalidate() message.success("片段已删除") }, onError: () => { @@ -129,14 +101,14 @@ export function useEditPlanClips(planId: string | undefined) { } deleteMutation.mutate(clipId) }, - [planId, selectedClipId, deleteMutation], + [planId, selectedClipId, setSelectedClipId, deleteMutation], ) /* ── 批量删除 ── */ const batchDeleteMutation = useMutation({ mutationFn: (clipIds: string[]) => batchDeleteEditPlanClips(planId!, clipIds), onSuccess: (res) => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) + invalidate() message.success(`已删除 ${res.deleted_count} 个片段`) }, onError: () => { @@ -152,19 +124,18 @@ export function useEditPlanClips(planId: string | undefined) { } batchDeleteMutation.mutate(clipIds) }, - [planId, selectedClipId, batchDeleteMutation], + [planId, selectedClipId, setSelectedClipId, batchDeleteMutation], ) - /* ── 重排序(拖拽结束后一次性提交) ── */ + /* ── 重排序 ── */ const reorderMutation = useMutation({ mutationFn: (items: ClipReorderItem[]) => reorderEditPlanClips(planId!, items), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) + invalidate() }, onError: () => { message.error("排序失败") - // 失败后刷新回服务端状态 - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) + invalidate() }, }) @@ -180,7 +151,7 @@ export function useEditPlanClips(planId: string | undefined) { const importFromAssetsMutation = useMutation({ mutationFn: (assetIds: string[]) => createClipsFromAssets(planId!, assetIds), onSuccess: (res) => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) + invalidate() message.success(`已导入 ${res.created_count} 个素材片段`) }, onError: () => { @@ -197,37 +168,16 @@ export function useEditPlanClips(planId: string | undefined) { ) return { - // 数据 - clips, - clipsTotal, - clipsLoading, - selectedClipId, - selectedClip, - // 选中 - setSelectedClipId, - // 操作 addClip, updateClip, removeClip, batchRemoveClips, reorderClips, importFromAssets, - refetchClips, - // 状态 isCreating: createMutation.isPending, isUpdating: updateMutation.isPending, isDeleting: deleteMutation.isPending, isReordering: reorderMutation.isPending, isImporting: importFromAssetsMutation.isPending, - // 本地撤销重做(供拖拽等场景使用) - localClips, - setLocalClips, - undo, - redo, - canUndo, - canRedo, - resetLocalClips, } } - -export default useEditPlanClips diff --git a/apps/web/src/pages/editing-planner/hooks/useGlobalSettings.ts b/apps/web/src/pages/editing-planner/hooks/useGlobalSettings.ts new file mode 100644 index 000000000..27f653743 --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/useGlobalSettings.ts @@ -0,0 +1,121 @@ +/** + * EditingPlanner 全局配置状态管理 + * 集中管理 9 个全局配置:标题/字幕/BGM/水印/片头片尾/画中画/滤镜/绿幕/贴纸/封面 + */ +import { useState } from "react" +import type { TitleConfig } from "@/api/template-editor" +import type { + WatermarkConfig, + IntroOutroConfig, + PipConfig, + FilterConfig, + ChromaKeyConfig, + StickerConfig, + CoverConfig, +} from "../types" +import { + DEFAULT_WATERMARK, + DEFAULT_INTRO_OUTRO, + DEFAULT_PIP_CONFIG, + DEFAULT_FILTER_CONFIG, + DEFAULT_CHROMA_KEY_CONFIG, + DEFAULT_STICKER_CONFIG, + DEFAULT_COVER_CONFIG, +} from "../types" +import type { SubtitleStyleConfig } from "../types/subtitle" +import { DEFAULT_SUBTITLE_STYLE } from "../types/subtitle" +import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm" + +export interface GlobalSettings { + titleConfig: TitleConfig + setTitleConfig: (config: TitleConfig | ((prev: TitleConfig) => TitleConfig)) => void + subtitleSettings: SubtitleStyleConfig + setSubtitleSettings: ( + settings: SubtitleStyleConfig | ((prev: SubtitleStyleConfig) => SubtitleStyleConfig), + ) => void + bgmSettings: BgmMixConfig + setBgmSettings: (settings: BgmMixConfig | ((prev: BgmMixConfig) => BgmMixConfig)) => void + watermarkSettings: WatermarkConfig + setWatermarkSettings: (config: WatermarkConfig) => void + introOutroSettings: IntroOutroConfig + setIntroOutroSettings: (config: IntroOutroConfig) => void + pipSettings: PipConfig + setPipSettings: (config: PipConfig) => void + filterSettings: FilterConfig + setFilterSettings: (config: FilterConfig) => void + chromaKeySettings: ChromaKeyConfig + setChromaKeySettings: (config: ChromaKeyConfig) => void + stickerSettings: StickerConfig + setStickerSettings: (config: StickerConfig) => void + coverConfig: CoverConfig + setCoverConfig: (config: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void +} + +export const useGlobalSettings = (): GlobalSettings => { + const [titleConfig, setTitleConfig] = useState({ + ai_auto_select: false, + content: "", + position: "bottom", + font_preset: "思源黑体", + font_size: 28, + font_color: "#ffffff", + }) + + const [subtitleSettings, setSubtitleSettings] = useState({ + ...DEFAULT_SUBTITLE_STYLE, + }) + + const [bgmSettings, setBgmSettings] = useState({ + ...DEFAULT_BGM_MIX_CONFIG, + }) + + const [watermarkSettings, setWatermarkSettings] = useState({ + ...DEFAULT_WATERMARK, + }) + const [introOutroSettings, setIntroOutroSettings] = useState({ + ...DEFAULT_INTRO_OUTRO, + }) + + const [pipSettings, setPipSettings] = useState({ + ...DEFAULT_PIP_CONFIG, + }) + + const [filterSettings, setFilterSettings] = useState({ + ...DEFAULT_FILTER_CONFIG, + }) + + const [chromaKeySettings, setChromaKeySettings] = useState({ + ...DEFAULT_CHROMA_KEY_CONFIG, + }) + + const [stickerSettings, setStickerSettings] = useState({ + ...DEFAULT_STICKER_CONFIG, + }) + + const [coverConfig, setCoverConfig] = useState({ + ...DEFAULT_COVER_CONFIG, + }) + + return { + titleConfig, + setTitleConfig, + subtitleSettings, + setSubtitleSettings, + bgmSettings, + setBgmSettings, + watermarkSettings, + setWatermarkSettings, + introOutroSettings, + setIntroOutroSettings, + pipSettings, + setPipSettings, + filterSettings, + setFilterSettings, + chromaKeySettings, + setChromaKeySettings, + stickerSettings, + setStickerSettings, + coverConfig, + setCoverConfig, + } +} diff --git a/apps/web/src/pages/editing-planner/hooks/useVoiceMaterials.ts b/apps/web/src/pages/editing-planner/hooks/useVoiceMaterials.ts new file mode 100644 index 000000000..5b7227157 --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/useVoiceMaterials.ts @@ -0,0 +1,32 @@ +/** + * EditingPlanner 配音素材数据加载 + * queryKey 与 VoiceMaterialLibrary 共享缓存 + */ +import { useQuery } from "@tanstack/react-query" +import { ensureDefaultLibrary, getAssetsByKind, type AssetItem } from "@/api/assets" +import { getOrCreateDefaultProject } from "@/api/projects" + +export interface UseVoiceMaterialsReturn { + voiceMaterials: AssetItem[] + loading: boolean + refetch: () => Promise +} + +export const useVoiceMaterials = (): UseVoiceMaterialsReturn => { + const query = useQuery({ + queryKey: ["assets", "voice"], + queryFn: async () => { + const project = await getOrCreateDefaultProject() + await ensureDefaultLibrary({ project_id: project.id, kind: "voice" }) + const assets = await getAssetsByKind("voice") + return assets + }, + staleTime: 30_000, + }) + + return { + voiceMaterials: query.data ?? [], + loading: query.isLoading, + refetch: query.refetch, + } +} diff --git a/apps/web/src/pages/editing-planner/types.ts b/apps/web/src/pages/editing-planner/types.ts deleted file mode 100644 index 4be6060a2..000000000 --- a/apps/web/src/pages/editing-planner/types.ts +++ /dev/null @@ -1,550 +0,0 @@ -/** - * 片段(Clip)统一类型定义 - * 片段 = 时间规划 + 类型标记,不绑定任何素材 - */ - -export type ClipType = "voice" | "pip" - -/* ──────── 转场特效 ──────── */ - -/** 14 种转场类型 */ -export type TransitionType = - | "none" - | "cut" - | "fade" - | "dissolve" - | "zoom" - | "slide_left" - | "slide_right" - | "slide_up" - | "slide_down" - | "wipe_left" - | "wipe_right" - | "wipe_up" - | "wipe_down" - | "circlecrop" - | "rectcrop" - -/** 片段间转场配置 */ -export interface TransitionConfig { - /** 转场类型 */ - type: TransitionType - /** 转场时长(秒),0.3 ~ 2.0 */ - duration: number -} - -/** 默认转场配置 */ -export const DEFAULT_TRANSITION: TransitionConfig = { - type: "none", - duration: 0.5, -} - -/* ──────── 片段调速 ──────── */ - -/** 片段调速配置 */ -export interface SpeedConfig { - /** 播放速度,0.25 ~ 4.0 */ - rate: number - /** 音调修正(变速不变调) */ - pitchCorrection: boolean -} - -/** 默认调速配置 */ -export const DEFAULT_SPEED: SpeedConfig = { - rate: 1.0, - pitchCorrection: true, -} - -/* ──────── TTS 配音 ──────── */ - -/** 配音模式 */ -export type TtsMode = "none" | "upload" | "tts" - -/** TTS 配音配置 */ -export interface TtsConfig { - /** 配音模式 */ - mode: TtsMode - /** TTS 合成文本 */ - text: string - /** 音色 ID */ - voice_id: string - /** 语速 0.5 ~ 2.0 */ - speed: number - /** 语调(半音)-12 ~ +12 */ - pitch: number - /** 音量 0 ~ 100 */ - volume: number - /** 字幕联动 */ - subtitle_sync: boolean -} - -/** 默认 TTS 配置 */ -export const DEFAULT_TTS_CONFIG: TtsConfig = { - mode: "none", - text: "", - voice_id: "", - speed: 1.0, - pitch: 0, - volume: 100, - subtitle_sync: true, -} - -/* ──────── 裁剪配置 ──────── */ - -/** 片段裁剪配置 — 定义素材的入点/出点 */ -export interface TrimConfig { - /** 入点(秒),素材原始时间轴上的起始位置 */ - start_time: number - /** 出点(秒),素材原始时间轴上的结束位置 */ - end_time: number - /** 素材原始总时长(秒),用于"恢复原始长度" */ - original_duration?: number -} - -/* ──────── 水印配置 ──────── */ - -/** 水印类型 */ -export type WatermarkType = "none" | "image" | "text" | "scroll" - -/** 水印位置 */ -export type WatermarkPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right" | "center" - -/** 滚动水印方向 */ -export type ScrollDirection = "horizontal" | "vertical" | "diagonal" - -/** 水印配置 */ -export interface WatermarkConfig { - /** 水印类型 */ - type: WatermarkType - /** 图片水印 URL */ - image_url?: string - /** 水印宽度(像素或百分比 0~1) */ - width?: number - /** 水印高度(像素或百分比 0~1) */ - height?: number - /** 水印位置 */ - position: WatermarkPosition - /** 水印不透明度 0~1 */ - opacity: number - /** 文字水印内容 */ - text?: string - /** 文字水印字号 */ - font_size?: number - /** 文字水印颜色 */ - color?: string - /** 滚动水印方向 */ - scroll_direction?: ScrollDirection - /** 滚动水印速度(像素/秒) */ - scroll_speed?: number -} - -/** 默认水印配置 */ -export const DEFAULT_WATERMARK: WatermarkConfig = { - type: "none", - position: "bottom_right", - opacity: 0.7, -} - -/* ──────── 片头片尾配置 ──────── */ - -/** 片头片尾素材类型 */ -export type IntroOutroKind = "none" | "video" | "image" - -/** 片头/片尾单项配置 */ -export interface IntroOutroItem { - /** 素材类型 */ - kind: IntroOutroKind - /** 素材 URL */ - url?: string - /** 显示时长(秒) */ - duration: number - /** 过渡动画 */ - transition?: TransitionType - /** 过渡时长(秒) */ - transition_duration?: number -} - -/** 片头片尾完整配置 */ -export interface IntroOutroConfig { - intro: IntroOutroItem - outro: IntroOutroItem -} - -/** 默认片头片尾配置 */ -export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = { - intro: { kind: "none", duration: 3 }, - outro: { kind: "none", duration: 3 }, -} - -/* ──────── 混剪配置 ──────── */ - -/** 九宫格位置 */ -export type PipGridPosition = - | "top_left" - | "top_center" - | "top_right" - | "center_left" - | "center" - | "center_right" - | "bottom_left" - | "bottom_center" - | "bottom_right" - -/** 入场动画类型 */ -export type PipAnimType = "none" | "fade_in" | "slide_in" - -/** 入场方向 */ -export type PipSlideDirection = "left" | "right" | "up" | "down" - -/** 混剪图层 */ -export interface PipLayer { - id: string - /** 图层名称(用户可编辑) */ - name: string - /** 素材类型 */ - material_type: "image" | "video" - /** 素材 URL */ - material_url: string - /** 素材缩略图 */ - thumbnail_url?: string - /** 九宫格快捷位置 */ - grid_position: PipGridPosition - /** 精确 X 坐标(百分比 0~100) */ - x: number - /** 精确 Y 坐标(百分比 0~100) */ - y: number - /** 宽度(百分比 0~100,相对主画面) */ - width: number - /** 高度(百分比 0~100,相对主画面) */ - height: number - /** 锁定宽高比 */ - aspect_lock: boolean - /** 圆角(百分比 0~50) */ - border_radius: number - /** 不透明度(0~100) */ - opacity: number - /** 开始时间(秒) */ - start_time: number - /** 持续时长(秒) */ - duration: number - /** 入场动画 */ - animation: PipAnimType - /** 入场方向 */ - slide_direction: PipSlideDirection - /** 图层顺序(z-index) */ - z_index: number -} - -/** 混剪配置 */ -export interface PipConfig { - /** 是否启用混剪 */ - enabled: boolean - /** 图层列表 */ - layers: PipLayer[] -} - -/** 默认 PiP 图层 */ -export const DEFAULT_PIP_LAYER: PipLayer = { - id: "", - name: "图层", - material_type: "image", - material_url: "", - grid_position: "top_right", - x: 70, - y: 5, - width: 25, - height: 25, - aspect_lock: true, - border_radius: 0, - opacity: 100, - start_time: 0, - duration: 5, - animation: "none", - slide_direction: "right", - z_index: 1, -} - -/** 默认 PiP 配置 */ -export const DEFAULT_PIP_CONFIG: PipConfig = { - enabled: false, - layers: [], -} - -/* ──────── 滤镜调色 ──────── */ - -/** 预设滤镜 */ -export type FilterPreset = - | "none" - | "original" - | "fresh" - | "warm" - | "cool" - | "vintage" - | "cinema" - | "bw" - | "sunshine" - | "film" - -/** 预设滤镜标签 */ -export const FILTER_PRESET_LABELS: Record = { - none: "无", - original: "原片", - fresh: "清新", - warm: "暖调", - cool: "冷色", - vintage: "复古", - cinema: "电影", - bw: "黑白", - sunshine: "暖阳", - film: "胶片", -} - -/** 滤镜调色配置 */ -export interface FilterConfig { - /** 是否启用滤镜 */ - enabled: boolean - /** 预设滤镜 */ - preset: FilterPreset - /** 亮度(-100 ~ 100) */ - brightness: number - /** 对比度(-100 ~ 100) */ - contrast: number - /** 饱和度(-100 ~ 100) */ - saturation: number - /** 色温(-100 ~ 100,负值偏蓝,正值偏黄) */ - temperature: number - /** 色调(-100 ~ 100,负值偏绿,正值偏品红) */ - tint: number - /** 锐度(0 ~ 100) */ - sharpness: number -} - -/** 默认滤镜调色配置 */ -export const DEFAULT_FILTER_CONFIG: FilterConfig = { - enabled: false, - preset: "none", - brightness: 0, - contrast: 0, - saturation: 0, - temperature: 0, - tint: 0, - sharpness: 0, -} - -/* ──────── 绿幕抠像 ──────── */ - -/** 绿幕抠像颜色预设 */ -export type ChromaKeyColorPreset = "green" | "blue" | "red" | "pure_green" | "soft_green" - -/** 颜色预设标签 */ -export const CHROMA_KEY_PRESET_LABELS: Record = { - green: "绿", - blue: "蓝", - red: "红", - pure_green: "精绿", - soft_green: "柔绿", -} - -/** 颜色预设对应的默认色值 */ -export const CHROMA_KEY_PRESET_COLORS: Record = { - green: "#00FF00", - blue: "#0000FF", - red: "#FF0000", - pure_green: "#00C800", - soft_green: "#40E040", -} - -/** 绿幕抠像配置 */ -export interface ChromaKeyConfig { - /** 是否启用绿幕抠像 */ - enabled: boolean - /** 颜色预设 */ - color_preset: ChromaKeyColorPreset - /** 抠像目标颜色(HEX) */ - color: string - /** 相似度(0 ~ 100,越大容忍的色差范围越广) */ - similarity: number - /** 边缘平滑(0 ~ 100,越大边缘越柔和) */ - blend: number - /** 溢色抑制(0 ~ 100,去除边缘颜色溢出) */ - spill: number -} - -/** 默认绿幕抠像配置 */ -export const DEFAULT_CHROMA_KEY_CONFIG: ChromaKeyConfig = { - enabled: false, - color_preset: "green", - color: "#00FF00", - similarity: 30, - blend: 10, - spill: 20, -} - -/* ──────── 贴纸配置 ──────── */ - -/** 贴纸类型 */ -export type StickerType = "emoji" | "image" | "text" - -/** 文字花字预设 */ -export type TextStickerPreset = - | "normal" // 普通 - | "highlight" // 高亮 - | "bubble" // 气泡 - | "neon" // 霓虹 - | "shadow" // 投影 - | "outline" // 描边 - | "gradient" // 渐变 - | "handwrite" // 手写 - -/** 贴纸项 */ -export interface StickerItem { - id: string - /** 贴纸类型 */ - type: StickerType - /** 内容(emoji 字符 / 图片 URL / 文字内容) */ - content: string - /** X 坐标(百分比 0~100) */ - x: number - /** Y 坐标(百分比 0~100) */ - y: number - /** 宽度(百分比 0~100) */ - width: number - /** 高度(百分比 0~100) */ - height: number - /** 旋转角度(度 -180~180) */ - rotation: number - /** 不透明度(0~100) */ - opacity: number - /** 开始时间(秒) */ - start_time: number - /** 持续时长(秒,0 表示全程显示) */ - duration: number - /** 图层顺序 */ - z_index: number - /** 文字花字预设(仅 type=text 时有效) */ - text_preset: TextStickerPreset - /** 文字颜色(仅 type=text 时有效) */ - text_color: string - /** 文字大小(px,仅 type=text 时有效) */ - font_size: number -} - -/** 贴纸配置 */ -export interface StickerConfig { - enabled: boolean - items: StickerItem[] -} - -/** 默认贴纸项 */ -export const DEFAULT_STICKER_ITEM: StickerItem = { - id: "", - type: "emoji", - content: "😀", - x: 50, - y: 50, - width: 15, - height: 15, - rotation: 0, - opacity: 100, - start_time: 0, - duration: 0, - z_index: 1, - text_preset: "normal", - text_color: "#FFFFFF", - font_size: 24, -} - -/** 默认贴纸配置 */ -export const DEFAULT_STICKER_CONFIG: StickerConfig = { - enabled: false, - items: [], -} - -/** 文字花字预设标签 */ -export const TEXT_STICKER_PRESET_LABELS: Record = { - normal: "普通", - highlight: "高亮", - bubble: "气泡", - neon: "霓虹", - shadow: "投影", - outline: "描边", - gradient: "渐变", - handwrite: "手写", -} - -/* ──────── 封面配置 ──────── */ - -/** 封面来源模式 */ -export type CoverMode = "auto" | "frame" | "upload" - -/** 封面配置 */ -export interface CoverConfig { - /** 是否启用自定义封面 */ - enabled: boolean - /** 封面来源模式 */ - mode: CoverMode - /** 抽帧时间点(秒,mode=frame 时使用) */ - frame_time: number - /** 上传的封面 URL(mode=upload 时使用) */ - upload_url: string - /** AI 智能推荐的抽帧时间(由后端分析得出) */ - ai_suggested_time: number | null - /** 封面缩略图 URL */ - thumbnail_url: string -} - -/** 默认封面配置 */ -export const DEFAULT_COVER_CONFIG: CoverConfig = { - enabled: false, - mode: "auto", - frame_time: 0, - upload_url: "", - ai_suggested_time: null, - thumbnail_url: "", -} - -/* ──────── 片段数据 ──────── */ - -export interface ClipData { - id: string - type: ClipType // 片段类型:voice(口播)或 pip(混剪) - duration: number // 时长(秒) - startOffset: number // 仅 voice 类型:在口播素材中的起始时间(秒) - /** 素材库素材 ID(main/pip 类型片段使用) */ - media_asset_id?: string - // 保留兼容字段(后端序列化需要) - template_segment_id?: string - script_text?: string - order?: number - /** 配音素材 ID(voice 类型片段使用) */ - voice_asset_id?: string - /** 配音素材文件 URL(voice 类型片段使用) */ - voice_file_url?: string - /** 与前一片段之间的转场效果 */ - transition?: TransitionConfig - /** 播放速度配置 */ - speed?: SpeedConfig - /** TTS 配音配置 */ - tts_config?: TtsConfig - /** 裁剪配置 — 定义素材入点/出点 */ - trim_config?: TrimConfig -} - -/* ──────── 标题设置 ──────── */ - -/** - * 标题设置 — 对齐后端 title_config 字段 - * 前端 UI 使用 camelCase,发送到后端时映射为 snake_case - */ -export interface TitleSettings { - aiAutoSelect: boolean - title: string - position: string - font: string - size: number - bold: boolean - italic: boolean - stroke: boolean - shadow: boolean - color: string -} diff --git a/apps/web/src/pages/editing-planner/types/chroma-key.ts b/apps/web/src/pages/editing-planner/types/chroma-key.ts new file mode 100644 index 000000000..980401567 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/chroma-key.ts @@ -0,0 +1,50 @@ +/** + * 绿幕抠像类型 + */ + +/** 绿幕抠像颜色预设 */ +export type ChromaKeyColorPreset = "green" | "blue" | "red" | "pure_green" | "soft_green" + +/** 颜色预设标签 */ +export const CHROMA_KEY_PRESET_LABELS: Record = { + green: "绿", + blue: "蓝", + red: "红", + pure_green: "精绿", + soft_green: "柔绿", +} + +/** 颜色预设对应的默认色值 */ +export const CHROMA_KEY_PRESET_COLORS: Record = { + green: "#00FF00", + blue: "#0000FF", + red: "#FF0000", + pure_green: "#00C800", + soft_green: "#40E040", +} + +/** 绿幕抠像配置 */ +export interface ChromaKeyConfig { + /** 是否启用绿幕抠像 */ + enabled: boolean + /** 颜色预设 */ + color_preset: ChromaKeyColorPreset + /** 抠像目标颜色(HEX) */ + color: string + /** 相似度(0 ~ 100,越大容忍的色差范围越广) */ + similarity: number + /** 边缘平滑(0 ~ 100,越大边缘越柔和) */ + blend: number + /** 溢色抑制(0 ~ 100,去除边缘颜色溢出) */ + spill: number +} + +/** 默认绿幕抠像配置 */ +export const DEFAULT_CHROMA_KEY_CONFIG: ChromaKeyConfig = { + enabled: false, + color_preset: "green", + color: "#00FF00", + similarity: 30, + blend: 10, + spill: 20, +} diff --git a/apps/web/src/pages/editing-planner/types/clip.ts b/apps/web/src/pages/editing-planner/types/clip.ts new file mode 100644 index 000000000..8602b8318 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/clip.ts @@ -0,0 +1,36 @@ +/** + * 片段数据类型 + */ +import type { TransitionConfig } from "./transition" +import type { SpeedConfig } from "./speed" +import type { TtsConfig } from "./tts" +import type { TrimConfig } from "./trim" + +/** 片段类型 */ +export type ClipType = "voice" | "pip" + +/** 片段数据 — 时间规划 + 类型标记,不绑定任何素材 */ +export interface ClipData { + id: string + type: ClipType // 片段类型:voice(口播)或 pip(混剪) + duration: number // 时长(秒) + startOffset: number // 仅 voice 类型:在口播素材中的起始时间(秒) + /** 素材库素材 ID(main/pip 类型片段使用) */ + media_asset_id?: string + // 保留兼容字段(后端序列化需要) + template_segment_id?: string + script_text?: string + order?: number + /** 配音素材 ID(voice 类型片段使用) */ + voice_asset_id?: string + /** 配音素材文件 URL(voice 类型片段使用) */ + voice_file_url?: string + /** 与前一片段之间的转场效果 */ + transition?: TransitionConfig + /** 播放速度配置 */ + speed?: SpeedConfig + /** TTS 配音配置 */ + tts_config?: TtsConfig + /** 裁剪配置 — 定义素材入点/出点 */ + trim_config?: TrimConfig +} diff --git a/apps/web/src/pages/editing-planner/types/clipProperties.ts b/apps/web/src/pages/editing-planner/types/clipProperties.ts old mode 100755 new mode 100644 index 26125a186..3f37015f2 --- a/apps/web/src/pages/editing-planner/types/clipProperties.ts +++ b/apps/web/src/pages/editing-planner/types/clipProperties.ts @@ -1,7 +1,7 @@ /** * ClipPropertiesPanel 相关类型定义 */ -import type { ClipData } from "@/pages/editing-planner/types" +import type { ClipData } from "./clip" import type { TemplateMode } from "@/api/editing-planner" import type { AssetItem } from "@/api/assets" diff --git a/apps/web/src/pages/editing-planner/types/cover.ts b/apps/web/src/pages/editing-planner/types/cover.ts new file mode 100644 index 000000000..c3a02e887 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/cover.ts @@ -0,0 +1,32 @@ +/** + * 封面配置类型 + */ + +/** 封面来源模式 */ +export type CoverMode = "auto" | "frame" | "upload" + +/** 封面配置 */ +export interface CoverConfig { + /** 是否启用自定义封面 */ + enabled: boolean + /** 封面来源模式 */ + mode: CoverMode + /** 抽帧时间点(秒,mode=frame 时使用) */ + frame_time: number + /** 上传的封面 URL(mode=upload 时使用) */ + upload_url: string + /** AI 智能推荐的抽帧时间(由后端分析得出) */ + ai_suggested_time: number | null + /** 封面缩略图 URL */ + thumbnail_url: string +} + +/** 默认封面配置 */ +export const DEFAULT_COVER_CONFIG: CoverConfig = { + enabled: false, + mode: "auto", + frame_time: 0, + upload_url: "", + ai_suggested_time: null, + thumbnail_url: "", +} diff --git a/apps/web/src/pages/editing-planner/types/filter.ts b/apps/web/src/pages/editing-planner/types/filter.ts new file mode 100644 index 000000000..03f0201cc --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/filter.ts @@ -0,0 +1,62 @@ +/** + * 滤镜调色类型 + */ + +/** 预设滤镜 */ +export type FilterPreset = + | "none" + | "original" + | "fresh" + | "warm" + | "cool" + | "vintage" + | "cinema" + | "bw" + | "sunshine" + | "film" + +/** 预设滤镜标签 */ +export const FILTER_PRESET_LABELS: Record = { + none: "无", + original: "原片", + fresh: "清新", + warm: "暖调", + cool: "冷色", + vintage: "复古", + cinema: "电影", + bw: "黑白", + sunshine: "暖阳", + film: "胶片", +} + +/** 滤镜调色配置 */ +export interface FilterConfig { + /** 是否启用滤镜 */ + enabled: boolean + /** 预设滤镜 */ + preset: FilterPreset + /** 亮度(-100 ~ 100) */ + brightness: number + /** 对比度(-100 ~ 100) */ + contrast: number + /** 饱和度(-100 ~ 100) */ + saturation: number + /** 色温(-100 ~ 100,负值偏蓝,正值偏黄) */ + temperature: number + /** 色调(-100 ~ 100,负值偏绿,正值偏品红) */ + tint: number + /** 锐度(0 ~ 100) */ + sharpness: number +} + +/** 默认滤镜调色配置 */ +export const DEFAULT_FILTER_CONFIG: FilterConfig = { + enabled: false, + preset: "none", + brightness: 0, + contrast: 0, + saturation: 0, + temperature: 0, + tint: 0, + sharpness: 0, +} diff --git a/apps/web/src/pages/editing-planner/types/index.ts b/apps/web/src/pages/editing-planner/types/index.ts new file mode 100644 index 000000000..072ad49e1 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/index.ts @@ -0,0 +1,84 @@ +/** + * EditingPlanner 类型定义入口 + * 按功能模块拆分,统一从这里导出 + */ + +/* 转场 */ +export { type TransitionType, type TransitionConfig, DEFAULT_TRANSITION } from "./transition" + +/* 调速 */ +export { type SpeedConfig, DEFAULT_SPEED } from "./speed" + +/* TTS 配音 */ +export { type TtsMode, type TtsConfig, DEFAULT_TTS_CONFIG } from "./tts" + +/* 裁剪 */ +export { type TrimConfig } from "./trim" + +/* 水印 */ +export { + type WatermarkType, + type WatermarkPosition, + type ScrollDirection, + type WatermarkConfig, + DEFAULT_WATERMARK, +} from "./watermark" + +/* 片头片尾 */ +export { + type IntroOutroKind, + type IntroOutroItem, + type IntroOutroConfig, + DEFAULT_INTRO_OUTRO, +} from "./intro-outro" + +/* 混剪 PiP */ +export { + type PipGridPosition, + type PipAnimType, + type PipSlideDirection, + type PipLayer, + type PipConfig, + DEFAULT_PIP_LAYER, + DEFAULT_PIP_CONFIG, +} from "./pip" + +/* 滤镜调色 */ +export { + type FilterPreset, + FILTER_PRESET_LABELS, + type FilterConfig, + DEFAULT_FILTER_CONFIG, +} from "./filter" + +/* 绿幕抠像 */ +export { + type ChromaKeyColorPreset, + CHROMA_KEY_PRESET_LABELS, + CHROMA_KEY_PRESET_COLORS, + type ChromaKeyConfig, + DEFAULT_CHROMA_KEY_CONFIG, +} from "./chroma-key" + +/* 贴纸 */ +export { + type StickerType, + type TextStickerPreset, + type StickerItem, + type StickerConfig, + DEFAULT_STICKER_ITEM, + DEFAULT_STICKER_CONFIG, + TEXT_STICKER_PRESET_LABELS, +} from "./sticker" + +/* 封面 */ +export { type CoverMode, type CoverConfig, DEFAULT_COVER_CONFIG } from "./cover" + +/* 片段数据 */ +export { type ClipType, type ClipData } from "./clip" + +/* 标题设置 */ +export { type TitleSettings } from "./title" + +/* 字幕样式 */ +export { type SubtitleStyleConfig, DEFAULT_SUBTITLE_STYLE } from "./subtitle" diff --git a/apps/web/src/pages/editing-planner/types/intro-outro.ts b/apps/web/src/pages/editing-planner/types/intro-outro.ts new file mode 100644 index 000000000..f0c53f934 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/intro-outro.ts @@ -0,0 +1,33 @@ +/** + * 片头片尾配置类型 + */ +import type { TransitionType } from "./transition" + +/** 片头片尾素材类型 */ +export type IntroOutroKind = "none" | "video" | "image" + +/** 片头/片尾单项配置 */ +export interface IntroOutroItem { + /** 素材类型 */ + kind: IntroOutroKind + /** 素材 URL */ + url?: string + /** 显示时长(秒) */ + duration: number + /** 过渡动画 */ + transition?: TransitionType + /** 过渡时长(秒) */ + transition_duration?: number +} + +/** 片头片尾完整配置 */ +export interface IntroOutroConfig { + intro: IntroOutroItem + outro: IntroOutroItem +} + +/** 默认片头片尾配置 */ +export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = { + intro: { kind: "none", duration: 3 }, + outro: { kind: "none", duration: 3 }, +} diff --git a/apps/web/src/pages/editing-planner/types/pip.ts b/apps/web/src/pages/editing-planner/types/pip.ts new file mode 100644 index 000000000..27c012ac9 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/pip.ts @@ -0,0 +1,95 @@ +/** + * 混剪(PiP)配置类型 + */ + +/** 九宫格位置 */ +export type PipGridPosition = + | "top_left" + | "top_center" + | "top_right" + | "center_left" + | "center" + | "center_right" + | "bottom_left" + | "bottom_center" + | "bottom_right" + +/** 入场动画类型 */ +export type PipAnimType = "none" | "fade_in" | "slide_in" + +/** 入场方向 */ +export type PipSlideDirection = "left" | "right" | "up" | "down" + +/** 混剪图层 */ +export interface PipLayer { + id: string + /** 图层名称(用户可编辑) */ + name: string + /** 素材类型 */ + material_type: "image" | "video" + /** 素材 URL */ + material_url: string + /** 素材缩略图 */ + thumbnail_url?: string + /** 九宫格快捷位置 */ + grid_position: PipGridPosition + /** 精确 X 坐标(百分比 0~100) */ + x: number + /** 精确 Y 坐标(百分比 0~100) */ + y: number + /** 宽度(百分比 0~100,相对主画面) */ + width: number + /** 高度(百分比 0~100,相对主画面) */ + height: number + /** 锁定宽高比 */ + aspect_lock: boolean + /** 圆角(百分比 0~50) */ + border_radius: number + /** 不透明度(0~100) */ + opacity: number + /** 开始时间(秒) */ + start_time: number + /** 持续时长(秒) */ + duration: number + /** 入场动画 */ + animation: PipAnimType + /** 入场方向 */ + slide_direction: PipSlideDirection + /** 图层顺序(z-index) */ + z_index: number +} + +/** 混剪配置 */ +export interface PipConfig { + /** 是否启用混剪 */ + enabled: boolean + /** 图层列表 */ + layers: PipLayer[] +} + +/** 默认 PiP 图层 */ +export const DEFAULT_PIP_LAYER: PipLayer = { + id: "", + name: "图层", + material_type: "image", + material_url: "", + grid_position: "top_right", + x: 70, + y: 5, + width: 25, + height: 25, + aspect_lock: true, + border_radius: 0, + opacity: 100, + start_time: 0, + duration: 5, + animation: "none", + slide_direction: "right", + z_index: 1, +} + +/** 默认 PiP 配置 */ +export const DEFAULT_PIP_CONFIG: PipConfig = { + enabled: false, + layers: [], +} diff --git a/apps/web/src/pages/editing-planner/types/speed.ts b/apps/web/src/pages/editing-planner/types/speed.ts new file mode 100644 index 000000000..227b54de1 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/speed.ts @@ -0,0 +1,17 @@ +/** + * 片段调速类型 + */ + +/** 片段调速配置 */ +export interface SpeedConfig { + /** 播放速度,0.25 ~ 4.0 */ + rate: number + /** 音调修正(变速不变调) */ + pitchCorrection: boolean +} + +/** 默认调速配置 */ +export const DEFAULT_SPEED: SpeedConfig = { + rate: 1.0, + pitchCorrection: true, +} diff --git a/apps/web/src/pages/editing-planner/types/sticker.ts b/apps/web/src/pages/editing-planner/types/sticker.ts new file mode 100644 index 000000000..95bed824e --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/sticker.ts @@ -0,0 +1,93 @@ +/** + * 贴纸配置类型 + */ + +/** 贴纸类型 */ +export type StickerType = "emoji" | "image" | "text" + +/** 文字花字预设 */ +export type TextStickerPreset = + | "normal" // 普通 + | "highlight" // 高亮 + | "bubble" // 气泡 + | "neon" // 霓虹 + | "shadow" // 投影 + | "outline" // 描边 + | "gradient" // 渐变 + | "handwrite" // 手写 + +/** 贴纸项 */ +export interface StickerItem { + id: string + /** 贴纸类型 */ + type: StickerType + /** 内容(emoji 字符 / 图片 URL / 文字内容) */ + content: string + /** X 坐标(百分比 0~100) */ + x: number + /** Y 坐标(百分比 0~100) */ + y: number + /** 宽度(百分比 0~100) */ + width: number + /** 高度(百分比 0~100) */ + height: number + /** 旋转角度(度 -180~180) */ + rotation: number + /** 不透明度(0~100) */ + opacity: number + /** 开始时间(秒) */ + start_time: number + /** 持续时长(秒,0 表示全程显示) */ + duration: number + /** 图层顺序 */ + z_index: number + /** 文字花字预设(仅 type=text 时有效) */ + text_preset: TextStickerPreset + /** 文字颜色(仅 type=text 时有效) */ + text_color: string + /** 文字大小(px,仅 type=text 时有效) */ + font_size: number +} + +/** 贴纸配置 */ +export interface StickerConfig { + enabled: boolean + items: StickerItem[] +} + +/** 默认贴纸项 */ +export const DEFAULT_STICKER_ITEM: StickerItem = { + id: "", + type: "emoji", + content: "😀", + x: 50, + y: 50, + width: 15, + height: 15, + rotation: 0, + opacity: 100, + start_time: 0, + duration: 0, + z_index: 1, + text_preset: "normal", + text_color: "#FFFFFF", + font_size: 24, +} + +/** 默认贴纸配置 */ +export const DEFAULT_STICKER_CONFIG: StickerConfig = { + enabled: false, + items: [], +} + +/** 文字花字预设标签 */ +export const TEXT_STICKER_PRESET_LABELS: Record = { + normal: "普通", + highlight: "高亮", + bubble: "气泡", + neon: "霓虹", + shadow: "投影", + outline: "描边", + gradient: "渐变", + handwrite: "手写", +} diff --git a/apps/web/src/pages/editing-planner/types/title.ts b/apps/web/src/pages/editing-planner/types/title.ts new file mode 100644 index 000000000..e6c8df4a3 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/title.ts @@ -0,0 +1,20 @@ +/** + * 标题设置类型 + */ + +/** + * 标题设置 — 对齐后端 title_config 字段 + * 前端 UI 使用 camelCase,发送到后端时映射为 snake_case + */ +export interface TitleSettings { + aiAutoSelect: boolean + title: string + position: string + font: string + size: number + bold: boolean + italic: boolean + stroke: boolean + shadow: boolean + color: string +} diff --git a/apps/web/src/pages/editing-planner/types/transition.ts b/apps/web/src/pages/editing-planner/types/transition.ts new file mode 100644 index 000000000..ad04cb554 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/transition.ts @@ -0,0 +1,35 @@ +/** + * 转场特效类型 + */ + +/** 14 种转场类型 */ +export type TransitionType = + | "none" + | "cut" + | "fade" + | "dissolve" + | "zoom" + | "slide_left" + | "slide_right" + | "slide_up" + | "slide_down" + | "wipe_left" + | "wipe_right" + | "wipe_up" + | "wipe_down" + | "circlecrop" + | "rectcrop" + +/** 片段间转场配置 */ +export interface TransitionConfig { + /** 转场类型 */ + type: TransitionType + /** 转场时长(秒),0.3 ~ 2.0 */ + duration: number +} + +/** 默认转场配置 */ +export const DEFAULT_TRANSITION: TransitionConfig = { + type: "none", + duration: 0.5, +} diff --git a/apps/web/src/pages/editing-planner/types/trim.ts b/apps/web/src/pages/editing-planner/types/trim.ts new file mode 100644 index 000000000..f9bfa71bf --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/trim.ts @@ -0,0 +1,13 @@ +/** + * 片段裁剪类型 + */ + +/** 片段裁剪配置 — 定义素材的入点/出点 */ +export interface TrimConfig { + /** 入点(秒),素材原始时间轴上的起始位置 */ + start_time: number + /** 出点(秒),素材原始时间轴上的结束位置 */ + end_time: number + /** 素材原始总时长(秒),用于"恢复原始长度" */ + original_duration?: number +} diff --git a/apps/web/src/pages/editing-planner/types/tts.ts b/apps/web/src/pages/editing-planner/types/tts.ts new file mode 100644 index 000000000..32436e42e --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/tts.ts @@ -0,0 +1,35 @@ +/** + * TTS 配音类型 + */ + +/** 配音模式 */ +export type TtsMode = "none" | "upload" | "tts" + +/** TTS 配音配置 */ +export interface TtsConfig { + /** 配音模式 */ + mode: TtsMode + /** TTS 合成文本 */ + text: string + /** 音色 ID */ + voice_id: string + /** 语速 0.5 ~ 2.0 */ + speed: number + /** 语调(半音)-12 ~ +12 */ + pitch: number + /** 音量 0 ~ 100 */ + volume: number + /** 字幕联动 */ + subtitle_sync: boolean +} + +/** 默认 TTS 配置 */ +export const DEFAULT_TTS_CONFIG: TtsConfig = { + mode: "none", + text: "", + voice_id: "", + speed: 1.0, + pitch: 0, + volume: 100, + subtitle_sync: true, +} diff --git a/apps/web/src/pages/editing-planner/types/watermark.ts b/apps/web/src/pages/editing-planner/types/watermark.ts new file mode 100644 index 000000000..f8c3e288b --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/watermark.ts @@ -0,0 +1,45 @@ +/** + * 水印配置类型 + */ + +/** 水印类型 */ +export type WatermarkType = "none" | "image" | "text" | "scroll" + +/** 水印位置 */ +export type WatermarkPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right" | "center" + +/** 滚动水印方向 */ +export type ScrollDirection = "horizontal" | "vertical" | "diagonal" + +/** 水印配置 */ +export interface WatermarkConfig { + /** 水印类型 */ + type: WatermarkType + /** 图片水印 URL */ + image_url?: string + /** 水印宽度(像素或百分比 0~1) */ + width?: number + /** 水印高度(像素或百分比 0~1) */ + height?: number + /** 水印位置 */ + position: WatermarkPosition + /** 水印不透明度 0~1 */ + opacity: number + /** 文字水印内容 */ + text?: string + /** 文字水印字号 */ + font_size?: number + /** 文字水印颜色 */ + color?: string + /** 滚动水印方向 */ + scroll_direction?: ScrollDirection + /** 滚动水印速度(像素/秒) */ + scroll_speed?: number +} + +/** 默认水印配置 */ +export const DEFAULT_WATERMARK: WatermarkConfig = { + type: "none", + position: "bottom_right", + opacity: 0.7, +} diff --git a/apps/web/src/pages/generate/hooks/step5-voice/useSaveToLibrary.tsx b/apps/web/src/pages/generate/hooks/step5-voice/useSaveToLibrary.tsx new file mode 100644 index 000000000..a7fe87307 --- /dev/null +++ b/apps/web/src/pages/generate/hooks/step5-voice/useSaveToLibrary.tsx @@ -0,0 +1,113 @@ +import { useState, useCallback } from "react" +import { useNavigate } from "react-router-dom" +import { message } from "antd" +import { useQuery, useMutation } from "@tanstack/react-query" +import { saveTtsToLibrary } from "@/api/tts" +import { getTags, createTag } from "@/api/tags" + +/** + * 存为素材(配音库)弹窗逻辑 + */ +export function useSaveToLibrary(completedTtsJobId: string | null, resetTtsState: () => void) { + const navigate = useNavigate() + + const [saveModalOpen, setSaveModalOpen] = useState(false) + const [saveName, setSaveName] = useState("") + const [saveTagIds, setSaveTagIds] = useState([]) + const [saveNewTag, setSaveNewTag] = useState("") + + const { data: allTags = [] } = useQuery({ + queryKey: ["generate-save-tags"], + queryFn: getTags, + staleTime: 30_000, + }) + + const handleGoToLibrary = useCallback(() => { + navigate("/app/voice-materials") + }, [navigate]) + + const saveToLibraryMutation = useMutation({ + mutationFn: (params: { name?: string; tag_ids?: string[] }) => + saveTtsToLibrary(completedTtsJobId!, params), + onSuccess: () => { + message.success({ + content: ( + + 已保存到配音库!{" "} + + 去视频库查看 + + + ), + duration: 5, + }) + setSaveModalOpen(false) + setSaveName("") + setSaveTagIds([]) + setSaveNewTag("") + resetTtsState() + }, + onError: (err: Error) => { + message.error(`保存失败:${err.message || "请重试"}`) + }, + }) + + const handleOpenSaveModal = useCallback(() => { + setSaveName("") + setSaveTagIds([]) + setSaveNewTag("") + setSaveModalOpen(true) + }, []) + + const handleConfirmSave = useCallback(() => { + if (!completedTtsJobId) return + saveToLibraryMutation.mutate({ + name: saveName.trim() || undefined, + tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined, + }) + }, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation]) + + const handleAddTagInModal = useCallback( + async (tagName: string) => { + const trimmed = tagName.trim() + if (!trimmed) return + const existing = allTags.find((t) => t.name === trimmed) + if (existing) { + if (!saveTagIds.includes(existing.id)) { + setSaveTagIds((prev) => [...prev, existing.id]) + } + return + } + try { + const created = await createTag(trimmed) + setSaveTagIds((prev) => [...prev, created.id]) + setSaveNewTag("") + } catch { + message.error(`创建标签"${trimmed}"失败`) + } + }, + [allTags, saveTagIds], + ) + + return { + saveModalOpen, + setSaveModalOpen, + saveName, + setSaveName, + saveTagIds, + setSaveTagIds, + saveNewTag, + setSaveNewTag, + allTags, + saveToLibraryMutation, + handleOpenSaveModal, + handleConfirmSave, + handleAddTagInModal, + } +} diff --git a/apps/web/src/pages/generate/hooks/step5-voice/useTtsSynthesis.ts b/apps/web/src/pages/generate/hooks/step5-voice/useTtsSynthesis.ts new file mode 100644 index 000000000..3c6447c9b --- /dev/null +++ b/apps/web/src/pages/generate/hooks/step5-voice/useTtsSynthesis.ts @@ -0,0 +1,96 @@ +import { useState, useCallback, useEffect } from "react" +import { message } from "antd" +import { useMutation } from "@tanstack/react-query" +import { synthesizeSpeech, getTTSJobStatus } from "@/api/tts" + +/** + * TTS 自定义合成 + 轮询状态 + */ +export function useTtsSynthesis(selectedVoice: string) { + const [customVoiceText, setCustomVoiceText] = useState("") + const [customAudioUrl, setCustomAudioUrl] = useState(null) + const [ttsError, setTtsError] = useState(null) + const [ttsJobId, setTtsJobId] = useState(null) + const [completedTtsJobId, setCompletedTtsJobId] = useState(null) + + const synthesizeMutation = useMutation({ + mutationFn: synthesizeSpeech, + onSuccess: (data) => { + setTtsJobId(data.job_id) + message.info("语音合成已提交,等待处理…") + }, + onError: () => { + setTtsError("语音合成请求失败,请重试") + }, + }) + + /* 轮询 TTS 任务状态 */ + useEffect(() => { + if (!ttsJobId) return + let cancelled = false + let timer: ReturnType + + const poll = async () => { + try { + const status = await getTTSJobStatus(ttsJobId) + if (cancelled) return + if (status.status === "completed") { + setCustomAudioUrl(status.output_audio_url) + setCompletedTtsJobId(ttsJobId) + setTtsJobId(null) + setTtsError(null) + message.success("语音合成完成!") + return + } + if (status.status === "failed" || status.status === "cancelled") { + setTtsError(status.error_message || "语音合成失败") + setTtsJobId(null) + return + } + timer = setTimeout(poll, 2000) + } catch { + if (!cancelled) { + setTtsError("查询合成状态失败") + setTtsJobId(null) + } + } + } + + timer = setTimeout(poll, 2000) + return () => { + cancelled = true + clearTimeout(timer) + } + }, [ttsJobId]) + + const handleSynthesizeVoice = useCallback(() => { + if (!customVoiceText.trim()) { + message.warning("请先输入配音文案") + return + } + setTtsError(null) + setCustomAudioUrl(null) + synthesizeMutation.mutate({ + text: customVoiceText.trim(), + voice_id: selectedVoice || undefined, + language: "zh-CN", + }) + }, [customVoiceText, selectedVoice, synthesizeMutation]) + + const resetTtsState = useCallback(() => { + setCompletedTtsJobId(null) + setCustomAudioUrl(null) + }, []) + + return { + customVoiceText, + setCustomVoiceText, + customAudioUrl, + ttsError, + ttsJobId, + completedTtsJobId, + synthesizeMutation, + handleSynthesizeVoice, + resetTtsState, + } +} diff --git a/apps/web/src/pages/generate/hooks/step5-voice/useVoiceAudio.ts b/apps/web/src/pages/generate/hooks/step5-voice/useVoiceAudio.ts new file mode 100644 index 000000000..70691ef6c --- /dev/null +++ b/apps/web/src/pages/generate/hooks/step5-voice/useVoiceAudio.ts @@ -0,0 +1,39 @@ +import { useState, useRef, useCallback } from "react" +import { message } from "antd" + +/** + * 音色试听播放控制 + */ +export function useVoiceAudio() { + const audioRef = useRef(null) + const [playingVoice, setPlayingVoice] = useState(null) + + const toggleVoicePlay = useCallback( + (voiceId: string, previewUrl: string | null) => { + if (playingVoice === voiceId) { + audioRef.current?.pause() + audioRef.current = null + setPlayingVoice(null) + return + } + audioRef.current?.pause() + if (!previewUrl) { + message.warning("该音色暂无试听音频") + return + } + const audio = new Audio(previewUrl) + audioRef.current = audio + audio.play().catch(() => { + message.error("播放失败,请检查网络") + }) + audio.onended = () => { + setPlayingVoice(null) + audioRef.current = null + } + setPlayingVoice(voiceId) + }, + [playingVoice], + ) + + return { playingVoice, toggleVoicePlay } +} diff --git a/apps/web/src/pages/generate/hooks/step5-voice/useVoiceRecommend.ts b/apps/web/src/pages/generate/hooks/step5-voice/useVoiceRecommend.ts new file mode 100644 index 000000000..de78ffc8a --- /dev/null +++ b/apps/web/src/pages/generate/hooks/step5-voice/useVoiceRecommend.ts @@ -0,0 +1,68 @@ +import { useState, useCallback, useMemo } from "react" +import { useQuery } from "@tanstack/react-query" +import { fetchPresetVoices } from "@/api/voices" +import type { PresetVoiceItem } from "@/api/voices" + +/** + * 智能配音推荐 + * 根据标题内容风格模拟推荐音色 + */ +export function useVoiceRecommend(titleText: string) { + const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({ + queryKey: ["preset-voices"], + queryFn: fetchPresetVoices, + }) + + const presetVoices: PresetVoiceItem[] = useMemo( + () => presetVoicesData?.items ?? [], + [presetVoicesData], + ) + + const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false) + const [voiceRecommendations, setVoiceRecommendations] = useState([]) + const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false) + + const handleVoiceRecommend = useCallback(async () => { + if (presetVoices.length === 0) return + setVoiceRecommendLoading(true) + setHasVoiceRecommend(true) + + await new Promise((resolve) => setTimeout(resolve, 1000)) + + const title = titleText.toLowerCase() + let recommended: string[] = [] + + const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id) + const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id) + const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id) + + if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) { + recommended = femaleVoices.slice(0, 3) + } else if (/教程|知识|科普|干货|讲解|分析/.test(title)) { + recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1)) + } else if (/活力|热血|运动|搞笑|有趣/.test(title)) { + recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1)) + } else { + recommended = presetVoices.slice(0, 3).map((v) => v.voice_id) + } + + if (recommended.length < 3) { + const others = presetVoices + .filter((v) => !recommended.includes(v.voice_id)) + .map((v) => v.voice_id) + recommended = recommended.concat(others.slice(0, 3 - recommended.length)) + } + + setVoiceRecommendations(recommended) + setVoiceRecommendLoading(false) + }, [presetVoices, titleText]) + + return { + presetVoices, + presetVoicesLoading, + voiceRecommendLoading, + voiceRecommendations, + hasVoiceRecommend, + handleVoiceRecommend, + } +} diff --git a/apps/web/src/pages/generate/hooks/useStep4Title.ts b/apps/web/src/pages/generate/hooks/useStep4Title.ts deleted file mode 100644 index eb9957f8a..000000000 --- a/apps/web/src/pages/generate/hooks/useStep4Title.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * Step 4 标题设置 Hook - * 封装 AI 标题生成、标题样式设置等逻辑 - */ -import { useState, useCallback, useMemo } from "react" -import { message } from "antd" -import { useQuery } from "@tanstack/react-query" -import { getTitles } from "@/api/titles" -import { TITLE_PRESETS, AI_TITLE_TEMPLATES } from "../constants" -import type { TitleSettings } from "../types" - -interface UseStep4TitleProps { - titleSettings: TitleSettings - onTitleSettingsChange: (settings: TitleSettings) => void -} - -interface AiTitleItem { - title: string - highlight: string - style: "catchy" | "emotional" | "informative" -} - -export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) { - /* ── 标题库 API ── */ - const { data: userTitles = [] } = useQuery({ - queryKey: ["titles"], - queryFn: () => getTitles(), - staleTime: 30_000, - }) - - /* ── AI 标题生成状态 ── */ - const [aiTitleInput, setAiTitleInput] = useState("") - const [aiTitleGenerating, setAiTitleGenerating] = useState(false) - const [aiTitleResults, setAiTitleResults] = useState([]) - const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false) - - /* ── 辅助函数 ── */ - const extractTopic = (text: string): string => { - const keywords = text - .replace(/[,。!?、,.!?]/g, " ") - .split(/\s+/) - .filter(Boolean) - if (keywords.length === 0) return "这个话题" - // 取前3个关键词组合 - return keywords.slice(0, 3).join("") - } - - const getActivePreset = (settings: TitleSettings): string | null => { - for (const p of TITLE_PRESETS) { - if ( - settings.size === p.style.size && - settings.color === p.style.color && - settings.bold === p.style.bold && - settings.italic === p.style.italic && - settings.stroke === p.style.stroke && - settings.shadow === p.style.shadow - ) { - return p.key - } - } - return null - } - - const activePreset = useMemo(() => getActivePreset(titleSettings), [titleSettings]) - - /* ── AI 标题生成 ── */ - const handleGenerateAiTitles = useCallback(async () => { - if (!aiTitleInput.trim()) { - message.warning("请先输入视频描述或关键词") - return - } - setAiTitleGenerating(true) - setHasGeneratedTitles(true) - - // 模拟 AI 生成延迟 - await new Promise((resolve) => setTimeout(resolve, 1200)) - - const topic = extractTopic(aiTitleInput) - const results: AiTitleItem[] = [] - - const styles: Array<"catchy" | "emotional" | "informative"> = [ - "catchy", - "emotional", - "informative", - ] - styles.forEach((style) => { - const templates = AI_TITLE_TEMPLATES[style] - // 每种风格随机选2个 - const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2) - shuffled.forEach((tpl) => { - const title = tpl.replace(/\{topic\}/g, topic) - const highlights = { - catchy: "吸睛标题", - emotional: "情感共鸣", - informative: "知识干货", - } - results.push({ - title, - highlight: highlights[style], - style, - }) - }) - }) - - // 打乱顺序 - results.sort(() => Math.random() - 0.5) - setAiTitleResults(results) - setAiTitleGenerating(false) - }, [aiTitleInput]) - - const handleSelectAiTitle = useCallback( - (title: string) => { - onTitleSettingsChange({ ...titleSettings, title, aiAutoSelect: false }) - message.success("已选用此标题") - }, - [titleSettings, onTitleSettingsChange], - ) - - const handleRefreshAiTitles = useCallback(async () => { - if (!aiTitleInput.trim()) return - setAiTitleGenerating(true) - await new Promise((resolve) => setTimeout(resolve, 800)) - // 重新生成一批 - const topic = extractTopic(aiTitleInput) - const results: AiTitleItem[] = [] - const styles: Array<"catchy" | "emotional" | "informative"> = [ - "catchy", - "emotional", - "informative", - ] - const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" } - styles.forEach((style) => { - const templates = AI_TITLE_TEMPLATES[style] - const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2) - shuffled.forEach((tpl) => { - results.push({ - title: tpl.replace(/\{topic\}/g, topic), - highlight: highlights[style], - style, - }) - }) - }) - results.sort(() => Math.random() - 0.5) - setAiTitleResults(results) - setAiTitleGenerating(false) - }, [aiTitleInput]) - - /* ── 标题设置更新 ── */ - const updateTitle = useCallback( - (title: string) => { - onTitleSettingsChange({ ...titleSettings, title }) - }, - [titleSettings, onTitleSettingsChange], - ) - - const toggleAiAutoSelect = useCallback(() => { - onTitleSettingsChange({ ...titleSettings, aiAutoSelect: !titleSettings.aiAutoSelect }) - }, [titleSettings, onTitleSettingsChange]) - - const updatePosition = useCallback( - (position: string) => { - onTitleSettingsChange({ ...titleSettings, position }) - }, - [titleSettings, onTitleSettingsChange], - ) - - const updateFont = useCallback( - (font: string) => { - onTitleSettingsChange({ ...titleSettings, font }) - }, - [titleSettings, onTitleSettingsChange], - ) - - const updateSize = useCallback( - (size: number) => { - onTitleSettingsChange({ ...titleSettings, size }) - }, - [titleSettings, onTitleSettingsChange], - ) - - const updateColor = useCallback( - (color: string) => { - onTitleSettingsChange({ ...titleSettings, color }) - }, - [titleSettings, onTitleSettingsChange], - ) - - const toggleBold = useCallback(() => { - onTitleSettingsChange({ ...titleSettings, bold: !titleSettings.bold }) - }, [titleSettings, onTitleSettingsChange]) - - const toggleItalic = useCallback(() => { - onTitleSettingsChange({ ...titleSettings, italic: !titleSettings.italic }) - }, [titleSettings, onTitleSettingsChange]) - - const toggleStroke = useCallback(() => { - onTitleSettingsChange({ ...titleSettings, stroke: !titleSettings.stroke }) - }, [titleSettings, onTitleSettingsChange]) - - const toggleShadow = useCallback(() => { - onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow }) - }, [titleSettings, onTitleSettingsChange]) - - const applyPreset = useCallback( - (presetKey: string) => { - const preset = TITLE_PRESETS.find((p) => p.key === presetKey) - if (!preset) return - onTitleSettingsChange({ - ...titleSettings, - size: preset.style.size, - color: preset.style.color, - bold: preset.style.bold, - italic: preset.style.italic, - stroke: preset.style.stroke, - shadow: preset.style.shadow, - }) - }, - [titleSettings, onTitleSettingsChange], - ) - - return { - // 数据 - userTitles, - titleSettings, - aiTitleInput, - setAiTitleInput, - aiTitleGenerating, - aiTitleResults, - hasGeneratedTitles, - activePreset, - titlePresets: TITLE_PRESETS, - // AI 标题操作 - handleGenerateAiTitles, - handleSelectAiTitle, - handleRefreshAiTitles, - // 标题设置操作 - updateTitle, - toggleAiAutoSelect, - updatePosition, - updateFont, - updateSize, - updateColor, - toggleBold, - toggleItalic, - toggleStroke, - toggleShadow, - applyPreset, - } -} - -export default useStep4Title diff --git a/apps/web/src/pages/generate/hooks/useStep4Title/index.ts b/apps/web/src/pages/generate/hooks/useStep4Title/index.ts new file mode 100644 index 000000000..eb8114623 --- /dev/null +++ b/apps/web/src/pages/generate/hooks/useStep4Title/index.ts @@ -0,0 +1,61 @@ +import { useQuery } from "@tanstack/react-query" +import { getTitles } from "@/api/titles" +import type { TitleSettings } from "../../types" +import { useAiTitleGenerator } from "./useAiTitleGenerator" +import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters" + +interface UseStep4TitleProps { + titleSettings: TitleSettings + onTitleSettingsChange: (settings: TitleSettings) => void +} + +/** + * Step 4 标题设置 Hook + * 封装 AI 标题生成、标题样式设置等逻辑 + */ +export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) { + // 标题库数据 + const { data: userTitles = [] } = useQuery({ + queryKey: ["titles"], + queryFn: () => getTitles(), + staleTime: 30_000, + }) + + // AI 标题生成 + const aiGenerator = useAiTitleGenerator({ titleSettings, onTitleSettingsChange }) + + // 样式更新 + const styleUpdaters = useTitleStyleUpdaters({ titleSettings, onTitleSettingsChange }) + + return { + // 数据 + userTitles, + titleSettings, + // AI 标题状态 + aiTitleInput: aiGenerator.aiTitleInput, + setAiTitleInput: aiGenerator.setAiTitleInput, + aiTitleGenerating: aiGenerator.aiTitleGenerating, + aiTitleResults: aiGenerator.aiTitleResults, + hasGeneratedTitles: aiGenerator.hasGeneratedTitles, + activePreset: styleUpdaters.activePreset, + titlePresets: styleUpdaters.titlePresets, + // AI 标题操作 + handleGenerateAiTitles: aiGenerator.handleGenerateAiTitles, + handleSelectAiTitle: aiGenerator.handleSelectAiTitle, + handleRefreshAiTitles: aiGenerator.handleRefreshAiTitles, + // 标题设置操作 + updateTitle: styleUpdaters.updateTitle, + toggleAiAutoSelect: styleUpdaters.toggleAiAutoSelect, + updatePosition: styleUpdaters.updatePosition, + updateFont: styleUpdaters.updateFont, + updateSize: styleUpdaters.updateSize, + updateColor: styleUpdaters.updateColor, + toggleBold: styleUpdaters.toggleBold, + toggleItalic: styleUpdaters.toggleItalic, + toggleStroke: styleUpdaters.toggleStroke, + toggleShadow: styleUpdaters.toggleShadow, + applyPreset: styleUpdaters.applyPreset, + } +} + +export default useStep4Title diff --git a/apps/web/src/pages/generate/hooks/useStep4Title/useAiTitleGenerator.ts b/apps/web/src/pages/generate/hooks/useStep4Title/useAiTitleGenerator.ts new file mode 100644 index 000000000..0e2f778bd --- /dev/null +++ b/apps/web/src/pages/generate/hooks/useStep4Title/useAiTitleGenerator.ts @@ -0,0 +1,102 @@ +import { useState, useCallback } from "react" +import { message } from "antd" +import { AI_TITLE_TEMPLATES } from "../../constants" +import type { TitleSettings } from "../../types" + +export interface AiTitleItem { + title: string + highlight: string + style: "catchy" | "emotional" | "informative" +} + +interface UseAiTitleGeneratorOptions { + titleSettings: TitleSettings + onTitleSettingsChange: (settings: TitleSettings) => void +} + +/** + * AI 标题生成 Hook + * 封装 AI 标题生成、刷新、选择等逻辑 + */ +export function useAiTitleGenerator({ + titleSettings, + onTitleSettingsChange, +}: UseAiTitleGeneratorOptions) { + const [aiTitleInput, setAiTitleInput] = useState("") + const [aiTitleGenerating, setAiTitleGenerating] = useState(false) + const [aiTitleResults, setAiTitleResults] = useState([]) + const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false) + + const extractTopic = (text: string): string => { + const keywords = text + .replace(/[,。!?、,.!?]/g, " ") + .split(/\s+/) + .filter(Boolean) + if (keywords.length === 0) return "这个话题" + return keywords.slice(0, 3).join("") + } + + const generateTitlesFromTopic = (topic: string): AiTitleItem[] => { + const results: AiTitleItem[] = [] + const styles: Array<"catchy" | "emotional" | "informative"> = [ + "catchy", + "emotional", + "informative", + ] + const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" } + styles.forEach((style) => { + const templates = AI_TITLE_TEMPLATES[style] + const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2) + shuffled.forEach((tpl) => { + results.push({ + title: tpl.replace(/\{topic\}/g, topic), + highlight: highlights[style], + style, + }) + }) + }) + results.sort(() => Math.random() - 0.5) + return results + } + + const handleGenerateAiTitles = useCallback(async () => { + if (!aiTitleInput.trim()) { + message.warning("请先输入视频描述或关键词") + return + } + setAiTitleGenerating(true) + setHasGeneratedTitles(true) + await new Promise((resolve) => setTimeout(resolve, 1200)) + const topic = extractTopic(aiTitleInput) + setAiTitleResults(generateTitlesFromTopic(topic)) + setAiTitleGenerating(false) + }, [aiTitleInput]) + + const handleSelectAiTitle = useCallback( + (title: string) => { + onTitleSettingsChange({ ...titleSettings, title, aiAutoSelect: false }) + message.success("已选用此标题") + }, + [titleSettings, onTitleSettingsChange], + ) + + const handleRefreshAiTitles = useCallback(async () => { + if (!aiTitleInput.trim()) return + setAiTitleGenerating(true) + await new Promise((resolve) => setTimeout(resolve, 800)) + const topic = extractTopic(aiTitleInput) + setAiTitleResults(generateTitlesFromTopic(topic)) + setAiTitleGenerating(false) + }, [aiTitleInput]) + + return { + aiTitleInput, + setAiTitleInput, + aiTitleGenerating, + aiTitleResults, + hasGeneratedTitles, + handleGenerateAiTitles, + handleSelectAiTitle, + handleRefreshAiTitles, + } +} diff --git a/apps/web/src/pages/generate/hooks/useStep4Title/useTitleStyleUpdaters.ts b/apps/web/src/pages/generate/hooks/useStep4Title/useTitleStyleUpdaters.ts new file mode 100644 index 000000000..4b70269a7 --- /dev/null +++ b/apps/web/src/pages/generate/hooks/useStep4Title/useTitleStyleUpdaters.ts @@ -0,0 +1,123 @@ +import { useCallback, useMemo } from "react" +import { TITLE_PRESETS } from "../../constants" +import type { TitleSettings } from "../../types" + +interface UseTitleStyleUpdatersOptions { + titleSettings: TitleSettings + onTitleSettingsChange: (settings: TitleSettings) => void +} + +/** + * 标题样式更新 Hook + * 封装标题文字、位置、字体、样式等所有设置更新函数 + */ +export function useTitleStyleUpdaters({ + titleSettings, + onTitleSettingsChange, +}: UseTitleStyleUpdatersOptions) { + const getActivePreset = (settings: TitleSettings): string | null => { + for (const p of TITLE_PRESETS) { + if ( + settings.size === p.style.size && + settings.color === p.style.color && + settings.bold === p.style.bold && + settings.italic === p.style.italic && + settings.stroke === p.style.stroke && + settings.shadow === p.style.shadow + ) { + return p.key + } + } + return null + } + + const activePreset = useMemo(() => getActivePreset(titleSettings), [titleSettings]) + + const updateTitle = useCallback( + (title: string) => { + onTitleSettingsChange({ ...titleSettings, title }) + }, + [titleSettings, onTitleSettingsChange], + ) + + const toggleAiAutoSelect = useCallback(() => { + onTitleSettingsChange({ ...titleSettings, aiAutoSelect: !titleSettings.aiAutoSelect }) + }, [titleSettings, onTitleSettingsChange]) + + const updatePosition = useCallback( + (position: string) => { + onTitleSettingsChange({ ...titleSettings, position }) + }, + [titleSettings, onTitleSettingsChange], + ) + + const updateFont = useCallback( + (font: string) => { + onTitleSettingsChange({ ...titleSettings, font }) + }, + [titleSettings, onTitleSettingsChange], + ) + + const updateSize = useCallback( + (size: number) => { + onTitleSettingsChange({ ...titleSettings, size }) + }, + [titleSettings, onTitleSettingsChange], + ) + + const updateColor = useCallback( + (color: string) => { + onTitleSettingsChange({ ...titleSettings, color }) + }, + [titleSettings, onTitleSettingsChange], + ) + + const toggleBold = useCallback(() => { + onTitleSettingsChange({ ...titleSettings, bold: !titleSettings.bold }) + }, [titleSettings, onTitleSettingsChange]) + + const toggleItalic = useCallback(() => { + onTitleSettingsChange({ ...titleSettings, italic: !titleSettings.italic }) + }, [titleSettings, onTitleSettingsChange]) + + const toggleStroke = useCallback(() => { + onTitleSettingsChange({ ...titleSettings, stroke: !titleSettings.stroke }) + }, [titleSettings, onTitleSettingsChange]) + + const toggleShadow = useCallback(() => { + onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow }) + }, [titleSettings, onTitleSettingsChange]) + + const applyPreset = useCallback( + (presetKey: string) => { + const preset = TITLE_PRESETS.find((p) => p.key === presetKey) + if (!preset) return + onTitleSettingsChange({ + ...titleSettings, + size: preset.style.size, + color: preset.style.color, + bold: preset.style.bold, + italic: preset.style.italic, + stroke: preset.style.stroke, + shadow: preset.style.shadow, + }) + }, + [titleSettings, onTitleSettingsChange], + ) + + return { + activePreset, + titlePresets: TITLE_PRESETS, + updateTitle, + toggleAiAutoSelect, + updatePosition, + updateFont, + updateSize, + updateColor, + toggleBold, + toggleItalic, + toggleStroke, + toggleShadow, + applyPreset, + } +} diff --git a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx index cf6dea7fe..54a85fd28 100644 --- a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx +++ b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx @@ -2,17 +2,15 @@ * Step 5 配音选择 Hook * 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑 */ -import { useState, useRef, useCallback, useEffect, useMemo } from "react" -import { useNavigate } from "react-router-dom" -import { message } from "antd" -import { useQuery, useMutation } from "@tanstack/react-query" -import type { PresetVoiceItem } from "@/api/voices" -import { fetchPresetVoices } from "@/api/voices" -import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts" -import { getTags, createTag } from "@/api/tags" -import { formatDuration } from "../utils/formatDuration" +import { useCallback } from "react" import type { VoiceClone } from "@/api/voice-clone" +import { message } from "antd" import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants" +import { formatDuration } from "../utils/formatDuration" +import { useVoiceAudio } from "./step5-voice/useVoiceAudio" +import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend" +import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis" +import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary" interface UseStep5VoiceProps { selectedVoice: string @@ -43,91 +41,47 @@ export function useStep5Voice({ onCloneModalOpenChange, titleText, }: UseStep5VoiceProps) { - const navigate = useNavigate() - /* ── 预置音色 API ── */ - const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({ - queryKey: ["preset-voices"], - queryFn: fetchPresetVoices, - }) - const presetVoices: PresetVoiceItem[] = useMemo( - () => presetVoicesData?.items ?? [], - [presetVoicesData], - ) + /* ── 子模块 ── */ + const { playingVoice, toggleVoicePlay } = useVoiceAudio() - /* ── 音频播放 ── */ - const audioRef = useRef(null) - const [playingVoice, setPlayingVoice] = useState(null) + const { + presetVoices, + presetVoicesLoading, + voiceRecommendLoading, + voiceRecommendations, + hasVoiceRecommend, + handleVoiceRecommend, + } = useVoiceRecommend(titleText) - const toggleVoicePlay = useCallback( - (voiceId: string, previewUrl: string | null) => { - if (playingVoice === voiceId) { - audioRef.current?.pause() - audioRef.current = null - setPlayingVoice(null) - return - } - audioRef.current?.pause() - if (!previewUrl) { - message.warning("该音色暂无试听音频") - return - } - const audio = new Audio(previewUrl) - audioRef.current = audio - audio.play().catch(() => { - message.error("播放失败,请检查网络") - }) - audio.onended = () => { - setPlayingVoice(null) - audioRef.current = null - } - setPlayingVoice(voiceId) - }, - [playingVoice], - ) + const { + customVoiceText, + setCustomVoiceText, + customAudioUrl, + ttsError, + ttsJobId, + completedTtsJobId, + synthesizeMutation, + handleSynthesizeVoice, + resetTtsState, + } = useTtsSynthesis(selectedVoice) - /* ── 智能配音推荐 ── */ - const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false) - const [voiceRecommendations, setVoiceRecommendations] = useState([]) - const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false) - - const handleVoiceRecommend = useCallback(async () => { - if (presetVoices.length === 0) return - setVoiceRecommendLoading(true) - setHasVoiceRecommend(true) - - await new Promise((resolve) => setTimeout(resolve, 1000)) - - // 根据标题内容风格模拟推荐:情感类→温柔女声,知识类→沉稳男声,活力类→阳光少年 - const title = titleText.toLowerCase() - let recommended: string[] = [] - - const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id) - const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id) - const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id) - - if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) { - recommended = femaleVoices.slice(0, 3) - } else if (/教程|知识|科普|干货|讲解|分析/.test(title)) { - recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1)) - } else if (/活力|热血|运动|搞笑|有趣/.test(title)) { - recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1)) - } else { - // 默认推荐前3个 - recommended = presetVoices.slice(0, 3).map((v) => v.voice_id) - } - - // 不足3个时补足 - if (recommended.length < 3) { - const others = presetVoices - .filter((v) => !recommended.includes(v.voice_id)) - .map((v) => v.voice_id) - recommended = recommended.concat(others.slice(0, 3 - recommended.length)) - } - - setVoiceRecommendations(recommended) - setVoiceRecommendLoading(false) - }, [presetVoices, titleText]) + const { + saveModalOpen, + setSaveModalOpen, + saveName, + setSaveName, + saveTagIds, + setSaveTagIds, + saveNewTag, + setSaveNewTag, + allTags, + saveToLibraryMutation, + handleOpenSaveModal, + handleConfirmSave, + handleAddTagInModal, + } = useSaveToLibrary(completedTtsJobId, resetTtsState) + /* ── 推荐音色选择 ── */ const handleSelectRecommendedVoice = useCallback( (voiceId: string) => { onVoiceModeChange("preset") @@ -136,173 +90,6 @@ export function useStep5Voice({ [onVoiceModeChange, onSelectedVoiceChange], ) - /* ── TTS 自定义合成状态 ── */ - const [customVoiceText, setCustomVoiceText] = useState("") - const [customAudioUrl, setCustomAudioUrl] = useState(null) - const [ttsError, setTtsError] = useState(null) - const [ttsJobId, setTtsJobId] = useState(null) - /** 合成完成后保留的 job ID,用于"存为素材" */ - const [completedTtsJobId, setCompletedTtsJobId] = useState(null) - - /* ── TTS mutation ── */ - const synthesizeMutation = useMutation({ - mutationFn: synthesizeSpeech, - onSuccess: (data) => { - setTtsJobId(data.job_id) - message.info("语音合成已提交,等待处理…") - }, - onError: () => { - setTtsError("语音合成请求失败,请重试") - }, - }) - - /** 轮询 TTS 任务状态 */ - useEffect(() => { - if (!ttsJobId) return - let cancelled = false - let timer: ReturnType - - const poll = async () => { - try { - const status = await getTTSJobStatus(ttsJobId) - if (cancelled) return - if (status.status === "completed") { - setCustomAudioUrl(status.output_audio_url) - setCompletedTtsJobId(ttsJobId) - setTtsJobId(null) - setTtsError(null) - message.success("语音合成完成!") - return - } - if (status.status === "failed" || status.status === "cancelled") { - setTtsError(status.error_message || "语音合成失败") - setTtsJobId(null) - return - } - timer = setTimeout(poll, 2000) - } catch { - if (!cancelled) { - setTtsError("查询合成状态失败") - setTtsJobId(null) - } - } - } - - timer = setTimeout(poll, 2000) - return () => { - cancelled = true - clearTimeout(timer) - } - }, [ttsJobId]) - - /** 触发自定义文本 TTS 合成 */ - const handleSynthesizeVoice = useCallback(() => { - if (!customVoiceText.trim()) { - message.warning("请先输入配音文案") - return - } - setTtsError(null) - setCustomAudioUrl(null) - synthesizeMutation.mutate({ - text: customVoiceText.trim(), - voice_id: selectedVoice || undefined, - language: "zh-CN", - }) - }, [customVoiceText, selectedVoice, synthesizeMutation]) - - /* ── 存为素材弹窗状态 ── */ - const [saveModalOpen, setSaveModalOpen] = useState(false) - const [saveName, setSaveName] = useState("") - const [saveTagIds, setSaveTagIds] = useState([]) - const [saveNewTag, setSaveNewTag] = useState("") - - /* ── 标签列表(用于存为素材弹窗) ── */ - const { data: allTags = [] } = useQuery({ - queryKey: ["generate-save-tags"], - queryFn: getTags, - staleTime: 30_000, - }) - - /* ── 存为素材 mutation ── */ - const saveToLibraryMutation = useMutation({ - mutationFn: (params: { name?: string; tag_ids?: string[] }) => - saveTtsToLibrary(completedTtsJobId!, params), - onSuccess: () => { - message.success({ - content: ( - - 已保存到配音库!{" "} - - 去视频库查看 - - - ), - duration: 5, - }) - setSaveModalOpen(false) - setSaveName("") - setSaveTagIds([]) - setSaveNewTag("") - setCompletedTtsJobId(null) - setCustomAudioUrl(null) - }, - onError: (err: Error) => { - message.error(`保存失败:${err.message || "请重试"}`) - }, - }) - - /** 打开存为素材弹窗 */ - const handleOpenSaveModal = useCallback(() => { - setSaveName("") - setSaveTagIds([]) - setSaveNewTag("") - setSaveModalOpen(true) - }, []) - - /** 确认保存 */ - const handleConfirmSave = useCallback(() => { - if (!completedTtsJobId) return - saveToLibraryMutation.mutate({ - name: saveName.trim() || undefined, - tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined, - }) - }, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation]) - - /** 在弹窗中新增标签(先创建再选中) */ - const handleAddTagInModal = useCallback( - async (tagName: string) => { - const trimmed = tagName.trim() - if (!trimmed) return - /* 已在选中列表则跳过 */ - const existing = allTags.find((t) => t.name === trimmed) - if (existing) { - if (!saveTagIds.includes(existing.id)) { - setSaveTagIds((prev) => [...prev, existing.id]) - } - return - } - try { - const created = await createTag(trimmed) - setSaveTagIds((prev) => [...prev, created.id]) - setSaveNewTag("") - } catch { - message.error(`创建标签"${trimmed}"失败`) - } - }, - [allTags, saveTagIds], - ) - - /** 保存成功后跳转到视频库 */ - const handleGoToLibrary = useCallback(() => { - navigate("/app/voice-materials") - }, [navigate]) - /* ── 克隆成功回调 ── */ const handleCloneSuccess = useCallback( (voice: VoiceClone) => { diff --git a/apps/web/src/pages/my-templates/MyTemplates.tsx b/apps/web/src/pages/my-templates/MyTemplates.tsx index cf60fafea..f1119f5ff 100644 --- a/apps/web/src/pages/my-templates/MyTemplates.tsx +++ b/apps/web/src/pages/my-templates/MyTemplates.tsx @@ -3,126 +3,39 @@ * 卡片视图展示用户已保存的剪辑模板 * 支持搜索、分类筛选、编辑/复制/删除/使用模板生成 */ -import React, { useState } from "react" -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" -import { - Typography, - Card, - Input, - Select, - Tag, - Button, - Space, - Empty, - Spin, - Tooltip, - message, - Popconfirm, - Row, - Col, -} from "antd" -import { - SearchOutlined, - EditOutlined, - CopyOutlined, - DeleteOutlined, - VideoCameraOutlined, - AppstoreOutlined, - PlusOutlined, -} from "@ant-design/icons" +import React from "react" +import { Typography, Input, Select, Button, Empty, Spin, Row, Col } from "antd" +import { SearchOutlined, AppstoreOutlined, PlusOutlined } from "@ant-design/icons" import { useNavigate } from "react-router-dom" -import { - getEditingTemplates, - getTemplateCategories, - deleteEditingTemplate, - createEditingTemplate, - MODE_LABELS, - MODE_COLORS, - type EditingTemplate, - type TemplateMode, -} from "@/api/editing-planner" +import type { EditingTemplate } from "@/api/editing-planner" +import { useMyTemplates } from "./hooks/useMyTemplates" +import { TemplateCard } from "./components/TemplateCard" import "./MyTemplates.css" const { Title, Text } = Typography const MyTemplates: React.FC = () => { const navigate = useNavigate() - const queryClient = useQueryClient() + const { + searchText, + setSearchText, + filterCategory, + setFilterCategory, + templates, + categories, + isLoading, + handleCopy, + handleDelete, + } = useMyTemplates() - const [searchText, setSearchText] = useState("") - const [filterCategory, setFilterCategory] = useState("") - - /* ── 数据查询 ── */ - const { data: templates = [], isLoading } = useQuery({ - queryKey: ["editing-templates", filterCategory, searchText], - queryFn: () => - getEditingTemplates({ - category: filterCategory || undefined, - tag: searchText || undefined, - }), - }) - - const { data: categories = [] } = useQuery({ - queryKey: ["template-categories"], - queryFn: getTemplateCategories, - }) - - /* ── Mutations ── */ - const deleteMutation = useMutation({ - mutationFn: deleteEditingTemplate, - onSuccess: () => { - message.success("模板已删除") - queryClient.invalidateQueries({ queryKey: ["editing-templates"] }) - }, - onError: (err: unknown) => { - if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("删除失败") - }, - }) - - const copyMutation = useMutation({ - mutationFn: (tpl: EditingTemplate) => - createEditingTemplate({ - name: `${tpl.name}(副本)`, - mode: tpl.mode, - category: tpl.category, - tags: tpl.tags, - title_config: tpl.title_config, - subtitle_config: tpl.subtitle_config, - bgm_config: tpl.bgm_config, - estimated_duration: - tpl.estimated_duration ?? - Math.round( - tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0), - ), - segments: tpl.segments.map(({ id: _id, ...rest }) => rest), - }), - onSuccess: () => { - message.success("模板已复制") - queryClient.invalidateQueries({ queryKey: ["editing-templates"] }) - }, - onError: (err: unknown) => { - if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("复制失败") - }, - }) - - /* ── 操作 ── */ const handleEdit = (tpl: EditingTemplate) => { navigate(`/editing-planner?template=${tpl.id}`) } const handleGenerate = (tpl: EditingTemplate) => { - // 跳转到智能剪辑页面,统一从智能剪辑出片 navigate(`/generate?templateId=${tpl.id}`) } - const handleCopy = (tpl: EditingTemplate) => { - copyMutation.mutate(tpl) - } - - const handleDelete = (id: string) => { - deleteMutation.mutate(id) - } - return (
{/* 页面头部 */} @@ -179,69 +92,13 @@ const MyTemplates: React.FC = () => { {templates.map((tpl) => ( - - handleEdit(tpl)} /> - , - - handleCopy(tpl)} /> - , - - handleGenerate(tpl)} /> - , - handleDelete(tpl.id)} - okText="删除" - cancelText="取消" - > - - - - , - ]} - > -
- - {tpl.name} - - - {MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} - - 用户自制 -
- -
- - {tpl.segments.length} 片段 · 预估 ~{tpl.estimated_duration}s - - {tpl.category && ( - {tpl.category} - )} -
- - {tpl.tags.length > 0 && ( -
- {tpl.tags.map((tag) => ( - - {tag} - - ))} -
- )} - -
- - {tpl.title_config.ai_auto_select && AI标题} - {tpl.subtitle_config.enabled && 字幕} - {tpl.bgm_config.enabled && BGM} - -
-
+ ))}
diff --git a/apps/web/src/pages/my-templates/components/TemplateCard.tsx b/apps/web/src/pages/my-templates/components/TemplateCard.tsx new file mode 100644 index 000000000..2979228c3 --- /dev/null +++ b/apps/web/src/pages/my-templates/components/TemplateCard.tsx @@ -0,0 +1,92 @@ +import React from "react" +import { Card, Tag, Tooltip, Popconfirm, Space, Typography } from "antd" +import { EditOutlined, CopyOutlined, DeleteOutlined, VideoCameraOutlined } from "@ant-design/icons" +import { + MODE_LABELS, + MODE_COLORS, + type EditingTemplate, + type TemplateMode, +} from "@/api/editing-planner" + +const { Text } = Typography + +interface TemplateCardProps { + tpl: EditingTemplate + onEdit: (tpl: EditingTemplate) => void + onCopy: (tpl: EditingTemplate) => void + onGenerate: (tpl: EditingTemplate) => void + onDelete: (id: string) => void +} + +/** + * 单个模板卡片组件 + */ +export const TemplateCard: React.FC = ({ + tpl, + onEdit, + onCopy, + onGenerate, + onDelete, +}) => ( + + onEdit(tpl)} /> + , + + onCopy(tpl)} /> + , + + onGenerate(tpl)} /> + , + onDelete(tpl.id)} + okText="删除" + cancelText="取消" + > + + + + , + ]} + > +
+ + {tpl.name} + + + {MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} + + 用户自制 +
+ +
+ + {tpl.segments.length} 片段 · 预估 ~{tpl.estimated_duration}s + + {tpl.category && {tpl.category}} +
+ + {tpl.tags.length > 0 && ( +
+ {tpl.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + +
+ + {tpl.title_config.ai_auto_select && AI标题} + {tpl.subtitle_config.enabled && 字幕} + {tpl.bgm_config.enabled && BGM} + +
+
+) diff --git a/apps/web/src/pages/my-templates/hooks/useMyTemplates.ts b/apps/web/src/pages/my-templates/hooks/useMyTemplates.ts new file mode 100644 index 000000000..76610297c --- /dev/null +++ b/apps/web/src/pages/my-templates/hooks/useMyTemplates.ts @@ -0,0 +1,100 @@ +import { useState } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { message } from "antd" +import { + getEditingTemplates, + getTemplateCategories, + deleteEditingTemplate, + createEditingTemplate, + type EditingTemplate, +} from "@/api/editing-planner" + +/** + * 我的模板数据 Hook + * 封装模板列表查询、筛选、删除、复制等数据操作 + */ +export function useMyTemplates() { + const queryClient = useQueryClient() + const [searchText, setSearchText] = useState("") + const [filterCategory, setFilterCategory] = useState("") + + /* 模板列表 */ + const { data: templates = [], isLoading } = useQuery({ + queryKey: ["editing-templates", filterCategory, searchText], + queryFn: () => + getEditingTemplates({ + category: filterCategory || undefined, + tag: searchText || undefined, + }), + }) + + /* 分类列表 */ + const { data: categories = [] } = useQuery({ + queryKey: ["template-categories"], + queryFn: getTemplateCategories, + }) + + /* 删除 mutation */ + const deleteMutation = useMutation({ + mutationFn: deleteEditingTemplate, + onSuccess: () => { + message.success("模板已删除") + queryClient.invalidateQueries({ queryKey: ["editing-templates"] }) + }, + onError: (err: unknown) => { + if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("删除失败") + }, + }) + + /* 复制 mutation */ + const copyMutation = useMutation({ + mutationFn: (tpl: EditingTemplate) => + createEditingTemplate({ + name: `${tpl.name}(副本)`, + mode: tpl.mode, + category: tpl.category, + tags: tpl.tags, + title_config: tpl.title_config, + subtitle_config: tpl.subtitle_config, + bgm_config: tpl.bgm_config, + estimated_duration: + tpl.estimated_duration ?? + Math.round( + tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0), + ), + segments: tpl.segments.map(({ id: _id, ...rest }) => rest), + }), + onSuccess: () => { + message.success("模板已复制") + queryClient.invalidateQueries({ queryKey: ["editing-templates"] }) + }, + onError: (err: unknown) => { + if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("复制失败") + }, + }) + + const handleCopy = (tpl: EditingTemplate) => { + copyMutation.mutate(tpl) + } + + const handleDelete = (id: string) => { + deleteMutation.mutate(id) + } + + return { + // 状态 + searchText, + setSearchText, + filterCategory, + setFilterCategory, + // 数据 + templates, + categories, + isLoading, + // 操作 + handleCopy, + handleDelete, + isDeleting: deleteMutation.isPending, + isCopying: copyMutation.isPending, + } +} diff --git a/apps/web/src/pages/products/ProductLibrary.tsx b/apps/web/src/pages/products/ProductLibrary.tsx old mode 100755 new mode 100644 index af70965eb..d2c4ca906 --- a/apps/web/src/pages/products/ProductLibrary.tsx +++ b/apps/web/src/pages/products/ProductLibrary.tsx @@ -1,38 +1,29 @@ /** * 成片库页面 — V21 设计系统 * 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选 - * 使用 useQuery 对接后端真实 API(api/products.ts) * - * 代码结构(三阶段重构后): - * - types.ts: 类型定义 - * - constants.ts: 常量配置 - * - utils/index.ts: 工具函数 - * - components/ProductCard.tsx: 产品卡片组件 - * - components/VideoPlayer.tsx: 视频播放器组件 - * - hooks/useProductList.ts: 列表查询与筛选 - * - hooks/useProductActions.ts: 单个/批量操作 + * 主组件仅保留 Hook 组装与整体布局 + * 列表查询 → hooks/useProductList + * 操作逻辑 → hooks/useProductActions + * 筛选栏 → components/ProductFilterBar + * 批量操作栏 → components/ProductBatchBar + * 空状态 → components/ProductEmptyState + * 产品卡片 → components/ProductCard + * 视频播放 → components/VideoPlayer */ import React, { useState } from "react" -import { Popconfirm, message } from "antd" -import { - SearchOutlined, - VideoCameraOutlined, - DownloadOutlined, - DeleteOutlined, - CheckOutlined, - CloudUploadOutlined, -} from "@ant-design/icons" -import { Button, Input, Select } from "@/components/ui" +import { VideoCameraOutlined, DownloadOutlined } from "@ant-design/icons" +import { Button } from "@/components/ui" import type { ProductItem } from "./types" import { ProductCard } from "./components/ProductCard" import { VideoPlayer } from "./components/VideoPlayer" +import { ProductFilterBar } from "./components/ProductFilterBar" +import { ProductBatchBar } from "./components/ProductBatchBar" +import { ProductEmptyState } from "./components/ProductEmptyState" import { useProductList } from "./hooks/useProductList" import { useProductActions } from "./hooks/useProductActions" import "./products.css" -/* ============================================================ - * 主组件 - * ============================================================ */ const ProductLibrary: React.FC = () => { const { products, @@ -83,58 +74,20 @@ const ProductLibrary: React.FC = () => { setPlayingProduct, }) + // ── Loading 状态 ── if (isLoading) { - return ( -
-
-
-

加载中...

-
-
- ) + return } // ── Error 状态 ── if (isError) { console.error("[ProductLibrary] 加载失败:", error) const errorMsg = error?.message || "加载失败" - // 404 视为空数据(API 尚未就绪或无数据) const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found") if (is404) { - return ( -
-
-

- 成片库 -

-
-
-
🎬
-

暂无成片数据

-

- 完成视频生成后,成片将自动保存到这里 -

-
-
- ) + return } - return ( -
-
-
-

{errorMsg || "加载失败,请稍后重试"}

- -
-
- ) + return } return ( @@ -153,129 +106,35 @@ const ProductLibrary: React.FC = () => { {/* 批量操作栏 */} {batchMode && ( -
-
-
- {allSelected && } -
- - {allSelected ? "取消全选" : "全选"} - - 已选择 {selectedIds.size} 项 -
-
- - - - - - -
-
+ )} {/* 筛选栏 */} -
-
- } - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - allowClear - style={{ width: 220 }} - /> - - - } + value={searchText} + onChange={(e) => onSearchChange(e.target.value)} + allowClear + style={{ width: 220 }} + /> + + +