From 1a0679049e3938544e6b3726c774ce96cf7bb767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=B1=BC=E5=BC=80=E5=8F=91?= Date: Fri, 22 May 2026 15:02:11 +0800 Subject: [PATCH] refactor(profile): restore recent transactions table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace menu list (使用明细 + 设置) with recent transactions table: - Add back recentTx state and loading state - Fetch last 5 transactions in loadData - Display table with type/amount/description/time columns - Add '查看全部' link to usage-detail page - Remove unused icon components (FileTextIcon, SettingsIcon, ChevronRightIcon) --- python-api/.env.example | 3 + python-api/app/ai/adapters/constants.py | 1 + .../adapters/volcengine_mediakit_adapter.py | 91 ++++++++++++ .../providers/volcengine_mediakit_provider.py | 129 ++++++++++++++++++ python-api/app/config.py | 3 + python-api/app/main.py | 35 +++++ .../services/volcengine_mediakit_service.py | 83 +++++++++++ python-api/config/platform-config.yaml | 17 +++ python-api/pyproject.toml | 3 + tauri-app/src/api/modules/localStorage.ts | 1 + tauri-app/src/pages/Profile/Profile.tsx | 117 +++++++++++----- .../src/pages/VideoCreation/CoverDesign.tsx | 8 ++ .../pages/VideoCreation/ScriptCreation.tsx | 2 + .../pages/VideoCreation/SubtitleBurning.tsx | 8 ++ .../src/pages/VideoCreation/VideoCompose.tsx | 11 +- .../src/pages/VideoCreation/VideoCreation.css | 35 +++++ .../pages/VideoCreation/VoiceSynthesis.tsx | 9 ++ tauri-app/src/pages/VideoCreation/index.tsx | 18 ++- .../hooks/useVideoGeneration.ts | 1 + tauri-app/src/pages/VideoGeneration/index.tsx | 11 +- tauri-app/src/store/projectStore.ts | 31 +++++ tauri-app/src/types/project.ts | 3 + tauri-app/src/utils/projectMeta.ts | 1 + 23 files changed, 584 insertions(+), 37 deletions(-) create mode 100644 python-api/app/ai/adapters/volcengine_mediakit_adapter.py create mode 100644 python-api/app/ai/providers/volcengine_mediakit_provider.py create mode 100644 python-api/app/services/volcengine_mediakit_service.py diff --git a/python-api/.env.example b/python-api/.env.example index c00cdee..d0251a3 100644 --- a/python-api/.env.example +++ b/python-api/.env.example @@ -47,6 +47,9 @@ VOLCENGINE_API_KEY=your-volcengine-api-key VOLCENGINE_CAPTION_APPID=your-caption-appid VOLCENGINE_CAPTION_TOKEN=your-caption-token +# 火山 MediaKit +VOLCENGINE_MEDIAKIT_TOKEN=your-mediakit-token + # Vidu(TTS、声音复刻、对口型) VIDU_API_KEY=your-vidu-api-key diff --git a/python-api/app/ai/adapters/constants.py b/python-api/app/ai/adapters/constants.py index efea890..34911e5 100644 --- a/python-api/app/ai/adapters/constants.py +++ b/python-api/app/ai/adapters/constants.py @@ -20,3 +20,4 @@ class Method: CAPTION = "caption" AUTO_ALIGN = "auto_align" VIDEO_GENERATE = "video_generate" + REMOVE_BACKGROUND = "remove_background" diff --git a/python-api/app/ai/adapters/volcengine_mediakit_adapter.py b/python-api/app/ai/adapters/volcengine_mediakit_adapter.py new file mode 100644 index 0000000..4ae8771 --- /dev/null +++ b/python-api/app/ai/adapters/volcengine_mediakit_adapter.py @@ -0,0 +1,91 @@ +""" +火山引擎 MediaKit Adapter +========================== + +实现 PlatformAdapter + SyncCapable。 +直接接入 VolcengineMediakitProvider,提供标准 Protocol 接口。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +from app.ai.adapters.base import AdapterResponse, PlatformAdapter, SyncCapable +from app.ai.adapters.constants import Method +from app.ai.providers.volcengine_mediakit_provider import VolcengineMediakitProvider +from app.core.exceptions import PlatformError, PlatformErrorType + +logger = logging.getLogger(__name__) + + +class VolcengineMediakitAdapter(PlatformAdapter, SyncCapable): + """火山引擎 MediaKit 平台标准 Adapter""" + + platform_id = "volcengine_mediakit" + + def __init__(self, provider: VolcengineMediakitProvider): + self.provider = provider + + # ── PlatformAdapter ── + + async def health(self) -> AdapterResponse: + try: + # 用无效 URL 测试连通性(400 说明网络通且认证通过) + await self.provider.remove_background( + image_url="https://example.com/health-check.jpg", + scene="general", + ) + return AdapterResponse(success=True) + except PlatformError as e: + if e.error_type in ( + PlatformErrorType.AUTH_FAILED, + PlatformErrorType.BAD_REQUEST, + ): + return AdapterResponse(success=True) + return AdapterResponse( + success=False, + error_message=str(e), + retryable=e.retryable, + ) + except Exception as e: + return AdapterResponse( + success=False, + error_message=str(e), + retryable=False, + ) + + async def close(self) -> None: + await self.provider.close() + + # ── SyncCapable ── + + async def call(self, method: str, payload: dict[str, Any]) -> AdapterResponse: + try: + if method == Method.REMOVE_BACKGROUND: + result = await self.provider.remove_background( + image_url=payload["image_url"], + scene=payload.get("scene", "general"), + ) + data = result.get("data", {}) + return AdapterResponse( + success=True, + data={"image_url": data.get("image_url")}, + ) + + else: + return AdapterResponse( + success=False, + error_message=f"不支持的方法: {method}", + retryable=False, + ) + + except PlatformError: + raise + except Exception as e: + raise PlatformError( + f"MediaKit {method} 调用失败: {e}", + platform="volcengine_mediakit", + retryable=False, + error_type=PlatformErrorType.UNKNOWN, + ) from e diff --git a/python-api/app/ai/providers/volcengine_mediakit_provider.py b/python-api/app/ai/providers/volcengine_mediakit_provider.py new file mode 100644 index 0000000..b936cae --- /dev/null +++ b/python-api/app/ai/providers/volcengine_mediakit_provider.py @@ -0,0 +1,129 @@ +""" +火山引擎 MediaKit Provider +=========================== + +直接封装火山引擎 MediaKit HTTP API: +- 图像背景移除(/api/v1/tools/remove-image-background/sync) + +使用 httpx.AsyncClient,支持外部注入(由 lifespan 管理生命周期)。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +from app.config import get_settings +from app.core.exceptions import PlatformError, PlatformErrorType + +logger = logging.getLogger(__name__) + + +def _map_mediakit_error(status: int, message: str, code: int | None = None) -> PlatformError: + """把 MediaKit 错误映射为标准 PlatformError""" + error_mapping = { + 400: (PlatformErrorType.BAD_REQUEST, False), + 401: (PlatformErrorType.AUTH_FAILED, False), + 403: (PlatformErrorType.AUTH_FAILED, False), + 429: (PlatformErrorType.RATE_LIMIT, True), + 500: (PlatformErrorType.SERVER_ERROR, True), + 502: (PlatformErrorType.SERVER_ERROR, True), + 503: (PlatformErrorType.SERVER_ERROR, True), + } + error_type, retryable = error_mapping.get(status, (PlatformErrorType.UNKNOWN, False)) + return PlatformError( + message, platform="volcengine_mediakit", + retryable=retryable, error_type=error_type, + status_code=status, + ) + + +class VolcengineMediakitProvider: + """火山引擎 MediaKit Provider + + 直接调用 MediaKit HTTP API,不做业务层处理。 + """ + + BASE_URL = "https://mediakit.cn-beijing.volces.com" + REMOVE_BG_PATH = "/api/v1/tools/remove-image-background/sync" + DEFAULT_TIMEOUT = 60.0 + + def __init__( + self, + token: str | None = None, + client: httpx.AsyncClient | None = None, + ): + settings = get_settings() + self.token = token or settings.VOLCENGINE_MEDIAKIT_TOKEN or "" + + if not self.token: + raise PlatformError( + "VOLCENGINE_MEDIAKIT_TOKEN 未配置", + platform="volcengine_mediakit", + retryable=False, + error_type=PlatformErrorType.BAD_REQUEST, + ) + + if client is not None: + self.client = client + self._owns_client = False + else: + self.client = httpx.AsyncClient(timeout=self.DEFAULT_TIMEOUT) + self._owns_client = True + + def _get_headers(self) -> dict: + return { + "Authorization": f"Bearer; {self.token}", + "Content-Type": "application/json", + } + + async def close(self) -> None: + """关闭 HTTP 客户端""" + if self._owns_client and self.client and not self.client.is_closed: + await self.client.aclose() + + async def remove_background( + self, + image_url: str, + scene: str = "general", + ) -> dict[str, Any]: + """同步抠图,返回原始 JSON + + Returns: + {"code": 0, "message": "Success", "data": {"image_url": "https://..."}} + """ + payload = {"image_url": image_url, "scene": scene} + + try: + response = await self.client.post( + f"{self.BASE_URL}{self.REMOVE_BG_PATH}", + json=payload, + headers=self._get_headers(), + ) + response.raise_for_status() + data = response.json() + + code = data.get("code", -1) + if code != 0: + raise _map_mediakit_error( + response.status_code, + data.get("message", f"抠图失败: code={code}"), + code=code, + ) + return data + + except PlatformError: + raise + except httpx.HTTPStatusError as e: + raise _map_mediakit_error( + e.response.status_code, f"HTTP错误: {e.response.status_code}" + ) from e + except (httpx.NetworkError, httpx.TimeoutException) as e: + raise PlatformError( + f"MediaKit 网络错误: {e}", platform="volcengine_mediakit", + retryable=True, error_type=PlatformErrorType.TIMEOUT, + ) from e + except Exception as e: + raise _map_mediakit_error(500, f"抠图失败: {str(e)}") from e diff --git a/python-api/app/config.py b/python-api/app/config.py index 91b06b1..ea05eea 100644 --- a/python-api/app/config.py +++ b/python-api/app/config.py @@ -106,6 +106,9 @@ class Settings(BaseSettings): VOLCENGINE_CAPTION_APPID: str | None = Field(default=None, description="火山字幕 AppID") VOLCENGINE_CAPTION_TOKEN: str | None = Field(default=None, description="火山字幕 Token") + # 火山引擎 MediaKit 服务(背景移除等多媒体处理) + VOLCENGINE_MEDIAKIT_TOKEN: str | None = Field(default=None, description="火山引擎 MediaKit Token") + # Vidu 密钥(base_url 已从 Settings 移除,改用 config/platform-config.yaml 配置) VIDU_API_KEY: str | None = Field(default=None, description="Vidu API Key") diff --git a/python-api/app/main.py b/python-api/app/main.py index d015812..f78718c 100644 --- a/python-api/app/main.py +++ b/python-api/app/main.py @@ -66,6 +66,10 @@ async def lifespan(app: FastAPI): timeout=httpx.Timeout(60.0, connect=5.0), limits=httpx.Limits(max_connections=10, max_keepalive_connections=10), ), + "volcengine_mediakit": httpx.AsyncClient( + timeout=httpx.Timeout(60.0, connect=5.0), + limits=httpx.Limits(max_connections=10, max_keepalive_connections=10), + ), "default": httpx.AsyncClient( timeout=httpx.Timeout(30.0, connect=5.0), limits=httpx.Limits(max_connections=50, max_keepalive_connections=20), @@ -90,6 +94,18 @@ async def lifespan(app: FastAPI): logger.warning(f"Volcengine Caption Provider 初始化跳过: {e}") app.state.volcengine_caption_provider = None + # 火山 Mediakit Provider + from app.ai.providers.volcengine_mediakit_provider import VolcengineMediakitProvider + + try: + app.state.volcengine_mediakit_provider = VolcengineMediakitProvider( + client=app.state.http_clients["volcengine_mediakit"] + ) + logger.info("Volcengine Mediakit Provider initialized") + except Exception as e: + logger.warning(f"Volcengine Mediakit Provider 初始化跳过: {e}") + app.state.volcengine_mediakit_provider = None + # 火山方舟 Provider(可选,需要 API Key) try: from app.ai.providers.volcengine_provider import VolcengineProvider @@ -104,6 +120,7 @@ async def lifespan(app: FastAPI): from app.ai.adapters.vidu_adapter import ViduAdapter from app.ai.adapters.volcengine_ark_adapter import VolcengineArkAdapter from app.ai.adapters.volcengine_caption_adapter import VolcengineCaptionAdapter + from app.ai.adapters.volcengine_mediakit_adapter import VolcengineMediakitAdapter from app.platform_gateway import PlatformGateway app.state.vidu_adapter = ViduAdapter(app.state.vidu_provider) @@ -122,6 +139,15 @@ async def lifespan(app: FastAPI): ) logger.info("VolcengineCaptionAdapter initialized") + if app.state.volcengine_mediakit_provider: + app.state.volcengine_mediakit_adapter = VolcengineMediakitAdapter( + app.state.volcengine_mediakit_provider + ) + app.state.platform_gateway.register( + "volcengine_mediakit", app.state.volcengine_mediakit_adapter + ) + logger.info("VolcengineMediakitAdapter initialized") + if app.state.volcengine_provider: app.state.volcengine_ark_adapter = VolcengineArkAdapter( app.state.volcengine_provider @@ -145,6 +171,7 @@ async def lifespan(app: FastAPI): # 初始化 Service(传入 Gateway) from app.services.vidu_service import ViduService from app.services.volcengine_caption_service import VolcengineCaptionService + from app.services.volcengine_mediakit_service import VolcengineMediakitService app.state.vidu_service = ViduService(app.state.platform_gateway) logger.info("Vidu Service initialized") @@ -157,6 +184,14 @@ async def lifespan(app: FastAPI): else: app.state.volcengine_caption_service = None + if app.state.volcengine_mediakit_provider: + app.state.volcengine_mediakit_service = VolcengineMediakitService( + app.state.platform_gateway + ) + logger.info("Volcengine Mediakit Service initialized") + else: + app.state.volcengine_mediakit_service = None + # LLM Gateway(可选,向后兼容) if app.state.volcengine_provider: from app.ai.gateways.llm_gateway import LLMGateway diff --git a/python-api/app/services/volcengine_mediakit_service.py b/python-api/app/services/volcengine_mediakit_service.py new file mode 100644 index 0000000..dcfe05c --- /dev/null +++ b/python-api/app/services/volcengine_mediakit_service.py @@ -0,0 +1,83 @@ +""" +火山引擎 MediaKit Service +========================= + +通过 PlatformGateway 调用 MediaKit 第三方 API,自身负责: +- 参数校验 +- 结果提取与格式化 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any + +from app.ai.adapters.constants import Method +from app.platform_gateway import PlatformGateway + +logger = logging.getLogger(__name__) + + +@dataclass +class RemoveBackgroundResult: + """抠图结果""" + + image_url: str = "" + raw: dict[str, Any] = field(default_factory=dict) + + +class VolcengineMediakitService: + """火山引擎 MediaKit 服务封装""" + + # 支持的场景 + SUPPORTED_SCENES = {"general", "product"} + + def __init__(self, gateway: PlatformGateway): + self.gateway = gateway + + async def remove_background( + self, + image_url: str, + scene: str = "general", + ) -> RemoveBackgroundResult: + """同步抠图 + + Args: + image_url: 原始图片 URL + scene: 场景类型,"general"(通用)或 "product"(商品) + + Returns: + RemoveBackgroundResult: 包含抠图结果图片 URL + + Raises: + ValueError: 参数校验失败 + PlatformError: 平台调用失败 + """ + if not image_url: + raise ValueError("image_url 不能为空") + + if scene not in self.SUPPORTED_SCENES: + raise ValueError(f"不支持的场景: {scene},可选: {self.SUPPORTED_SCENES}") + + payload = { + "image_url": image_url, + "scene": scene, + } + + response = await self.gateway.call_sync( + platform="volcengine_mediakit", + method=Method.REMOVE_BACKGROUND, + payload=payload, + ) + + if not response.success: + raise RuntimeError( + response.error_message or "抠图失败" + ) + + result_image_url = (response.data or {}).get("image_url", "") + return RemoveBackgroundResult( + image_url=result_image_url, + raw=response.data or {}, + ) diff --git a/python-api/config/platform-config.yaml b/python-api/config/platform-config.yaml index c8c1707..3b39630 100644 --- a/python-api/config/platform-config.yaml +++ b/python-api/config/platform-config.yaml @@ -87,6 +87,23 @@ platforms: qps: 5 burst: 10 + # ── 火山引擎媒体处理(MediaKit)── + volcengine_mediakit: + name: "火山引擎媒体处理" + provider: "volcengine_mediakit" + base_url: "https://mediakit.cn-beijing.volces.com" + rate_limit: + qps: 5 + burst: 10 + models: [] + methods: + remove_background: + timeout: 120 + max_connections: 10 + rate_limit: + qps: 5 + burst: 10 + # ── 任务默认模型映射 ── # 当调用方未指定模型时,按任务类型选择默认模型 task_defaults: diff --git a/python-api/pyproject.toml b/python-api/pyproject.toml index b391f4c..5ac099a 100644 --- a/python-api/pyproject.toml +++ b/python-api/pyproject.toml @@ -61,6 +61,9 @@ dependencies = [ # 音频时长探测(TTS 扣费用) "mutagen~=1.47.0", + + # 图像处理(智能抠图合成封面) + "Pillow>=11.0.0", ] [project.optional-dependencies] diff --git a/tauri-app/src/api/modules/localStorage.ts b/tauri-app/src/api/modules/localStorage.ts index 371fc7a..9d17fe4 100644 --- a/tauri-app/src/api/modules/localStorage.ts +++ b/tauri-app/src/api/modules/localStorage.ts @@ -127,6 +127,7 @@ export const localProjectApi = { coverConfig: meta.coverConfig, version: meta.version, userUploadedMaterials: meta.userUploadedMaterials, + stepDirtyFlags: meta.stepDirtyFlags, }; const jsonContent = JSON.stringify(orderedMeta, null, 2); const res = await safeInvoke>('save_project_meta_raw', { diff --git a/tauri-app/src/pages/Profile/Profile.tsx b/tauri-app/src/pages/Profile/Profile.tsx index 860da87..6120f26 100644 --- a/tauri-app/src/pages/Profile/Profile.tsx +++ b/tauri-app/src/pages/Profile/Profile.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useNavigation } from '../../contexts/NavigationContext'; import { useAuthStore } from '../../store'; import { client } from '../../api/client'; -import { pointsApi, type PointBalance } from '../../api/modules/points'; +import { pointsApi, type PointBalance, type PointTransaction } from '../../api/modules/points'; import RechargeModal from '../../components/RechargeModal/RechargeModal'; import PricingModal from '../../components/PricingModal/PricingModal'; import PointsCard from '../../components/PointsCard/PointsCard'; @@ -21,24 +21,6 @@ function maskMobile(mobile: string): string { return `${mobile.slice(0, 3)}****${mobile.slice(7)}`; } -const FileTextIcon = () => ( - - - -); - -const SettingsIcon = () => ( - - - -); - -const ChevronRightIcon = ({ className }: { className?: string }) => ( - - - -); - const EditIcon = () => ( @@ -54,6 +36,8 @@ export default function Profile() { const [todayConsumed, setTodayConsumed] = useState(0); const [showRechargeModal, setShowRechargeModal] = useState(false); const [showPricingModal, setShowPricingModal] = useState(false); + const [recentTx, setRecentTx] = useState([]); + const [loading, setLoading] = useState(true); // 昵称编辑状态 const [nickname, setNickname] = useState(''); @@ -62,6 +46,7 @@ export default function Profile() { const [nickError, setNickError] = useState(''); const loadData = async () => { + setLoading(true); try { const [profileData, balanceData] = await Promise.all([ client.get('/auth/me').catch(() => null), @@ -73,10 +58,16 @@ export default function Profile() { } if (balanceData) { setBalance(balanceData); } - const todayData = await pointsApi.getTodayConsumed().catch(() => null); + const [txData, todayData] = await Promise.all([ + pointsApi.getTransactions({ page: 1, pageSize: 5 }).catch(() => null), + pointsApi.getTodayConsumed().catch(() => null), + ]); + if (txData) { setRecentTx(txData.items); } if (todayData) { setTodayConsumed(todayData.total); } } catch (e) { console.error('[Profile] 加载数据失败:', e); + } finally { + setLoading(false); } }; @@ -121,10 +112,22 @@ export default function Profile() { const displayAvatar = user?.avatar || authUser?.avatar || ''; const displayMobile = user?.mobile ? maskMobile(user.mobile) : ''; - const menuItems = [ - { label: '使用明细', icon: , onClick: () => navigate('usage-detail') }, - { label: '设置', icon: , onClick: () => navigate('settings') }, - ]; + const TYPE_LABELS: Record = { + recharge: '充值', + consume: '消费', + expire: '过期', + refund: '退款', + }; + + function formatTxTime(iso: string): string { + const d = new Date(iso); + return d.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + } return (
@@ -202,16 +205,64 @@ export default function Profile() { />
- {/* 设置入口 */} + {/* 最近记录 */}
-
- {menuItems.map((item) => ( - - ))} +
+

最近记录

+ +
+
+ + + + + + + + + + + {loading ? ( + + + + ) : recentTx.length === 0 ? ( + + + + ) : ( + recentTx.map((tx) => ( + + + + + + + )) + )} + +
类型变动积分说明时间
+ 加载中... +
+ 暂无记录 +
+ + {TYPE_LABELS[tx.type] || tx.type} + + + {tx.type === 'recharge' ? '+' : '-'}{tx.amount} + + {tx.description || '-'} + {formatTxTime(tx.createdAt)}
diff --git a/tauri-app/src/pages/VideoCreation/CoverDesign.tsx b/tauri-app/src/pages/VideoCreation/CoverDesign.tsx index 79eacb1..c187a7d 100644 --- a/tauri-app/src/pages/VideoCreation/CoverDesign.tsx +++ b/tauri-app/src/pages/VideoCreation/CoverDesign.tsx @@ -62,6 +62,8 @@ export default function CoverDesign() { const coverPath = useProjectStore((state) => state.coverPath); const setCoverPath = useProjectStore((state) => state.setCoverPath); const setCoverConfig = useProjectStore((state) => state.setCoverConfig); + const stepDirtyFlags = useProjectStore((state) => state.stepDirtyFlags); + const isStepDirty = (stepDirtyFlags?.[5] ?? false) && !!coverPath; const projectId = getCurrentProjectId(); const [config, setConfig] = useState<{ @@ -289,6 +291,7 @@ export default function CoverDesign() { subtitle: config.subtitle.trim(), }); + useProjectStore.getState().clearStepDirty(5); useProgressStore.getState().success('封面设计完成', 2); } catch (error: unknown) { const message = (error instanceof Error ? error.message : String(error)) || '封面设计失败'; @@ -308,6 +311,11 @@ export default function CoverDesign() { return (
+ {isStepDirty && ( +
+ 上游数据已更新,当前封面可能与最新内容不匹配,建议重新设计 +
+ )} state.segments); const setSegments = useProjectStore(state => state.setSegments); + const markStepsDirty = useProjectStore(state => state.markStepsDirty); const updateSegment = useProjectStore(state => state.updateSegment); const categoryCode = useProjectStore(state => state.categoryCode); const subcategoryCode = useProjectStore(state => state.subcategoryCode); @@ -213,6 +214,7 @@ export default function ScriptCreation() { setSegments(normalizedShots); setExpandedSegments(new Set(normalizedShots.map(s => s.id))); + markStepsDirty(1); progress.success('脚本生成成功', 5); // 设置标题 diff --git a/tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx b/tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx index 0f2c4e6..4480469 100644 --- a/tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx +++ b/tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx @@ -91,6 +91,8 @@ export default function SubtitleBurning() { // 预览用 const { videoUrl: loadedVideoUrl } = useLocalVideo(actualVideoUrl || actualVideoPath); const burnedVideoPath = useProjectStore(state => state.burnedVideoPath); + const stepDirtyFlags = useProjectStore(state => state.stepDirtyFlags); + const isStepDirty = (stepDirtyFlags?.[4] ?? false) && !!burnedVideoPath; const { videoUrl: burnedVideoUrl } = useLocalVideo(burnedVideoPath || undefined); const hasBurnedVideo = !!burnedVideoPath; @@ -438,6 +440,7 @@ export default function SubtitleBurning() { // 7. 保存压制结果 useProjectStore.setState({ burnedVideoPath: outputPath }); await saveMetaToLocalFile({ burnedVideoPath: outputPath }); + useProjectStore.getState().clearStepDirty(4); useProgressStore.getState().success('字幕烧录完成', 2); } catch (error: unknown) { const msg = error instanceof Error ? error.message : String(error); @@ -456,6 +459,11 @@ export default function SubtitleBurning() { return (
+ {isStepDirty && ( +
+ 上游数据已更新,当前字幕压制结果可能不匹配,建议重新压制 +
+ )} state.finalVideoPath); const setFinalVideoPath = useProjectStore(state => state.setFinalVideoPath); const setExportedAt = useProjectStore(state => state.setExportedAt); + const stepDirtyFlags = useProjectStore(state => state.stepDirtyFlags); + const isStepDirty = (stepDirtyFlags?.[6] ?? false) && !!finalVideoPath; const [compositing, setCompositing] = useState(false); @@ -192,7 +194,13 @@ export default function VideoCompose() { }; return ( -
+ <> + {isStepDirty && ( +
+ 上游数据已更新,当前成片可能与最新内容不匹配,建议重新合成 +
+ )} +
{/* Left Panel: Shot List */}
{ fetchBalance(); setShowPointsModal(false); }} />
+ ); } diff --git a/tauri-app/src/pages/VideoCreation/VideoCreation.css b/tauri-app/src/pages/VideoCreation/VideoCreation.css index 03b6fd4..f3e88a1 100644 --- a/tauri-app/src/pages/VideoCreation/VideoCreation.css +++ b/tauri-app/src/pages/VideoCreation/VideoCreation.css @@ -112,6 +112,41 @@ border-color: var(--border-color); } +/* Dirty step indicator */ +.step-dot.dirty .step-number { + border-color: #f5a623; + box-shadow: 0 0 0 3px rgba(245, 166, 35, 0.2); +} + +.step-dot.completed.dirty .step-number { + background: #f5a623; + border-color: #f5a623; +} + +.step-dot.dirty .step-label { + color: #d46b08; + font-weight: 500; +} + +/* Step dirty banner */ +.step-dirty-banner { + display: flex; + align-items: center; + justify-content: flex-start; + gap: var(--spacing-md); + padding: var(--spacing-md) var(--spacing-lg); + margin: 0 var(--spacing-lg) var(--spacing-md); + background: #fff8f0; + border: 1px solid #ffe8d0; + border-radius: var(--radius-lg); + border-left: 3px solid #f5a623; +} + +.step-dirty-banner span { + font-size: var(--font-sm); + color: #d46b08; +} + /* Step Content */ .step-content { flex: 1; diff --git a/tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx b/tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx index 468c95e..b1acf7c 100644 --- a/tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx +++ b/tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx @@ -50,6 +50,9 @@ export default function VoiceSynthesis() { // 使用 store 中持久化的配音 URL(本地 state 刷新后会丢失) const dubbingAudioUrl = useProjectStore(state => state.dubbingAudioUrl); + const stepDirtyFlags = useProjectStore(state => state.stepDirtyFlags); + const clearStepDirty = useProjectStore(state => state.clearStepDirty); + const isStepDirty = (stepDirtyFlags?.[2] ?? false) && !!dubbingAudioUrl; const [isPlayingGenerated, setIsPlayingGenerated] = useState(false); const generatedAudioRef = useRef(null); @@ -381,6 +384,7 @@ export default function VoiceSynthesis() { // 生成完成后自动执行打轴+截取 await handleAlignAndClip(qiniuUrl, meta.filePath); + clearStepDirty(2); progress.success('配音合成完成', result.consumedPoints); } catch (err) { if (handleError(err, '配音合成', estimatedTtsPoints.max)) { @@ -425,6 +429,11 @@ export default function VoiceSynthesis() { return (
+ {isStepDirty && ( +
+ 脚本已重新生成,当前配音结果可能与最新脚本不匹配,建议重新生成 +
+ )}
{/* 左侧:音色 + 语速 + 生成按钮 */}
diff --git a/tauri-app/src/pages/VideoCreation/index.tsx b/tauri-app/src/pages/VideoCreation/index.tsx index c4d815c..ef203c6 100644 --- a/tauri-app/src/pages/VideoCreation/index.tsx +++ b/tauri-app/src/pages/VideoCreation/index.tsx @@ -40,6 +40,7 @@ function VideoCreationContent() { const burnedVideoPath = useProjectStore(state => state.burnedVideoPath); const coverPath = useProjectStore(state => state.coverPath); const finalVideoPath = useProjectStore(state => state.finalVideoPath); + const stepDirtyFlags = useProjectStore(state => state.stepDirtyFlags); // 页面刷新后从 meta.json 恢复项目状态(projectStore persist 不保存业务数据) useEffect(() => { @@ -95,6 +96,9 @@ function VideoCreationContent() { if (meta.finalVideoPath !== undefined) {updates.finalVideoPath = meta.finalVideoPath;} if (meta.exportedAt !== undefined) {updates.exportedAt = meta.exportedAt;} + // 步骤脏标记 + if (meta.stepDirtyFlags !== undefined) {updates.stepDirtyFlags = meta.stepDirtyFlags;} + useProjectStore.setState(updates); } } catch (e) { @@ -261,13 +265,23 @@ function VideoCreationContent() {
{steps.map(step => { const accessible = canAccessStep(step.id); + const stepCompleteMap: Record = { + 1: isStep1Complete, + 2: isStep2Complete, + 3: isStep3Complete, + 4: isStep4Complete, + 5: isStep5Complete, + 6: isStep6Complete, + }; + const hasData = stepCompleteMap[step.id]; + const isDirty = (stepDirtyFlags?.[step.id] ?? false) && hasData; return (