refactor(profile): restore recent transactions table

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)
This commit is contained in:
小鱼开发
2026-05-22 15:02:11 +08:00
parent 91774f52ee
commit 1a0679049e
23 changed files with 584 additions and 37 deletions
+3
View File
@@ -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
+1
View File
@@ -20,3 +20,4 @@ class Method:
CAPTION = "caption"
AUTO_ALIGN = "auto_align"
VIDEO_GENERATE = "video_generate"
REMOVE_BACKGROUND = "remove_background"
@@ -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
@@ -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
+3
View File
@@ -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")
+35
View File
@@ -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
@@ -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 {},
)
+17
View File
@@ -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:
+3
View File
@@ -61,6 +61,9 @@ dependencies = [
# 音频时长探测(TTS 扣费用)
"mutagen~=1.47.0",
# 图像处理(智能抠图合成封面)
"Pillow>=11.0.0",
]
[project.optional-dependencies]
@@ -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<ApiResponse<boolean>>('save_project_meta_raw', {
+84 -33
View File
@@ -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 = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><polyline points="10 9 9 9 8 9" />
</svg>
);
const SettingsIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
);
const ChevronRightIcon = ({ className }: { className?: string }) => (
<svg className={className} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
);
const EditIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
@@ -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<PointTransaction[]>([]);
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<UserProfile>('/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: <FileTextIcon />, onClick: () => navigate('usage-detail') },
{ label: '设置', icon: <SettingsIcon />, onClick: () => navigate('settings') },
];
const TYPE_LABELS: Record<string, string> = {
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 (
<div className="settings-page">
@@ -202,16 +205,64 @@ export default function Profile() {
/>
</div>
{/* 设置入口 */}
{/* 最近记录 */}
<div className="profile-section-spaced">
<div className="card profile-menu-list">
{menuItems.map((item) => (
<button key={item.label} className="profile-menu-item" onClick={item.onClick}>
<span className="profile-menu-icon">{item.icon}</span>
<span className="profile-menu-label">{item.label}</span>
<ChevronRightIcon className="profile-menu-arrow" />
</button>
))}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontSize: 'var(--font-md)', fontWeight: 600, margin: 0 }}></h3>
<button className="btn btn-ghost btn-sm" onClick={() => navigate('usage-detail')}>
</button>
</div>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table className="usage-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={4} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
...
</td>
</tr>
) : recentTx.length === 0 ? (
<tr>
<td colSpan={4} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
</td>
</tr>
) : (
recentTx.map((tx) => (
<tr key={tx.id}>
<td>
<span
className="tx-tag"
style={{
background: tx.type === 'recharge' ? '#e8f5e9' : '#f5f5f5',
color: tx.type === 'recharge' ? '#36b26a' : 'var(--text-secondary)',
border: 'none',
}}
>
{TYPE_LABELS[tx.type] || tx.type}
</span>
</td>
<td style={{ fontWeight: 600 }}>
{tx.type === 'recharge' ? '+' : '-'}{tx.amount}
</td>
<td className="description-cell" title={tx.description || '-'}>
{tx.description || '-'}
</td>
<td>{formatTxTime(tx.createdAt)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
@@ -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 (
<div className="step-layout cover-design-variant cover-design">
{isStepDirty && (
<div className="step-dirty-banner" style={{ gridColumn: '1 / -1', margin: '0 0 var(--spacing-md) 0' }}>
<span></span>
</div>
)}
<ConfirmModal
open={showPointsModal}
type="warning"
@@ -20,6 +20,7 @@ export default function ScriptCreation() {
// 使用 selector 模式订阅 store,确保组件正确响应状态变化
const segments = useProjectStore(state => 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);
// 设置标题
@@ -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 (
<div className="step-layout subtitle-burning">
{isStepDirty && (
<div className="step-dirty-banner" style={{ gridColumn: '1 / -1', margin: '0 0 var(--spacing-md) 0' }}>
<span></span>
</div>
)}
<ConfirmModal
open={showPointsModal}
type="warning"
@@ -27,6 +27,8 @@ export default function VideoCompose() {
const finalVideoPath = useProjectStore(state => 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 (
<div className="step-layout video-gen-layout">
<>
{isStepDirty && (
<div className="step-dirty-banner">
<span></span>
</div>
)}
<div className="step-layout video-gen-layout">
{/* Left Panel: Shot List */}
<div
className="step-panel-left"
@@ -446,5 +454,6 @@ export default function VideoCompose() {
onRechargeSuccess={() => { fetchBalance(); setShowPointsModal(false); }}
/>
</div>
</>
);
}
@@ -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;
@@ -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<HTMLAudioElement | null>(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 (
<div className="voice-dubbing">
{isStepDirty && (
<div className="step-dirty-banner">
<span></span>
</div>
)}
<div className="dubbing-layout">
{/* 左侧:音色 + 语速 + 生成按钮 */}
<div className="voice-sidebar">
+16 -2
View File
@@ -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() {
<div className="step-dots">
{steps.map(step => {
const accessible = canAccessStep(step.id);
const stepCompleteMap: Record<number, boolean> = {
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 (
<button
key={step.id}
className={`step-dot ${step.id === currentStep ? 'active' : ''} ${step.id < currentStep ? 'completed' : ''} ${!accessible ? 'disabled' : ''}`}
className={`step-dot ${step.id === currentStep ? 'active' : ''} ${step.id < currentStep ? 'completed' : ''} ${!accessible ? 'disabled' : ''} ${isDirty ? 'dirty' : ''}`}
// 步骤条仅作进度展示,禁止直接点击跳转
// 用户通过「上一步 / 下一步」按钮顺序导航,避免跨步骤回退导致数据不一致
title={!accessible ? '请先完成前置步骤' : step.id === currentStep ? '当前步骤' : '通过下方按钮切换步骤'}
title={!accessible ? '请先完成前置步骤' : isDirty ? '上游已更新,需重新生成' : step.id === currentStep ? '当前步骤' : '通过下方按钮切换步骤'}
>
<span className="step-number">
{step.id < currentStep && accessible ? (
@@ -437,6 +437,7 @@ export function useVideoGeneration({
currentStep: 3,
});
useProjectStore.getState().clearStepDirty(3);
onSuccess();
progress.success('视频生成完成', actualVideoPoints);
+10 -1
View File
@@ -22,6 +22,8 @@ import type { ScriptShot } from '../../types/project';
export default function VideoGeneration() {
const segments = useProjectStore((state) => state.segments);
const composedVideoPath = useProjectStore((state) => state.composedVideoPath);
const stepDirtyFlags = useProjectStore((state) => state.stepDirtyFlags);
const isStepDirty = (stepDirtyFlags?.[3] ?? false) && !!composedVideoPath;
const { videoUrl: composedVideoBlobUrl } = useLocalVideo(composedVideoPath);
const projectId = getCurrentProjectId();
@@ -348,7 +350,13 @@ export default function VideoGeneration() {
const activeShot = shots.find((s) => Number(s.id) === activeScene);
return (
<div className="step-layout video-gen-layout">
<>
{isStepDirty && (
<div className="step-dirty-banner">
<span></span>
</div>
)}
<div className="step-layout video-gen-layout">
{/* Left Panel: Digital Human + Storyboard */}
<div
className="step-panel-left"
@@ -517,5 +525,6 @@ export default function VideoGeneration() {
</div>
</div>
</div>
</>
);
}
+31
View File
@@ -35,6 +35,8 @@ interface ProjectActions {
setSubcategoryCode: (_code: string) => void;
setIsLoading: (_loading: boolean) => void;
setHasHydrated: (_hydrated: boolean) => void;
markStepsDirty: (_fromStep: number) => void;
clearStepDirty: (_stepId: number) => void;
}
@@ -248,6 +250,35 @@ export const useProjectStore = create<ProjectStore>()(
saveMetaToLocalFile({ subcategoryCode: code });
},
markStepsDirty: (fromStep: number) => {
set(state => {
if (!state.stepDirtyFlags) {
state.stepDirtyFlags = {};
}
for (let i = fromStep + 1; i <= 6; i++) {
state.stepDirtyFlags[i] = true;
}
state.updatedAt = Date.now();
});
const flags = get().stepDirtyFlags || {};
const newFlags: Record<number, boolean> = {};
for (let i = fromStep + 1; i <= 6; i++) {
newFlags[i] = true;
}
saveMetaToLocalFile({ stepDirtyFlags: { ...flags, ...newFlags } });
},
clearStepDirty: (stepId: number) => {
set(state => {
if (state.stepDirtyFlags) {
state.stepDirtyFlags[stepId] = false;
}
state.updatedAt = Date.now();
});
const flags = get().stepDirtyFlags || {};
saveMetaToLocalFile({ stepDirtyFlags: { ...flags, [stepId]: false } });
},
}),
{
name: 'ai-video-project-config',
+3
View File
@@ -126,6 +126,9 @@ export interface ProjectMeta {
// === 素材 ===
userUploadedMaterials?: Record<string, { path: string; duration: number }>;
// === 步骤脏标记(上游更新后下游需重新生成)===
stepDirtyFlags?: Record<number, boolean>;
}
// ------------------------------------------------------------------
+1
View File
@@ -165,6 +165,7 @@ export const BLANK_META_OVERRIDES: MetaOverrides = {
subTitlePreset: undefined,
captionPreset: undefined,
userUploadedMaterials: undefined,
stepDirtyFlags: undefined,
};