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:
@@ -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}")
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -11,3 +11,4 @@ pub mod project;
|
||||
pub mod voice;
|
||||
pub mod video_compose;
|
||||
pub mod file;
|
||||
pub mod cover_avatar;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '我的作品' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
|
||||
// 类型导出
|
||||
|
||||
@@ -62,6 +62,8 @@ interface CoverConfig {
|
||||
mainTitle?: string;
|
||||
subtitle?: string;
|
||||
tags?: string;
|
||||
/** 封面形象图片 URL(抠图后的透明背景人物照) */
|
||||
avatarImage?: string | null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -154,6 +154,7 @@ export const BLANK_META_OVERRIDES: MetaOverrides = {
|
||||
lipSyncedVideoUrl: undefined,
|
||||
dubbingAudioUrl: undefined,
|
||||
dubbingAudioPath: undefined,
|
||||
dubbingAudioDuration: undefined,
|
||||
avatarMaterialPath: undefined,
|
||||
avatarMaterialName: undefined,
|
||||
avatarMaterialDuration: undefined,
|
||||
|
||||
Reference in New Issue
Block a user