feat(cover-avatar): 封面形象功能

后端:
- 新增 POST /upload/image 图片上传(七牛云 image bucket)
- 新增 POST /image/remove-background AI 抠图(火山引擎 MediaKit)
- 提取 file_validation.py 共享模块

Rust:
- 新增 cover_avatar.rs 存储层(cover_avatars.json + 图片本地存储)
- 新增 4 个 IPC 命令:load/save/delete/save_image

前端:
- 新增 CoverAvatarLibrary 页面(内容管理 → 封面形象)
- 新增 coverAvatar API 模块和 coverAvatarStore
- 封面设计集成:背景图/封面形象弹窗选择 + Fabric.js 叠加
- 优化左侧布局:视觉素材横向卡片(9:16)+ 文案配置分组
This commit is contained in:
小鱼开发
2026-05-22 18:38:18 +08:00
parent c55c256dc7
commit f01f2c366a
29 changed files with 1925 additions and 248 deletions
+193
View File
@@ -0,0 +1,193 @@
"""
图片处理 API
============
提供图片上传(七牛云)和 AI 抠图(火山引擎 MediaKit)功能。
"""
import io
import logging
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from pydantic import BaseModel, Field
from app.api.deps import get_current_user
from app.config import get_settings
from app.models.user import User
from app.schemas.common import ApiResponse, success_response
from app.services.qiniu_service import get_qiniu_service
from app.services.volcengine_mediakit_service import VolcengineMediakitService
from app.utils.file_validation import check_upload_file
router = APIRouter(tags=["Image"])
logger = logging.getLogger(__name__)
settings = get_settings()
# ── Dependencies ──
async def get_mediakit_service(request: Request) -> VolcengineMediakitService:
"""FastAPI Depends:从 app.state 获取全局 VolcengineMediakitService 实例。"""
service = getattr(request.app.state, "volcengine_mediakit_service", None)
if service is None:
raise HTTPException(
status_code=503,
detail="MediaKit 服务未初始化,请检查配置",
)
return service
# ── Schemas ──
class ImageUploadResponse(BaseModel):
"""图片上传响应"""
url: str = Field(..., description="七牛云图片 URL")
key: str = Field(..., description="七牛云文件 key")
size: int = Field(..., description="文件大小(字节)")
class RemoveBackgroundResponse(BaseModel):
"""抠图响应"""
url: str = Field(..., description="抠图结果图片 URL")
class RemoveBackgroundRequest(BaseModel):
"""抠图请求"""
image_url: str = Field(..., description="原始图片 URL")
scene: str = Field(default="general", description="场景类型:general(通用)或 product(商品)")
# ── Endpoints ──
@router.post("/upload/image", response_model=ApiResponse[ImageUploadResponse])
async def upload_image(
file: UploadFile = File(..., description="图片文件"),
current_user: User = Depends(get_current_user),
) -> ApiResponse[ImageUploadResponse]:
"""
上传图片到七牛云
支持格式:jpg, jpeg, png, gif, webp
返回七牛云永久访问 URL。
"""
try:
allowed_types = {
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
}
content_type = file.content_type or ""
# 如果 content_type 为空,尝试从文件名推断
if not content_type:
ext = Path(file.filename or "").suffix.lower()
ext_to_mime = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
}
content_type = ext_to_mime.get(ext, "")
if content_type not in allowed_types:
raise HTTPException(
status_code=400,
detail=f"不支持的图片格式: {content_type},请上传 jpg/png/gif/webp 图片",
)
# 读取文件内容
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="文件内容为空")
# 校验大小和魔数
check_upload_file(
content,
settings.UPLOAD_MAX_IMAGE_SIZE,
content_type,
"图片",
)
# 生成唯一文件名
ext = Path(file.filename or "image.jpg").suffix or ".jpg"
unique_name = f"{uuid.uuid4().hex[:16]}{ext}"
# 上传到七牛云
qiniu = get_qiniu_service()
bucket, domain = qiniu._get_bucket_and_domain("image")
file_key = qiniu.generate_key("image", unique_name)
stream = io.BytesIO(content)
result = await qiniu.upload_stream_async(
stream=stream,
key=file_key,
mime_type=content_type or "image/jpeg",
bucket=bucket,
domain=domain,
)
url = result.get("url")
returned_key = result.get("key")
if not url:
raise HTTPException(status_code=500, detail="上传到七牛云失败:未返回 URL")
logger.info(f"[Upload] 图片上传成功: {url[:80]}..., size={len(content)}")
return success_response(
data=ImageUploadResponse(
url=url,
key=returned_key or file_key,
size=len(content),
)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[Upload] 图片上传失败: {e}")
raise HTTPException(status_code=500, detail=f"上传失败: {e}")
@router.post("/image/remove-background", response_model=ApiResponse[RemoveBackgroundResponse])
async def remove_background(
req: RemoveBackgroundRequest,
current_user: User = Depends(get_current_user),
mediakit_service: VolcengineMediakitService = Depends(get_mediakit_service),
) -> ApiResponse[RemoveBackgroundResponse]:
"""
AI 抠图(火山引擎 MediaKit
移除图片背景,返回透明背景图片 URL。
"""
try:
result = await mediakit_service.remove_background(
image_url=req.image_url,
scene=req.scene,
)
if not result.image_url:
raise HTTPException(status_code=500, detail="抠图失败:未返回结果图片 URL")
logger.info(f"[RemoveBackground] 抠图成功: {result.image_url[:80]}...")
return success_response(
data=RemoveBackgroundResponse(url=result.image_url),
message="抠图成功",
)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"[RemoveBackground] 抠图失败: {e}")
raise HTTPException(status_code=500, detail=f"抠图失败: {e}")
+4
View File
@@ -10,6 +10,7 @@ from app.api.v1 import (
caption,
cover_background,
events,
image,
materials,
points,
script,
@@ -59,5 +60,8 @@ api_router.include_router(cover_background.router, tags=["Cover Background"])
# 积分系统模块
api_router.include_router(points.router, tags=["Points"])
# 图片处理模块(上传 + 抠图)
api_router.include_router(image.router, tags=["Image"])
# 应用更新模块
api_router.include_router(update.router, prefix="/update", tags=["Update"])
+3 -97
View File
@@ -18,6 +18,7 @@ from app.config import get_settings
from app.models.user import User
from app.schemas.common import ApiResponse, success_response
from app.services.qiniu_service import get_qiniu_service
from app.utils.file_validation import check_upload_file
router = APIRouter(prefix="/upload", tags=["Upload"])
@@ -25,101 +26,6 @@ logger = logging.getLogger(__name__)
settings = get_settings()
def _validate_file_magic(content: bytes, expected_content_type: str) -> bool:
"""通过文件头魔数校验文件真实类型,防止 MIME 伪造攻击。"""
if len(content) < 12:
return False
# 拒绝常见危险文件头
dangerous_signatures = [
(b"MZ", "Windows 可执行文件"), # .exe, .dll
(b"#!", "Shell 脚本"), # bash, python, etc
(b"PK\x03\x04", "ZIP 压缩包"), # .zip, .jar, .docx
(b"<?xml", "XML 文件"),
(b"<html", "HTML 文件"),
(b"<!DO", "HTML 文档"),
(b"%PDF", "PDF 文件"),
]
for sig, _ in dangerous_signatures:
if content.startswith(sig):
return False
if b"<script" in content[:512].lower():
return False
main_type = expected_content_type.split("/")[0]
# 图片校验
if main_type == "image":
if content.startswith(b"\xff\xd8\xff"):
return expected_content_type in ("image/jpeg", "image/jpg")
if content.startswith(b"\x89PNG\r\n\x1a\n"):
return expected_content_type == "image/png"
if content.startswith(b"GIF89a") or content.startswith(b"GIF87a"):
return expected_content_type == "image/gif"
if content.startswith(b"RIFF") and content[8:12] == b"WEBP":
return expected_content_type == "image/webp"
return False
# 视频校验
if main_type == "video":
# MP4 / MOV / M4V 等 ISO Base Media File Format
if content[4:8] == b"ftyp":
brand = content[8:12]
if brand in (b"qt ", b"qtw "):
return expected_content_type in ("video/quicktime",)
# mp4, isom, avc1, mp41, mp42 等
return expected_content_type in (
"video/mp4",
"video/quicktime",
)
if content.startswith(b"RIFF") and content[8:12] == b"AVI ":
return expected_content_type == "video/x-msvideo"
if content.startswith(b"\x1aE\xdf\xa3"):
return expected_content_type == "video/webm"
return False
# 音频校验
if main_type == "audio":
if content[:3] == b"ID3" or content[:2] in (
b"\xff\xfb",
b"\xff\xf3",
b"\xff\xf2",
):
return expected_content_type in ("audio/mpeg", "audio/mp3")
if content.startswith(b"RIFF") and content[8:12] == b"WAVE":
return expected_content_type in ("audio/wav", "audio/x-wav")
if content.startswith(b"fLaC"):
return expected_content_type == "audio/flac"
if content.startswith(b"OggS"):
return expected_content_type == "audio/ogg"
# AAC / M4A(也是 ftyp 格式)
if content[4:8] == b"ftyp":
brand = content[8:12]
if brand in (b"M4A ", b"m4a ", b"mp42", b"isom", b"M4P "):
return expected_content_type in (
"audio/mp4",
"audio/aac",
"audio/m4a",
)
return False
return False
def _check_upload_file(content: bytes, max_size: int, content_type: str, type_label: str) -> None:
"""统一校验文件大小和魔数,失败时直接抛 HTTPException。"""
if len(content) > max_size:
max_mb = max_size // 1024 // 1024
raise HTTPException(
status_code=413,
detail=f"{type_label}文件大小不能超过 {max_mb}MB",
)
if not _validate_file_magic(content, content_type):
raise HTTPException(
status_code=400,
detail=f"{type_label}文件内容与实际格式不符,可能存在安全风险",
)
class UploadResponse(BaseModel):
"""上传响应"""
@@ -173,7 +79,7 @@ async def upload_video(
raise HTTPException(status_code=400, detail="文件内容为空")
# 校验大小和魔数
_check_upload_file(
check_upload_file(
content,
settings.UPLOAD_MAX_VIDEO_SIZE,
content_type,
@@ -269,7 +175,7 @@ async def upload_audio(
raise HTTPException(status_code=400, detail="文件内容为空")
# 校验大小和魔数
_check_upload_file(
check_upload_file(
content,
settings.UPLOAD_MAX_AUDIO_SIZE,
content_type,
+105
View File
@@ -0,0 +1,105 @@
"""
文件校验工具
==========
提供文件头魔数校验和上传文件统一校验功能,
防止 MIME 伪造攻击和危险文件上传。
"""
from fastapi import HTTPException
def validate_file_magic(content: bytes, expected_content_type: str) -> bool:
"""通过文件头魔数校验文件真实类型,防止 MIME 伪造攻击。"""
if len(content) < 12:
return False
# 拒绝常见危险文件头
dangerous_signatures = [
(b"MZ", "Windows 可执行文件"), # .exe, .dll
(b"#!", "Shell 脚本"), # bash, python, etc
(b"PK\x03\x04", "ZIP 压缩包"), # .zip, .jar, .docx
(b"<?xml", "XML 文件"),
(b"<html", "HTML 文件"),
(b"<!DO", "HTML 文档"),
(b"%PDF", "PDF 文件"),
]
for sig, _ in dangerous_signatures:
if content.startswith(sig):
return False
if b"<script" in content[:512].lower():
return False
main_type = expected_content_type.split("/")[0]
# 图片校验
if main_type == "image":
if content.startswith(b"\xff\xd8\xff"):
return expected_content_type in ("image/jpeg", "image/jpg")
if content.startswith(b"\x89PNG\r\n\x1a\n"):
return expected_content_type == "image/png"
if content.startswith(b"GIF89a") or content.startswith(b"GIF87a"):
return expected_content_type == "image/gif"
if content.startswith(b"RIFF") and content[8:12] == b"WEBP":
return expected_content_type == "image/webp"
return False
# 视频校验
if main_type == "video":
# MP4 / MOV / M4V 等 ISO Base Media File Format
if content[4:8] == b"ftyp":
brand = content[8:12]
if brand in (b"qt ", b"qtw "):
return expected_content_type in ("video/quicktime",)
# mp4, isom, avc1, mp41, mp42 等
return expected_content_type in (
"video/mp4",
"video/quicktime",
)
if content.startswith(b"RIFF") and content[8:12] == b"AVI ":
return expected_content_type == "video/x-msvideo"
if content.startswith(b"\x1aE\xdf\xa3"):
return expected_content_type == "video/webm"
return False
# 音频校验
if main_type == "audio":
if content[:3] == b"ID3" or content[:2] in (
b"\xff\xfb",
b"\xff\xf3",
b"\xff\xf2",
):
return expected_content_type in ("audio/mpeg", "audio/mp3")
if content.startswith(b"RIFF") and content[8:12] == b"WAVE":
return expected_content_type in ("audio/wav", "audio/x-wav")
if content.startswith(b"fLaC"):
return expected_content_type == "audio/flac"
if content.startswith(b"OggS"):
return expected_content_type == "audio/ogg"
# AAC / M4A(也是 ftyp 格式)
if content[4:8] == b"ftyp":
brand = content[8:12]
if brand in (b"M4A ", b"m4a ", b"mp42", b"isom", b"M4P "):
return expected_content_type in (
"audio/mp4",
"audio/aac",
"audio/m4a",
)
return False
return False
def check_upload_file(content: bytes, max_size: int, content_type: str, type_label: str) -> None:
"""统一校验文件大小和魔数,失败时直接抛 HTTPException。"""
if len(content) > max_size:
max_mb = max_size // 1024 // 1024
raise HTTPException(
status_code=413,
detail=f"{type_label}文件大小不能超过 {max_mb}MB",
)
if not validate_file_magic(content, content_type):
raise HTTPException(
status_code=400,
detail=f"{type_label}文件内容与实际格式不符,可能存在安全风险",
)
@@ -0,0 +1,123 @@
//! 封面形象管理 IPC 命令
use crate::ApiResponse;
use crate::storage::cover_avatar as cover_avatar_storage;
// --------------------- 封面形象库命令 ---------------------
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CoverAvatarArgs {
pub id: String,
pub name: String,
pub image_url: String,
pub local_path: Option<String>,
pub created_at: String,
}
/// 加载封面形象库
#[tauri::command]
pub async fn load_cover_avatars() -> ApiResponse<Vec<cover_avatar_storage::CoverAvatar>> {
match cover_avatar_storage::load_cover_avatars() {
Ok(list) => ApiResponse {
code: 200,
message: "封面形象库加载成功".to_string(),
data: Some(list.avatars),
},
Err(e) => ApiResponse {
code: 500,
message: format!("加载封面形象库失败: {}", e),
data: Some(vec![]),
},
}
}
/// 保存封面形象
#[tauri::command]
pub async fn save_cover_avatar(
args: CoverAvatarArgs,
) -> ApiResponse<bool> {
let avatar = cover_avatar_storage::CoverAvatar {
id: args.id,
name: args.name,
image_url: args.image_url,
local_path: args.local_path,
created_at: args.created_at,
};
match cover_avatar_storage::add_cover_avatar(avatar) {
Ok(_) => ApiResponse {
code: 200,
message: "封面形象保存成功".to_string(),
data: Some(true),
},
Err(e) => ApiResponse {
code: 500,
message: format!("保存封面形象失败: {}", e),
data: Some(false),
},
}
}
/// 删除封面形象
#[tauri::command]
pub async fn delete_cover_avatar_cmd(
id: String,
) -> ApiResponse<bool> {
match cover_avatar_storage::delete_cover_avatar(&id) {
Ok(_) => ApiResponse {
code: 200,
message: "封面形象删除成功".to_string(),
data: Some(true),
},
Err(e) => ApiResponse {
code: 500,
message: format!("删除封面形象失败: {}", e),
data: Some(false),
},
}
}
// --------------------- 封面形象图片文件命令 ---------------------
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveCoverAvatarImageArgs {
pub avatar_id: String,
pub image_data: String, // base64 编码
pub ext: String, // 文件扩展名,如 "png"
}
/// 保存封面形象图片文件(前端传入 base64 编码)
#[tauri::command]
pub async fn save_cover_avatar_image(
args: SaveCoverAvatarImageArgs,
) -> ApiResponse<String> {
let image_bytes = match base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
&args.image_data,
) {
Ok(data) => data,
Err(e) => return ApiResponse {
code: 400,
message: format!("Invalid base64 data: {}", e),
data: None,
},
};
match cover_avatar_storage::save_cover_avatar_image(
&args.avatar_id,
&image_bytes,
&args.ext,
) {
Ok(path) => ApiResponse {
code: 200,
message: "封面形象图片保存成功".to_string(),
data: Some(path),
},
Err(e) => ApiResponse {
code: 500,
message: format!("保存封面形象图片失败: {}", e),
data: None,
},
}
}
+1
View File
@@ -11,3 +11,4 @@ pub mod project;
pub mod voice;
pub mod video_compose;
pub mod file;
pub mod cover_avatar;
+62
View File
@@ -111,6 +111,60 @@ fn clean_video_cache(app_data_dir: &std::path::Path) {
}
}
/// 手动清理 video_cache 目录,返回释放的字节数
#[tauri::command]
fn clear_video_cache_cmd() -> Result<u64, String> {
let app_data_dir = crate::storage::paths::get_app_data_dir()
.map_err(|e| e.to_string())?;
let cache_dir = app_data_dir.join("video_cache");
if !cache_dir.exists() {
return Ok(0);
}
let mut freed: u64 = 0;
let read_dir = match std::fs::read_dir(&cache_dir) {
Ok(d) => d,
Err(e) => return Err(format!("无法读取缓存目录: {}", e)),
};
for entry in read_dir.flatten() {
let Ok(metadata) = entry.metadata() else { continue };
if !metadata.is_file() { continue; }
let size = metadata.len();
if std::fs::remove_file(entry.path()).is_ok() {
freed += size;
}
}
Ok(freed)
}
/// 获取 video_cache 目录当前占用大小(字节)
#[tauri::command]
fn get_video_cache_size_cmd() -> Result<u64, String> {
let app_data_dir = crate::storage::paths::get_app_data_dir()
.map_err(|e| e.to_string())?;
let cache_dir = app_data_dir.join("video_cache");
if !cache_dir.exists() {
return Ok(0);
}
let mut total: u64 = 0;
let read_dir = match std::fs::read_dir(&cache_dir) {
Ok(d) => d,
Err(e) => return Err(format!("无法读取缓存目录: {}", e)),
};
for entry in read_dir.flatten() {
let Ok(metadata) = entry.metadata() else { continue };
if metadata.is_file() {
total += metadata.len();
}
}
Ok(total)
}
// ============================================================
// 应用入口
// ============================================================
@@ -257,6 +311,11 @@ pub fn run() {
commands::voice::load_voice_materials,
commands::voice::save_voice_material,
commands::voice::delete_voice_material_cmd,
// 封面形象库
commands::cover_avatar::load_cover_avatars,
commands::cover_avatar::save_cover_avatar,
commands::cover_avatar::delete_cover_avatar_cmd,
commands::cover_avatar::save_cover_avatar_image,
// 压制成片(Phase 2
commands::video_compose::extract_video_segment,
commands::video_compose::concat_video_clips,
@@ -267,6 +326,9 @@ pub fn run() {
get_video_metadata_cmd,
// 视频预览转码(统一浏览器兼容格式)
transcode_for_preview_cmd,
// 缓存清理
get_video_cache_size_cmd,
clear_video_cache_cmd,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@@ -0,0 +1,101 @@
//! 封面形象存储模块
//!
//! 管理用户上传的人物照片(抠图后)的本地存储。
use serde::{Deserialize, Serialize};
use crate::storage::engine::{atomic_write_bytes, atomic_write_json, ensure_dir, read_json, StorageError};
use crate::storage::paths::get_cover_avatars_dir;
/// 封面形象记录
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CoverAvatar {
pub id: String,
pub name: String,
/// 抠图后的透明背景图片 URL(七牛云)
pub image_url: String,
/// 本地文件路径(相对于项目目录或绝对路径)
pub local_path: Option<String>,
pub created_at: String,
}
/// 封面形象列表
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct CoverAvatarsList {
pub avatars: Vec<CoverAvatar>,
pub updated_at: String,
}
/// 加载封面形象库
pub fn load_cover_avatars() -> Result<CoverAvatarsList, StorageError> {
let path = crate::storage::paths::get_cover_avatars_json_path()?;
Ok(read_json(&path)?.unwrap_or_default())
}
/// 保存封面形象库
pub fn save_cover_avatars(list: &CoverAvatarsList) -> Result<(), StorageError> {
let path = crate::storage::paths::get_cover_avatars_json_path()?;
atomic_write_json(&path, list)
}
/// 添加封面形象
pub fn add_cover_avatar(avatar: CoverAvatar) -> Result<(), StorageError> {
let mut list = load_cover_avatars()?;
// 去重:相同 id 替换
if let Some(pos) = list.avatars.iter().position(|a| a.id == avatar.id) {
list.avatars[pos] = avatar;
} else {
list.avatars.push(avatar);
}
list.updated_at = chrono_lite_now();
save_cover_avatars(&list)
}
/// 删除封面形象
pub fn delete_cover_avatar(id: &str) -> Result<(), StorageError> {
let mut list = load_cover_avatars()?;
let pos = list.avatars.iter().position(|a| a.id == id)
.ok_or_else(|| StorageError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("封面形象 {} 不存在", id),
)))?;
list.avatars.remove(pos);
list.updated_at = chrono_lite_now();
save_cover_avatars(&list)
}
/// 保存封面形象图片文件到本地
///
/// 将 base64 编码的图片数据写入 `cover_avatars/` 子目录。
pub fn save_cover_avatar_image(
avatar_id: &str,
data: &[u8],
ext: &str,
) -> Result<String, StorageError> {
let avatars_dir = get_cover_avatars_dir()?;
ensure_dir(&avatars_dir)?;
// 净化扩展名:只允许字母数字,防止路径遍历
let safe_ext = ext
.chars()
.filter(|c| c.is_alphanumeric())
.collect::<String>();
if safe_ext.is_empty() {
return Err(StorageError::InvalidId("无效的文件扩展名".into()));
}
let file_name = format!("{}.{}", avatar_id, safe_ext);
let file_path = avatars_dir.join(&file_name);
atomic_write_bytes(&file_path, data)?;
Ok(file_path.to_str().unwrap_or_default().to_string())
}
// ====================== 工具函数 ======================
fn chrono_lite_now() -> String {
chrono::Utc::now().to_rfc3339()
}
+2 -1
View File
@@ -12,6 +12,7 @@ pub mod project;
pub mod auth;
pub mod voice;
pub mod config;
pub mod cover_avatar;
pub use engine::{
atomic_write_json, atomic_write_bytes,
@@ -22,5 +23,5 @@ pub use paths::{
init_app_data_dir, get_app_data_dir, get_projects_root_dir,
get_project_dir, get_project_dir_path, get_project_assets_dir,
get_project_videos_dir, get_project_products_dir, get_voices_json_path,
get_app_config_json_path,
get_app_config_json_path, get_cover_avatars_dir, get_cover_avatars_json_path,
};
+16
View File
@@ -104,3 +104,19 @@ pub fn get_auth_state_path(app: &AppHandle) -> Result<PathBuf, StorageError> {
crate::storage::engine::ensure_dir(&path)?;
Ok(path.join("auth.json"))
}
/// 获取封面形象图片存储目录
/// {app_local_data_dir}/cover_avatars/
pub fn get_cover_avatars_dir() -> Result<PathBuf, StorageError> {
let base = get_app_data_dir()?;
let path = base.join("cover_avatars");
crate::storage::engine::ensure_dir(&path)?;
Ok(path)
}
/// 获取封面形象库 JSON 路径
/// {app_local_data_dir}/cover_avatars.json
pub fn get_cover_avatars_json_path() -> Result<PathBuf, StorageError> {
let base = get_app_data_dir()?;
Ok(base.join("cover_avatars.json"))
}
+3
View File
@@ -9,6 +9,7 @@ import Login from './pages/Login/Login';
import VideoCreation from './pages/VideoCreation';
import MyWorks from './pages/ContentManagement/MyWorks';
import VoiceMaterialLibrary from './pages/ContentManagement/VoiceMaterialLibrary';
import CoverAvatarLibrary from './pages/ContentManagement/CoverAvatarLibrary';
import Settings from './pages/Settings/Settings';
import Profile from './pages/Profile/Profile';
import UsageDetail from './pages/Profile/UsageDetail';
@@ -29,6 +30,7 @@ import './App.css';
type PageType =
| 'video-creation'
| 'voice-material'
| 'cover-avatar'
| 'my-works'
| 'settings'
| 'profile'
@@ -38,6 +40,7 @@ type PageType =
const pages: Record<PageType, React.ComponentType> = {
'video-creation': VideoCreation,
'voice-material': VoiceMaterialLibrary,
'cover-avatar': CoverAvatarLibrary,
'my-works': MyWorks,
settings: Settings,
profile: Profile,
+104
View File
@@ -0,0 +1,104 @@
/**
* Cover Avatar 封面形象 API 模块
* ================================
*
* 提供图片上传、AI 抠图、本地封面形象库管理接口。
*/
import { invoke } from '@tauri-apps/api/core';
import { client } from '../client';
// ====================== 类型 ======================
export interface CoverAvatar {
id: string;
name: string;
imageUrl: string;
localPath?: string;
createdAt: string;
}
// ====================== HTTP API(后端直连) ======================
/**
* 上传图片到七牛云
*/
export async function uploadImage(file: File): Promise<string> {
const formData = new FormData();
formData.append('file', file);
const result = await client.postForm<{ url: string; key: string }>('/upload/image', formData);
return result.url;
}
/**
* AI 抠图(火山引擎 MediaKit
*/
export async function removeBackground(imageUrl: string, scene = 'general'): Promise<string> {
const result = await client.post<{ url: string }>('/image/remove-background', {
imageUrl,
scene,
});
return result.url;
}
// ====================== IPC 命令(本地存储) ======================
/**
* 从本地加载封面形象库
*/
export async function loadCoverAvatars(): Promise<CoverAvatar[]> {
const result = await invoke<{ code: number; data?: CoverAvatar[]; message: string }>('load_cover_avatars');
if (result.code !== 200) {
throw new Error(result.message || '加载封面形象库失败');
}
return result.data || [];
}
/**
* 保存封面形象到本地
*/
export async function saveCoverAvatar(avatar: CoverAvatar): Promise<void> {
const result = await invoke<{ code: number; message: string }>('save_cover_avatar', {
args: {
id: avatar.id,
name: avatar.name,
imageUrl: avatar.imageUrl,
localPath: avatar.localPath,
createdAt: avatar.createdAt,
},
});
if (result.code !== 200) {
throw new Error(result.message || '保存封面形象失败');
}
}
/**
* 删除本地封面形象
*/
export async function deleteCoverAvatar(id: string): Promise<void> {
const result = await invoke<{ code: number; message: string }>('delete_cover_avatar_cmd', { id });
if (result.code !== 200) {
throw new Error(result.message || '删除封面形象失败');
}
}
/**
* 保存封面形象图片文件到本地(base64 编码)
*/
export async function saveCoverAvatarImage(args: {
avatarId: string;
imageData: string;
ext: string;
}): Promise<string> {
const result = await invoke<{ code: number; data?: string; message: string }>('save_cover_avatar_image', {
args: {
avatarId: args.avatarId,
imageData: args.imageData,
ext: args.ext,
},
});
if (result.code !== 200 || !result.data) {
throw new Error(result.message || '保存封面形象图片失败');
}
return result.data;
}
@@ -111,6 +111,7 @@ export const localProjectApi = {
lipSyncedVideoUrl: meta.lipSyncedVideoUrl,
dubbingAudioUrl: meta.dubbingAudioUrl,
dubbingAudioPath: meta.dubbingAudioPath,
dubbingAudioDuration: meta.dubbingAudioDuration,
voiceSpeed: meta.voiceSpeed,
voiceVolume: meta.voiceVolume,
voicePitch: meta.voicePitch,
@@ -23,6 +23,7 @@ const navItems: NavItem[] = [
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
children: [
{ id: 'voice-material', label: '声音复刻' },
{ id: 'cover-avatar', label: '封面形象' },
{ id: 'my-works', label: '我的作品' },
],
},
+55 -2
View File
@@ -28,6 +28,7 @@ interface CoverDesignConfig {
backgroundImage: string | null;
mainTitle: string;
subtitle: string;
avatarImage?: string | null;
}
interface TemplateDef {
@@ -218,6 +219,49 @@ export function useCoverFabric() {
[]
);
// 加载封面形象(叠加在背景之上,文字之下)
const loadAvatarImage = useCallback(
async (canvas: Canvas, imageUrl: string): Promise<void> => {
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = (e) => reject(e);
image.src = imageUrl;
});
const fabricImg = new FabricImage(img);
// 计算缩放:宽度不超过画布的 70%,高度不超过画布的 40%
const maxWidth = CANVAS_WIDTH * 0.7;
const maxHeight = CANVAS_HEIGHT * 0.4;
const scale = Math.min(
maxWidth / (fabricImg.width || 1),
maxHeight / (fabricImg.height || 1),
1 // 不放大原图
);
fabricImg.scale(scale);
const scaledWidth = (fabricImg.width || 1) * scale;
// 居中偏下定位(约 55% 处)
fabricImg.set({
left: (CANVAS_WIDTH - scaledWidth) / 2,
top: CANVAS_HEIGHT * 0.55,
originX: 'left',
originY: 'top',
selectable: false,
evented: false,
});
canvas.add(fabricImg);
canvas.renderAll();
} catch {
// no-op
}
},
[]
);
// 渲染封面
const renderCover = useCallback(
async (config: CoverDesignConfig) => {
@@ -240,9 +284,18 @@ export function useCoverFabric() {
}
}
// 2. 封面形象(叠加在背景之上)
if (config.avatarImage) {
try {
await loadAvatarImage(canvas, config.avatarImage);
} catch {
// no-op: 封面形象加载失败不影响主流程
}
}
const template = TEMPLATES[config.template];
// 2. 主标题(自动换行,最多2行,居中)
// 3. 主标题(自动换行,最多2行,居中)
if (config.mainTitle.trim()) {
const maxWidth = CANVAS_WIDTH - 120; // 左右留边
const lines = wrapTextByWidth(
@@ -272,7 +325,7 @@ export function useCoverFabric() {
});
}
// 3. 副标题(底部居中,加粗)
// 4. 副标题(底部居中,加粗)
if (config.subtitle.trim()) {
const maxWidth = CANVAS_WIDTH - 120;
const lines = wrapTextByWidth(
@@ -2057,3 +2057,171 @@
max-width: 280px;
line-height: 1.6;
}
/* ============================================================
关于我们 (About) - 品牌展示 + 版本更新
============================================================ */
.about-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-md);
}
.about-brand {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-xs);
}
.about-logo {
width: 64px;
height: 64px;
border-radius: var(--radius-lg);
object-fit: contain;
}
.about-name {
font-size: var(--font-lg);
font-weight: 600;
color: var(--text-primary);
}
.about-version-row {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 320px;
margin: 0 auto;
padding: var(--spacing-xs) 0;
gap: var(--spacing-sm);
}
.about-version-text {
font-size: var(--font-sm);
color: var(--text-secondary);
cursor: default;
user-select: none;
flex-shrink: 0;
font-weight: 500;
}
.about-version-actions {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.about-checking-status {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: var(--font-sm);
color: var(--text-tertiary);
}
.about-check-result {
font-size: var(--font-sm);
font-weight: 500;
white-space: nowrap;
}
.about-check-latest {
color: var(--success);
}
.about-check-available {
color: var(--primary);
}
.about-check-btn {
flex-shrink: 0;
background: transparent;
color: var(--primary);
border: none;
border-radius: var(--radius-md);
padding: 4px 10px;
font-size: var(--font-xs);
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.about-check-btn:hover:not(:disabled) {
background: rgba(54, 178, 106, 0.08);
}
.about-check-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.about-update-detail {
width: 100%;
padding: var(--spacing-lg);
border-top: 1px solid var(--border-light);
background: var(--bg-input);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.about-update-body-title {
font-size: var(--font-sm);
font-weight: 500;
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
}
.about-update-body pre {
font-size: var(--font-sm);
line-height: 1.7;
white-space: pre-wrap;
margin: 0;
color: var(--text-primary);
}
.about-update-progress {
margin-bottom: var(--spacing-sm);
}
.about-progress-bar {
height: 6px;
background: var(--border-light);
border-radius: 3px;
overflow: hidden;
}
.about-progress-fill {
height: 100%;
background: var(--primary);
border-radius: 3px;
transition: width 0.3s;
}
.about-progress-meta {
display: flex;
justify-content: space-between;
font-size: var(--font-xs);
color: var(--text-tertiary);
margin-top: 4px;
}
.about-update-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
}
.about-update-error {
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
background: #fef2f2;
color: #dc2626;
font-size: var(--font-sm);
border-radius: var(--radius-md);
}
@@ -0,0 +1,361 @@
/**
* 封面形象库页面
* ============
*
* 管理用户上传的人物照片(抠图后)。
* 上传图片 → 七牛云 → 火山引擎 MediaKit 抠图 → 保存到本地素材库。
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { useCoverAvatarStore } from '../../store';
import { toast } from '../../store/uiStore';
import { useProgressStore } from '../../store/progressStore';
import Modal from '../../components/Modal/Modal';
import ConfirmModal from '../../components/Modal/ConfirmModal';
import './ContentManagement.css';
export default function CoverAvatarLibrary() {
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadName, setUploadName] = useState('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
// 重命名状态
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
// 删除确认状态
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const {
coverAvatars,
isLoading,
loadCoverAvatars,
addCoverAvatar,
renameCoverAvatar,
deleteCoverAvatar,
} = useCoverAvatarStore();
// 加载数据
useEffect(() => {
loadCoverAvatars();
}, [loadCoverAvatars]);
// 图片文件验证
const validateImageFile = (file: File): { valid: boolean; error?: string } => {
const maxSize = 20 * 1024 * 1024; // 20MB
if (file.size > maxSize) {
return { valid: false, error: `文件大小 ${(file.size / 1024 / 1024).toFixed(1)}MB,要求不超过 20MB` };
}
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return { valid: false, error: '仅支持 JPG、PNG、GIF、WebP 格式' };
}
return { valid: true };
};
// 文件选择
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {return;}
const validation = validateImageFile(file);
if (!validation.valid) {
toast.error(validation.error || '文件验证失败');
e.target.value = '';
return;
}
setSelectedFile(file);
// 默认使用文件名(不含扩展名)作为名称
if (!uploadName.trim()) {
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
setUploadName(nameWithoutExt);
}
}, [uploadName]);
// 重置弹窗状态
const resetUploadModal = useCallback(() => {
setUploadModalOpen(false);
setUploadName('');
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, []);
// 上传处理
const handleUpload = useCallback(async () => {
if (!uploadName.trim() || !selectedFile) {return;}
const progress = useProgressStore.getState();
setUploadModalOpen(false);
progress.show('上传封面形象');
try {
progress.update('正在上传并抠图...');
const avatar = await addCoverAvatar(selectedFile, uploadName.trim());
progress.success('封面形象保存成功', 200);
toast.success(`${avatar.name}」已保存`);
} catch (err) {
const msg = err instanceof Error ? err.message : '上传失败';
progress.error(msg);
toast.error(msg);
}
setUploadName('');
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [uploadName, selectedFile, addCoverAvatar]);
// 删除处理
const openDeleteModal = (id: string, name: string) => {
setDeleteTarget({ id, name });
setDeleteModalOpen(true);
};
const handleConfirmDelete = useCallback(async () => {
if (!deleteTarget) {return;}
try {
await deleteCoverAvatar(deleteTarget.id);
toast.success('已删除');
} catch {
toast.error('删除失败');
} finally {
setDeleteModalOpen(false);
setDeleteTarget(null);
}
}, [deleteTarget, deleteCoverAvatar]);
// 重命名
const startRename = (id: string, currentName: string) => {
setEditingId(id);
setEditingName(currentName);
};
const cancelRename = () => {
setEditingId(null);
setEditingName('');
};
const confirmRename = useCallback(async () => {
if (!editingId || !editingName.trim()) {
cancelRename();
return;
}
try {
await renameCoverAvatar(editingId, editingName.trim());
setEditingId(null);
setEditingName('');
} catch {
toast.error('重命名失败');
}
}, [editingId, editingName, renameCoverAvatar]);
return (
<div className="content-page">
{/* 页面标题和上传区域 */}
<div className="voice-clone-wrapper">
<div className="voice-clone-title-group">
<h2></h2>
<p className="voice-clone-desc">AI </p>
</div>
{/* 上传引导卡片 */}
<div
className="voice-upload-card"
onClick={() => setUploadModalOpen(true)}
>
<div className="voice-upload-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
<div className="voice-upload-text">
<span className="voice-upload-title"></span>
<span className="voice-upload-hint">JPG / PNG / GIF / WebP</span>
</div>
<div className="voice-upload-arrow">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</div>
</div>
</div>
{/* 上传弹窗 */}
<Modal
open={uploadModalOpen}
onClose={resetUploadModal}
title=""
width="480px"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<label style={{ fontSize: 'var(--font-sm)', fontWeight: 500, marginBottom: 8, display: 'block' }}>
</label>
<input
type="text"
className="input"
placeholder="例如:我的形象"
value={uploadName}
onChange={e => setUploadName(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div>
<label style={{ fontSize: 'var(--font-sm)', fontWeight: 500, marginBottom: 8, display: 'block' }}>
</label>
<div
style={{
border: '2px dashed var(--border-color)',
borderRadius: 'var(--radius-md)',
padding: 'var(--spacing-xl)',
textAlign: 'center',
cursor: 'pointer',
transition: 'all var(--transition-fast)',
}}
onClick={() => fileInputRef.current?.click()}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--primary)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-color)'; }}
>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
{selectedFile ? (
<div>
<div style={{ fontWeight: 500, fontSize: 'var(--font-sm)' }}>{selectedFile.name}</div>
<div style={{ fontSize: 'var(--font-xs)', color: 'var(--text-secondary)', marginTop: 4 }}>
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</div>
</div>
) : (
<div style={{ color: 'var(--text-secondary)' }}>
<div style={{ fontSize: 'var(--font-sm)' }}></div>
<div style={{ fontSize: 'var(--font-xs)', marginTop: 6, lineHeight: 1.6 }}>
JPG / PNG / GIF / WebP 20MB
</div>
</div>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button className="btn btn-secondary" onClick={resetUploadModal}></button>
<button
className="btn btn-primary"
onClick={handleUpload}
disabled={!uploadName.trim() || !selectedFile}
>
</button>
</div>
</div>
</Modal>
{/* 封面形象列表 */}
{isLoading ? (
<p style={{ color: 'var(--text-secondary)' }}>...</p>
) : coverAvatars.length === 0 ? (
<div className="empty-state-page">
<div className="empty-state-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
<p className="empty-state-title"></p>
<p className="empty-state-desc"><br />AI </p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, flex: 1, overflow: 'auto' }}>
<div className="voice-list" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, alignContent: 'start', alignItems: 'start' }}>
{coverAvatars.map(a => (
<div key={a.id} className="voice-row" style={{ cursor: 'default', padding: 12 }}>
{/* 图片预览 */}
<div style={{ marginBottom: 10, borderRadius: 'var(--radius-md)', overflow: 'hidden', background: '#f5f5f5', aspectRatio: '1', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<img
src={a.imageUrl}
alt={a.name}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
loading="lazy"
/>
</div>
<div className="voice-row-main" style={{ padding: 0 }}>
<div className="voice-row-info" style={{ flex: 1, minWidth: 0 }}>
{editingId === a.id ? (
<input
type="text"
className="input"
value={editingName}
onChange={e => setEditingName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {confirmRename();}
if (e.key === 'Escape') {cancelRename();}
}}
onBlur={confirmRename}
autoFocus
style={{ width: '100%', height: 28, padding: '2px 8px', fontSize: 'var(--font-sm)' }}
/>
) : (
<div className="voice-row-name" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{a.name}
</div>
)}
</div>
<div className="voice-row-actions">
<button
className="action-icon"
onClick={() => startRename(a.id, a.name)}
title="重命名"
>
<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" /></svg>
</button>
<button
className="action-icon"
onClick={() => openDeleteModal(a.id, a.name)}
title="删除"
>
</button>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 删除确认弹窗 */}
<ConfirmModal
open={deleteModalOpen}
type="danger"
title={<> <strong>{deleteTarget?.name}</strong> </>}
description="此操作不可撤销,形象将被永久删除"
confirmText="确认删除"
cancelText="取消"
confirmButtonType="danger"
onConfirm={handleConfirmDelete}
onCancel={() => { setDeleteModalOpen(false); setDeleteTarget(null); }}
/>
</div>
);
}
+94 -41
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useCallback } from 'react';
import { useState, useRef, useCallback, useEffect } from 'react';
import { useNavigation } from '../../contexts/NavigationContext';
import AppHeader from '../../components/Layout/AppHeader';
import EnvironmentSwitchModal from '../../components/Modal/EnvironmentSwitchModal';
@@ -27,6 +27,10 @@ export default function Settings() {
// ── 环境切换 ──
const [showEnvModal, setShowEnvModal] = useState(false);
// ── 缓存清理 ──
const [cacheSize, setCacheSize] = useState(0);
const [clearingCache, setClearingCache] = useState(false);
const clickCountRef = useRef(0);
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -128,6 +132,43 @@ export default function Settings() {
}, 2000);
}, []);
// 获取缓存大小
const fetchCacheSize = useCallback(async () => {
try {
const size = await invoke<number>('get_video_cache_size_cmd');
setCacheSize(size);
} catch {
setCacheSize(0);
}
}, []);
useEffect(() => {
fetchCacheSize();
return () => {
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
}
};
}, [fetchCacheSize]);
// 清理缓存
const handleClearCache = async () => {
if (cacheSize === 0) {
toast.info('暂无缓存需要清理');
return;
}
setClearingCache(true);
try {
const freed = await invoke<number>('clear_video_cache_cmd');
setCacheSize(0);
toast.success(`已清理 ${formatBytes(freed)} 缓存`);
} catch {
toast.error('清理缓存失败');
} finally {
setClearingCache(false);
}
};
const handleSaveEnv = async (env: string) => {
try {
await saveAppConfig(env);
@@ -149,58 +190,70 @@ export default function Settings() {
<div className="settings-page">
<AppHeader title="设置" showBack onBack={() => navigate('profile')} />
{/* 系统更新 */}
{/* 关于我们 */}
<div className="settings-section">
<h2></h2>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div className="settings-row">
<span className="settings-row-label"></span>
<span className="settings-row-value">v{CURRENT_VERSION}</span>
<div className="card about-card" style={{ padding: 'var(--spacing-lg)', overflow: 'hidden' }}>
{/* 品牌 */}
<div className="about-brand">
<img className="about-logo" src="/assets/logo.png" alt="美家卡智影" />
<span className="about-name"> </span>
</div>
<div className="settings-row" style={{ borderTop: '1px solid var(--border-light)' }}>
<span className="settings-row-label"></span>
<div className="settings-row-value" style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-md)', justifyContent: 'flex-end' }}>
{/* 版本 + 检查更新 */}
<div className="about-version-row">
<span
className="about-version-text"
onClick={handleVersionClick}
title={appEnvironment !== 'production' ? `当前环境: ${appEnvironment}` : undefined}
>
v{CURRENT_VERSION}
</span>
<div className="about-version-actions">
{checking ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', color: 'var(--text-tertiary)' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2" style={{ animation: 'spin 1s linear infinite' }}>
<span className="about-checking-status">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2" style={{ animation: 'spin 1s linear infinite' }}>
<path d="M21 12a9 9 0 11-6.219-8.56" />
</svg>
...
</span>
) : checkResult === 'latest' ? (
<span style={{ color: 'var(--success)', fontWeight: 500 }}></span>
<span className="about-check-result about-check-latest"></span>
) : checkResult === 'available' ? (
<span style={{ color: 'var(--primary)', fontWeight: 500 }}> {updateInfo?.version}</span>
<span className="about-check-result about-check-available"> {updateInfo?.version}</span>
) : null}
<button className="btn btn-primary btn-sm" onClick={handleCheck} disabled={checking || downloading}>
<button
className="btn btn-sm about-check-btn"
onClick={handleCheck}
disabled={checking || downloading}
>
{checking ? '检查中' : '检查更新'}
</button>
</div>
</div>
{/* 更新详情(发现新版本时展开) */}
{checkResult === 'available' && updateInfo && (
<div style={{ padding: '16px 20px', borderTop: '1px solid var(--border-light)', background: 'var(--bg-input)' }}>
<div className="about-update-detail">
{updateInfo.body && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 'var(--font-sm)', fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}></div>
<pre style={{ fontSize: 'var(--font-sm)', lineHeight: 1.7, whiteSpace: 'pre-wrap', margin: 0, color: 'var(--text-primary)' }}>{updateInfo.body}</pre>
<div className="about-update-body">
<div className="about-update-body-title"></div>
<pre>{updateInfo.body}</pre>
</div>
)}
{downloading && (
<div style={{ marginBottom: 12 }}>
<div style={{ height: 6, background: 'var(--border-light)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ width: `${progress}%`, height: '100%', background: 'var(--primary)', borderRadius: 3, transition: 'width 0.3s' }} />
<div className="about-update-progress">
<div className="about-progress-bar">
<div className="about-progress-fill" style={{ width: `${progress}%` }} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--font-xs)', color: 'var(--text-tertiary)', marginTop: 4 }}>
<div className="about-progress-meta">
<span>{progress}%</span>
{totalBytes > 0 && <span>{formatBytes(downloadedBytes)} / {formatBytes(totalBytes)}</span>}
</div>
</div>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<div className="about-update-actions">
{installing ? (
<button className="btn btn-primary btn-sm" onClick={handleRelaunch}></button>
) : (
@@ -213,31 +266,31 @@ export default function Settings() {
)}
{updateError && (
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border-light)', background: '#fef2f2', color: '#dc2626', fontSize: 'var(--font-sm)' }}>
<div className="about-update-error">
{updateError}
</div>
)}
</div>
</div>
{/* 关于我们 */}
{/* 缓存清理 */}
<div className="settings-section">
<h2></h2>
<h2></h2>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div className="settings-row">
<span className="settings-row-label"></span>
<span className="settings-row-value"> </span>
</div>
<div className="settings-row" style={{ borderTop: '1px solid var(--border-light)' }}>
<span className="settings-row-label"></span>
<span
className="settings-row-value"
onClick={handleVersionClick}
style={{ cursor: 'default', userSelect: 'none' }}
title={appEnvironment !== 'production' ? `当前环境: ${appEnvironment}` : undefined}
>
v{CURRENT_VERSION}
</span>
<span className="settings-row-label"></span>
<div className="settings-row-value" style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-md)', justifyContent: 'flex-end' }}>
<span style={{ fontSize: 'var(--font-sm)', color: 'var(--text-tertiary)' }}>
{cacheSize > 0 ? formatBytes(cacheSize) : '暂无缓存'}
</span>
<button
className="btn btn-sm about-check-btn"
onClick={handleClearCache}
disabled={clearingCache || cacheSize === 0}
>
{clearingCache ? '清理中...' : '立即清理'}
</button>
</div>
</div>
</div>
</div>
@@ -248,7 +301,7 @@ export default function Settings() {
<div className="card" style={{ padding: 'var(--spacing-xl)' }}>
<p style={{ lineHeight: 1.8, margin: 0 }}>
使
support@meijiaka.cn
</p>
</div>
</div>
@@ -5,13 +5,13 @@
* 完全遵循字幕压制页面结构和间距,保持一致
*/
/* 布局 - 55:45 和字幕压制一致 */
/* 布局 - 40:60 */
.step-layout.subtitle-burning {
grid-template-columns: 55fr 45fr;
}
.step-layout.cover-design-variant {
grid-template-columns: 55fr 45fr;
grid-template-columns: 40fr 60fr;
}
/* 左侧操作区 - 与视频生成页面统一 */
@@ -389,6 +389,178 @@
flex-shrink: 0;
}
/* 视觉素材横向卡片 */
.visual-assets-row {
display: flex;
gap: var(--spacing-md);
justify-content: center;
flex-wrap: wrap;
}
.visual-asset-card {
flex: 0 0 auto;
width: 250px;
max-width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px;
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-card);
cursor: pointer;
transition: all var(--transition-fast);
}
.visual-asset-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
.visual-asset-label {
font-size: var(--font-xs);
font-weight: 500;
color: var(--text-secondary);
}
.visual-asset-card img {
width: 100%;
aspect-ratio: 9 / 16;
object-fit: cover;
border-radius: var(--radius-sm);
display: block;
}
.visual-asset-placeholder {
width: 100%;
aspect-ratio: 9 / 16;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--text-tertiary);
font-size: var(--font-xs);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
}
.visual-asset-hint {
font-size: 11px;
color: var(--text-tertiary);
}
/* 弹窗背景图网格 */
.modal-bg-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-sm);
}
.modal-bg-thumb {
position: relative;
padding: 0;
border: 2px solid transparent;
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
background: none;
aspect-ratio: 9 / 16;
transition: all var(--transition-fast);
}
.modal-bg-thumb:hover {
border-color: var(--border-color);
transform: scale(1.02);
}
.modal-bg-thumb.active {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
.modal-bg-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 弹窗封面形象网格 */
.modal-avatar-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-sm);
}
.modal-avatar-thumb {
position: relative;
padding: 0;
border: 2px solid transparent;
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
background: var(--bg-secondary);
aspect-ratio: 1;
transition: all var(--transition-fast);
}
.modal-avatar-thumb:hover {
border-color: var(--border-color);
transform: scale(1.02);
}
.modal-avatar-thumb.active {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
.modal-avatar-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #999;
}
.modal-bg-error {
background: var(--bg-secondary, #f5f5f5);
display: flex;
align-items: center;
justify-content: center;
}
.modal-bg-error::after {
content: '图片加载失败';
font-size: 12px;
color: #999;
padding: 0 8px;
text-align: center;
}
.avatar-thumb-error {
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-thumb-error::after {
content: '加载失败';
font-size: 10px;
color: #999;
}
/* 背景图片区域头部(标签 + 按钮) */
.bg-section-header {
display: flex;
+213 -93
View File
@@ -3,13 +3,14 @@
* ================================
*
* 使用 Fabric.js Canvas 实时预览封面:
* - 背景图:支持选择本地图片
* - 模板:双标题居中 / 标题+标签列表
* - 文字样式:固定位置、颜色、大小
* - 背景图:点击卡片弹窗选图(6 张随机)
* - 封面形象:点击卡片弹窗选人
* - 模板:双标题居中
* - 导出:PNG 高清导出
*/
import { useState, useEffect } from 'react';
import { useCoverAvatarStore } from '../../store';
import { invoke } from '@tauri-apps/api/core';
import { useProjectStore } from '../../store';
import { getCurrentProjectId } from '../../api/modules/localStorage';
@@ -21,6 +22,7 @@ import { pointsApi } from '../../api/modules/points';
import { usePointStore, useAuthStore } from '../../store';
import RechargeModal from '../../components/RechargeModal/RechargeModal';
import ConfirmModal from '../../components/Modal/ConfirmModal';
import Modal from '../../components/Modal/Modal';
import { toast } from '../../store/uiStore';
import { getFriendlyErrorMessage } from '../../utils/errorMessage';
import { usePointsCheck } from '../../hooks/usePointsCheck';
@@ -41,20 +43,20 @@ function shuffleArray<T>(arr: T[]): T[] {
return result;
}
function pickThree(all: BgImage[], lastIds: Set<string>): BgImage[] {
if (all.length <= 3) {return all;}
function pickSix(all: BgImage[], lastIds: Set<string>): BgImage[] {
if (all.length <= 6) {return shuffleArray(all);}
let shuffled = shuffleArray(all);
for (let i = 0; i < 10; i++) {
const picked = shuffled.slice(0, 3);
const picked = shuffled.slice(0, 6);
const pickedIds = new Set(picked.map((b) => b.id));
const allSame =
pickedIds.size === 3 &&
lastIds.size === 3 &&
pickedIds.size === 6 &&
lastIds.size === 6 &&
[...pickedIds].every((id) => lastIds.has(id));
if (!allSame) {return picked;}
shuffled = shuffleArray(all);
}
return shuffled.slice(0, 3);
return shuffled.slice(0, 6);
}
export default function CoverDesign() {
@@ -71,11 +73,13 @@ export default function CoverDesign() {
backgroundImage: string | null;
mainTitle: string;
subtitle: string;
avatarImage: string | null;
}>({
template: 'dual-title',
backgroundImage: null,
mainTitle: '',
subtitle: '',
avatarImage: null,
});
const [isGeneratingMainTitle, setIsGeneratingMainTitle] = useState(false);
const [isGeneratingSubTitle, setIsGeneratingSubTitle] = useState(false);
@@ -85,12 +89,16 @@ export default function CoverDesign() {
const setShowRechargeModal = usePointStore(state => state.setShowRechargeModal);
const fetchBalance = usePointStore(state => state.fetchBalance);
// 弹窗状态
const [bgModalOpen, setBgModalOpen] = useState(false);
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
// 背景图配置
const categoryCode = useProjectStore((state) => state.categoryCode);
const [bgList, setBgList] = useState<BgImage[]>([]);
const [displayedBgs, setDisplayedBgs] = useState<BgImage[]>([]);
const [lastBgIds, setLastBgIds] = useState<Set<string>>(new Set());
const [bgLoadStatus, setBgLoadStatus] = useState<'idle' | 'loading' | 'empty' | 'error' | 'no_category'>('idle');
const [modalBgs, setModalBgs] = useState<BgImage[]>([]);
const [lastModalBgIds, setLastModalBgIds] = useState<Set<string>>(new Set());
const [, setBgLoadStatus] = useState<'idle' | 'loading' | 'empty' | 'error' | 'no_category'>('idle');
const { canvasRef, initCanvas, renderCover, exportPng } = useCoverFabric();
@@ -104,7 +112,6 @@ export default function CoverDesign() {
toast.error('暂无脚本内容,请先生成脚本');
return;
}
// 前置积分检查
const titlePoints = usePointStore.getState().getRule('title')?.points || 1;
const canProceed = await checkBalance(titlePoints, '标题生成', true);
if (!canProceed) {return;}
@@ -145,7 +152,7 @@ export default function CoverDesign() {
}
};
// 加载背景图(从 API 根据 script_code 获取)
// 加载背景图
useEffect(() => {
const loadBackgrounds = async () => {
if (!categoryCode) {
@@ -163,12 +170,11 @@ export default function CoverDesign() {
setBgList(list);
if (list.length === 0) {
setBgLoadStatus('empty');
setDisplayedBgs([]);
return;
}
const picked = pickThree(list, new Set());
setDisplayedBgs(picked);
setLastBgIds(new Set(picked.map((b) => b.id)));
// 初始随机选一张作为默认背景
const defaultBg = list[Math.floor(Math.random() * list.length)];
setConfig(prev => ({ ...prev, backgroundImage: defaultBg.src }));
setBgLoadStatus('idle');
} catch (err) {
console.error('[CoverDesign] 加载背景图失败:', err);
@@ -178,18 +184,20 @@ export default function CoverDesign() {
loadBackgrounds();
}, [categoryCode]);
// 换一组背景图
const handleShuffleBgs = () => {
const picked = pickThree(bgList, lastBgIds);
setDisplayedBgs(picked);
setLastBgIds(new Set(picked.map((b) => b.id)));
// 如果当前选中的背景不在新的一组中,清空选择
setConfig((prev) => {
const stillAvailable = picked.some((b) => b.src === prev.backgroundImage);
return stillAvailable
? prev
: { ...prev, backgroundImage: null };
});
// 弹窗展示 6 张背景图
const openBgModal = () => {
if (bgList.length === 0) {return;}
const picked = pickSix(bgList, lastModalBgIds);
setModalBgs(picked);
setLastModalBgIds(new Set(picked.map(b => b.id)));
setBgModalOpen(true);
};
// 弹窗换一组
const shuffleModalBgs = () => {
const picked = pickSix(bgList, lastModalBgIds);
setModalBgs(picked);
setLastModalBgIds(new Set(picked.map(b => b.id)));
};
// 初始化 Canvas
@@ -205,6 +213,7 @@ export default function CoverDesign() {
backgroundImage: coverConfig.backgroundImage ?? prev.backgroundImage,
mainTitle: coverConfig.mainTitle ?? coverConfig.caption ?? prev.mainTitle,
subtitle: coverConfig.subtitle ?? prev.subtitle,
avatarImage: coverConfig.avatarImage ?? prev.avatarImage,
}));
}, [coverConfig]);
@@ -221,7 +230,6 @@ export default function CoverDesign() {
if (!projectId) {return;}
if (!config.mainTitle.trim()) {return;}
// 积分预检:封面设计固定消耗 2 积分
const COVER_POINTS = 2;
await fetchBalance();
const currentBalance = usePointStore.getState().balance;
@@ -237,7 +245,6 @@ export default function CoverDesign() {
try {
useProgressStore.getState().update('正在设计封面...');
// 0. 删除旧封面文件(如果存在)
const oldCoverPath = useProjectStore.getState().coverPath;
if (oldCoverPath) {
try {
@@ -246,18 +253,16 @@ export default function CoverDesign() {
filePath: oldCoverPath,
});
} catch {
// no-op: 删除旧封面失败不影响主流程
// no-op
}
}
// 1. 导出 PNG (base64) — 纯前端操作,先确认成功再扣费
const dataUrl = exportPng();
if (!dataUrl) {
throw new Error('封面设计失败');
}
const base64 = dataUrl.split(',')[1];
// 2. 保存到本地(先保存,成功后再扣费,避免保存失败导致积分损失)
const result = await invoke<{
code: number;
data?: string;
@@ -272,7 +277,6 @@ export default function CoverDesign() {
throw new Error(result.message);
}
// 3. 扣费:封面设计
const coverDesignPoints = usePointStore.getState().getRule('cover_design')?.points || 2;
await pointsApi.consumePoints({
points: coverDesignPoints,
@@ -282,13 +286,13 @@ export default function CoverDesign() {
});
await usePointStore.getState().fetchBalance();
// 4. 更新状态
setCoverPath(result.data);
setCoverConfig({
template: config.template,
backgroundImage: config.backgroundImage,
mainTitle: config.mainTitle.trim(),
subtitle: config.subtitle.trim(),
avatarImage: config.avatarImage,
});
useProjectStore.getState().clearStepDirty(5);
@@ -307,6 +311,13 @@ export default function CoverDesign() {
}
};
// 封面形象库
const { coverAvatars, loadCoverAvatars } = useCoverAvatarStore();
useEffect(() => {
loadCoverAvatars();
}, [loadCoverAvatars]);
const currentAvatar = coverAvatars.find(a => a.imageUrl === config.avatarImage);
const canGenerate = !!config.mainTitle.trim();
return (
@@ -339,6 +350,113 @@ export default function CoverDesign() {
setShowPointsModal(false);
}}
/>
{/* 背景图弹窗 */}
<Modal
open={bgModalOpen}
onClose={() => setBgModalOpen(false)}
title="选择背景图片"
width="560px"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{modalBgs.length > 0 ? (
<div className="modal-bg-grid">
{modalBgs.map((bg) => (
<button
key={bg.id}
className={`modal-bg-thumb ${config.backgroundImage === bg.src ? 'active' : ''}`}
onClick={() => {
setConfig((prev) => ({ ...prev, backgroundImage: bg.src }));
setBgModalOpen(false);
}}
title={bg.name}
>
<img
src={bg.src}
alt={bg.name}
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const parent = (e.target as HTMLImageElement).parentElement;
if (parent) {parent.classList.add('modal-bg-error');}
}}
/>
</button>
))}
</div>
) : (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-secondary)' }}>
</div>
)}
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button
className="btn btn-secondary btn-sm"
onClick={shuffleModalBgs}
disabled={bgList.length <= 6}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: 4, verticalAlign: 'middle' }}>
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button className="btn btn-secondary btn-sm" onClick={() => setBgModalOpen(false)}></button>
</div>
</div>
</Modal>
{/* 封面形象弹窗 */}
<Modal
open={avatarModalOpen}
onClose={() => setAvatarModalOpen(false)}
title="选择封面形象"
width="480px"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="modal-avatar-grid">
<button
className={`modal-avatar-thumb ${config.avatarImage === null ? 'active' : ''}`}
onClick={() => {
setConfig((prev) => ({ ...prev, avatarImage: null }));
setAvatarModalOpen(false);
}}
>
<div className="avatar-placeholder">使</div>
</button>
{coverAvatars.map((avatar) => (
<button
key={avatar.id}
className={`modal-avatar-thumb ${config.avatarImage === avatar.imageUrl ? 'active' : ''}`}
onClick={() => {
setConfig((prev) => ({ ...prev, avatarImage: avatar.imageUrl }));
setAvatarModalOpen(false);
}}
title={avatar.name}
>
<img
src={avatar.imageUrl}
alt={avatar.name}
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const parent = (e.target as HTMLImageElement).parentElement;
if (parent) {parent.classList.add('avatar-thumb-error');}
}}
/>
</button>
))}
</div>
{coverAvatars.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--text-secondary)', fontSize: 'var(--font-xs)', padding: '8px 0' }}>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn btn-secondary btn-sm" onClick={() => setAvatarModalOpen(false)}></button>
</div>
</div>
</Modal>
{/* 左侧:配置区域 */}
<div className="step-panel-left">
<div
@@ -352,63 +470,70 @@ export default function CoverDesign() {
gap: 'var(--spacing-lg)',
}}
>
{/* 背景图片 */}
{/* 视觉素材:背景图 + 封面形象 同一行 */}
<div className="panel-section">
<div className="bg-section-header">
<label className="panel-label"></label>
<label className="panel-label"></label>
<div className="visual-assets-row">
{/* 背景图卡片 */}
<button
className="btn btn-secondary btn-sm bg-shuffle-btn"
onClick={handleShuffleBgs}
disabled={bgList.length <= 3}
className="visual-asset-card"
onClick={openBgModal}
title="点击更换背景"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<div className="visual-asset-label"></div>
{config.backgroundImage ? (
<img
src={config.backgroundImage}
alt="背景"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="visual-asset-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
<span></span>
</div>
)}
<div className="visual-asset-hint"></div>
</button>
{/* 封面形象卡片 */}
<button
className="visual-asset-card"
onClick={() => setAvatarModalOpen(true)}
title="点击更换形象"
>
<div className="visual-asset-label"></div>
{currentAvatar ? (
<img
src={currentAvatar.imageUrl}
alt={currentAvatar.name}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="visual-asset-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span>使</span>
</div>
)}
<div className="visual-asset-hint"></div>
</button>
</div>
<div className="bg-image-grid">
{displayedBgs.length === 0 ? (
<div className="bg-empty-tip" style={{ padding: '12px', color: '#999', fontSize: '13px', gridColumn: '1 / -1' }}>
{bgLoadStatus === 'no_category' && '请先选择脚本分类'}
{bgLoadStatus === 'loading' && '背景图加载中...'}
{bgLoadStatus === 'empty' && '暂无背景图,请联系管理员添加'}
{bgLoadStatus === 'error' && '背景图加载失败,请检查网络后刷新页面重试'}
{bgLoadStatus === 'idle' && '背景图加载失败,请检查网络后刷新页面重试'}
</div>
) : (
displayedBgs.map((bg) => (
<button
key={bg.id}
className={`bg-thumb ${config.backgroundImage === bg.src ? 'active' : ''}`}
onClick={() => setConfig((prev) => ({ ...prev, backgroundImage: bg.src }))}
title={bg.name}
>
{bg.src ? (
<img
src={bg.src}
alt={bg.name}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const parent = (e.target as HTMLImageElement).parentElement;
if (parent) {
parent.classList.add('bg-thumb-error');
}
}}
/>
) : (
<div className="bg-thumb-placeholder">{bg.name}</div>
)}
</button>
))
)}
</div>
</div>
{/* 主标题 */}
{/* 文案配置 */}
<div className="panel-section">
<label className="panel-label"></label>
<label className="panel-label"></label>
<div className="title-input-row">
<input
type="text"
@@ -429,12 +554,7 @@ export default function CoverDesign() {
{isGeneratingMainTitle ? '生成中...' : '智能生成'}
</button>
</div>
</div>
{/* 副标题 */}
<div className="panel-section">
<label className="panel-label"></label>
<div className="title-input-row">
<div className="title-input-row" style={{ marginTop: 8 }}>
<textarea
className="input subtitle-textarea"
placeholder="输入底部文案"
@@ -464,7 +584,7 @@ export default function CoverDesign() {
onClick={handleGenerate}
style={{ marginTop: 'auto', flexShrink: 0 }}
>
{coverPath ? '重新设计封面' : '立即设计封面'}{usePointStore.getState().getRule('cover_design')?.points || 2}
{coverPath ? '重新设计封面' : '立即设计封面'}{usePointStore.getState().getRule('cover_design')?.points || 2}
</button>
</div>
@@ -389,7 +389,7 @@ export default function ScriptCreation() {
>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
{generated ? `重新生成脚本(${usePointStore.getState().getRule('script')?.points || 5}积分)` : `生成脚本(${usePointStore.getState().getRule('script')?.points || 5}积分)`}
{generated ? `重新生成脚本(${usePointStore.getState().getRule('script')?.points || 5} 积分)` : `生成脚本(${usePointStore.getState().getRule('script')?.points || 5} 积分)`}
</button>
{/* 生成进度由全局 ProgressModal 统一展示 */}
@@ -626,7 +626,7 @@ export default function SubtitleBurning() {
onClick={handleBurn}
disabled={isBurning || !alignment?.utterances?.length}
>
{usePointStore.getState().getRule('subtitle_burn')?.points || 2}
{usePointStore.getState().getRule('subtitle_burn')?.points || 2}
</button>
<button
className={`btn burn-btn ${previewMode === 'result' ? 'btn-primary' : 'btn-secondary'}`}
@@ -642,7 +642,7 @@ export default function SubtitleBurning() {
onClick={handleBurn}
disabled={isBurning || !alignment?.utterances?.length}
>
{usePointStore.getState().getRule('subtitle_burn')?.points || 2}
{usePointStore.getState().getRule('subtitle_burn')?.points || 2}
</button>
)}
</div>
@@ -348,7 +348,7 @@ export default function VideoCompose() {
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
{usePointStore.getState().getRule('compose')?.points || 5}
{usePointStore.getState().getRule('compose')?.points || 5}
</button>
) : (
<div style={{ display: 'flex', gap: 'var(--spacing-md)', width: '100%' }}>
@@ -392,7 +392,7 @@ export default function VideoCompose() {
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{usePointStore.getState().getRule('compose')?.points || 5}
{usePointStore.getState().getRule('compose')?.points || 5}
</button>
</div>
)}
@@ -575,12 +575,12 @@ export default function VoiceSynthesis() {
<div className="voice-generate-wrap">
{!hasGeneratedAudio ? (
<button className="btn btn-primary generate-btn" onClick={handleGenerate} disabled={isGenerating || !mergedText.trim()}>
{estimatedTtsPoints.min}~{estimatedTtsPoints.max}
{estimatedTtsPoints.min}~{estimatedTtsPoints.max}
</button>
) : (
<div className="voice-generate-btns">
<button className="btn btn-secondary generate-btn" onClick={handleGenerate} disabled={isGenerating || !mergedText.trim()}>
{estimatedTtsPoints.min}~{estimatedTtsPoints.max}
{estimatedTtsPoints.min}~{estimatedTtsPoints.max}
</button>
<button
className="btn btn-primary generate-btn"
@@ -1,5 +1,3 @@
import { usePointStore } from '../../../store';
interface GenerationControlsProps {
composedVideoPath: string | null;
isComposing: boolean;
@@ -22,7 +20,6 @@ const GenerationControls: React.FC<GenerationControlsProps> = ({
onGenerate,
onPreview,
}) => {
const balance = usePointStore((s) => s.balance);
const generateIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="5 3 19 12 5 21 5 3" />
@@ -47,7 +44,7 @@ const GenerationControls: React.FC<GenerationControlsProps> = ({
disabled={isDisabled}
>
{refreshIcon}
{estimatedVideoPoints} · {balance}
{estimatedVideoPoints}
</button>
<button
className="btn btn-secondary"
@@ -70,7 +67,7 @@ const GenerationControls: React.FC<GenerationControlsProps> = ({
disabled={isDisabled}
>
{generateIcon}
{estimatedVideoPoints} · {balance}
{estimatedVideoPoints}
</button>
</div>
);
+128
View File
@@ -0,0 +1,128 @@
/**
* Cover Avatar Store - Zustand
* ==============================
*
* AI
*/
import { create } from 'zustand';
import type { CoverAvatar } from '../api/modules/coverAvatar';
import * as coverAvatarApi from '../api/modules/coverAvatar';
interface CoverAvatarState {
// 封面形象列表
coverAvatars: CoverAvatar[];
// 加载状态
isLoading: boolean;
}
interface CoverAvatarActions {
// 加载封面形象库
loadCoverAvatars: () => Promise<void>;
// 添加封面形象(上传 → 抠图 → 保存)
addCoverAvatar: (file: File, name: string) => Promise<CoverAvatar>;
// 重命名封面形象
renameCoverAvatar: (id: string, name: string) => Promise<void>;
// 删除封面形象
deleteCoverAvatar: (id: string) => Promise<void>;
// 重置
reset: () => void;
}
const initialState: CoverAvatarState = {
coverAvatars: [],
isLoading: false,
};
export const useCoverAvatarStore = create<CoverAvatarState & CoverAvatarActions>()(
(set) => ({
...initialState,
// ====================== 加载 ======================
loadCoverAvatars: async () => {
set({ isLoading: true });
try {
const avatars = await coverAvatarApi.loadCoverAvatars();
set({ coverAvatars: avatars });
} catch (err) {
console.error('[CoverAvatarStore] 加载封面形象库失败:', err);
} finally {
set({ isLoading: false });
}
},
// ====================== 添加 ======================
addCoverAvatar: async (file: File, name: string) => {
// 1. 上传原始图片到七牛云
const originalUrl = await coverAvatarApi.uploadImage(file);
// 2. AI 抠图
const removedBgUrl = await coverAvatarApi.removeBackground(originalUrl);
// 3. 下载抠图结果并转 base64
const response = await fetch(removedBgUrl);
if (!response.ok) {
throw new Error(`下载抠图结果失败: ${response.status}`);
}
const blob = await response.blob();
const arrayBuffer = await blob.arrayBuffer();
const base64Data = btoa(
new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), '')
);
// 4. 保存图片到本地
const avatarId = crypto.randomUUID();
const ext = 'png';
const localPath = await coverAvatarApi.saveCoverAvatarImage({
avatarId,
imageData: base64Data,
ext,
});
// 5. 保存元数据到本地 JSON
const avatar: CoverAvatar = {
id: avatarId,
name,
imageUrl: removedBgUrl,
localPath,
createdAt: new Date().toISOString(),
};
await coverAvatarApi.saveCoverAvatar(avatar);
set(state => ({ coverAvatars: [avatar, ...state.coverAvatars] }));
return avatar;
},
// ====================== 重命名 ======================
renameCoverAvatar: async (id: string, name: string) => {
set(state => {
const updated = state.coverAvatars.map(a =>
a.id === id ? { ...a, name } : a
);
const target = updated.find(a => a.id === id);
if (target) {
coverAvatarApi.saveCoverAvatar(target).catch(err => {
console.error('[CoverAvatarStore] 重命名保存失败:', err);
});
}
return { coverAvatars: updated };
});
},
// ====================== 删除 ======================
deleteCoverAvatar: async (id: string) => {
await coverAvatarApi.deleteCoverAvatar(id);
set(state => ({
coverAvatars: state.coverAvatars.filter(a => a.id !== id),
}));
},
// ====================== 重置 ======================
reset: () => set(initialState),
})
);
+1
View File
@@ -13,6 +13,7 @@ export { useUIStore, toast } from './uiStore';
export { useProgressStore } from './progressStore';
export { useVoiceStore } from './voiceStore';
export { usePointStore } from './pointStore';
export { useCoverAvatarStore } from './coverAvatarStore';
// 类型导出
+2
View File
@@ -62,6 +62,8 @@ interface CoverConfig {
mainTitle?: string;
subtitle?: string;
tags?: string;
/** 封面形象图片 URL(抠图后的透明背景人物照) */
avatarImage?: string | null;
}
// ------------------------------------------------------------------
+1
View File
@@ -154,6 +154,7 @@ export const BLANK_META_OVERRIDES: MetaOverrides = {
lipSyncedVideoUrl: undefined,
dubbingAudioUrl: undefined,
dubbingAudioPath: undefined,
dubbingAudioDuration: undefined,
avatarMaterialPath: undefined,
avatarMaterialName: undefined,
avatarMaterialDuration: undefined,