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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 {},
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -126,6 +126,9 @@ export interface ProjectMeta {
|
||||
|
||||
// === 素材 ===
|
||||
userUploadedMaterials?: Record<string, { path: string; duration: number }>;
|
||||
|
||||
// === 步骤脏标记(上游更新后下游需重新生成)===
|
||||
stepDirtyFlags?: Record<number, boolean>;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -165,6 +165,7 @@ export const BLANK_META_OVERRIDES: MetaOverrides = {
|
||||
subTitlePreset: undefined,
|
||||
captionPreset: undefined,
|
||||
userUploadedMaterials: undefined,
|
||||
stepDirtyFlags: undefined,
|
||||
};
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user