feat: 应用自动更新系统 + 草稿箱删除 + 分类缓存优化

- 新增 Tauri 自动更新(updater 插件)
  - Rust: 集成 tauri-plugin-updater + tauri-plugin-process
  - 后端: app_releases / release_packages 表 + /update/check API
  - 前端: UpdateDialog 组件 + useUpdater hook + SystemUpdate 手动检查
  - 发版脚本: scripts/publish_release.py(扫描 .sig → 上传七牛云 → 写入数据库)
  - 配置 test 环境域名 dev.tapi.meijiaka.cn

- 草稿箱删除功能
  - DraftListItem 添加删除按钮
  - MyWorks 添加删除确认弹窗 + localProjectApi.deleteProject 调用

- 创作主题分类本地缓存
  - scriptApi.getCategoriesCached() 先读 localStorage 再静默刷新

- TermsModal tab 居中

- 更新应用图标(Big Sur 风格圆角矩形)

- 清理: 删除未使用文件 create_user.py / video-replace-mvp.py / DEPS_*.md
This commit is contained in:
小鱼开发
2026-05-15 16:41:57 +08:00
parent 3bfaea018c
commit cb56698836
69 changed files with 1780 additions and 691 deletions
+1
View File
@@ -30,6 +30,7 @@ from app.models.user import User # noqa
from app.models.user_device import UserDevice # noqa
from app.models.user_point import UserPoint # noqa
from app.models.cover_background import CoverBackground # noqa
from app.models.update import AppRelease, ReleasePackage # noqa
# this is the Alembic Config object
config = context.config
@@ -0,0 +1,62 @@
"""
add app update tables
Revision ID: d0a7c5a375c6
Revises: e02c96e264d9
Create Date: 2026-05-11 09:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "d0a7c5a375c6"
down_revision: Union[str, None] = "e02c96e264d9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"app_releases",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("version", sa.String(length=20), nullable=False),
sa.Column("release_date", sa.DateTime(timezone=True), nullable=False),
sa.Column("notes", sa.Text(), nullable=False),
sa.Column("mandatory", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("version"),
)
op.create_index("ix_app_releases_version", "app_releases", ["version"], unique=False)
op.create_table(
"release_packages",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("release_id", sa.Integer(), nullable=False),
sa.Column("platform", sa.String(length=20), nullable=False),
sa.Column("architecture", sa.String(length=20), nullable=False),
sa.Column("filename", sa.String(length=255), nullable=False),
sa.Column("file_url", sa.String(length=500), nullable=False),
sa.Column("file_size", sa.BigInteger(), nullable=False),
sa.Column("signature", sa.Text(), nullable=False),
sa.Column("download_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["release_id"], ["app_releases.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("release_id", "platform", "architecture", name="uix_pkg_platform_arch"),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("release_packages")
op.drop_index("ix_app_releases_version", table_name="app_releases")
op.drop_table("app_releases")
# ### end Alembic commands ###
+4
View File
@@ -15,6 +15,7 @@ from app.api.v1 import (
script,
system,
tasks,
update,
upload,
vidu,
voice,
@@ -57,3 +58,6 @@ api_router.include_router(cover_background.router, tags=["Cover Background"])
# 积分系统模块
api_router.include_router(points.router, tags=["Points"])
# 应用更新模块
api_router.include_router(update.router, prefix="/update", tags=["Update"])
+189
View File
@@ -0,0 +1,189 @@
"""
应用更新 API
============
为 Tauri updater 插件提供更新检查接口。
"""
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.models.update import AppRelease, ReleasePackage
from app.schemas.update import (
ReleaseCreate,
ReleaseListItem,
ReleaseResponse,
TauriPlatformInfo,
TauriUpdateResponse,
)
router = APIRouter()
@router.get("/check", response_model=TauriUpdateResponse)
async def check_update(
version: str = Query(..., description="当前应用版本"),
target: str = Query(..., description="平台:darwin / windows / linux"),
arch: str = Query(..., description="架构:x86_64 / aarch64"),
db: AsyncSession = Depends(get_db),
):
"""
检查应用更新 — Tauri updater 插件入口
Tauri 启动时会向此接口发送请求,携带当前版本、平台、架构信息。
如果无需更新,返回 204;如果有更新,返回 Tauri 标准格式的 JSON。
"""
# 查询最新版本
result = await db.execute(
select(AppRelease).order_by(AppRelease.release_date.desc()).limit(1)
)
latest: AppRelease | None = result.scalar_one_or_none()
if not latest:
raise HTTPException(status_code=status.HTTP_204_NO_CONTENT)
# 已是最新版本(或更高)
if latest.version == version:
raise HTTPException(status_code=status.HTTP_204_NO_CONTENT)
# 查询对应平台的包
result = await db.execute(
select(ReleasePackage).where(
ReleasePackage.release_id == latest.id,
ReleasePackage.platform == target,
ReleasePackage.architecture == arch,
)
)
pkg: ReleasePackage | None = result.scalar_one_or_none()
if not pkg:
# 该平台无包,返回 204(避免报错阻断用户)
raise HTTPException(status_code=status.HTTP_204_NO_CONTENT)
# 构建 Tauri 格式的响应
platform_key = f"{target}-{arch}"
return TauriUpdateResponse(
version=latest.version,
notes=latest.notes,
pub_date=latest.release_date.isoformat() if latest.release_date else None,
mandatory=latest.mandatory,
platforms={
platform_key: TauriPlatformInfo(
url=pkg.file_url,
signature=pkg.signature,
)
},
)
@router.post("/releases", response_model=ReleaseResponse, status_code=status.HTTP_201_CREATED)
async def create_release(
release: ReleaseCreate,
db: AsyncSession = Depends(get_db),
):
"""
创建新版本发布(管理员接口)
用于手动发版时,将构建产物信息写入数据库。
"""
# 检查版本是否已存在
result = await db.execute(select(AppRelease).where(AppRelease.version == release.version))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"版本 {release.version} 已存在",
)
# 创建发布记录
new_release = AppRelease(
version=release.version,
release_date=datetime.now(UTC),
notes=release.notes,
mandatory=release.mandatory,
)
db.add(new_release)
await db.flush()
# 创建包记录
for pkg in release.packages:
db.add(
ReleasePackage(
release_id=new_release.id,
platform=pkg.platform,
architecture=pkg.architecture,
filename=pkg.filename,
file_url=pkg.file_url,
file_size=pkg.file_size,
signature=pkg.signature,
)
)
await db.commit()
await db.refresh(new_release)
return ReleaseResponse(
id=new_release.id,
version=new_release.version,
release_date=new_release.release_date,
notes=new_release.notes,
mandatory=new_release.mandatory,
created_at=new_release.created_at,
packages=[
{
"platform": p.platform,
"architecture": p.architecture,
"filename": p.filename,
"file_url": p.file_url,
"file_size": p.file_size,
"signature": p.signature,
}
for p in new_release.packages
],
)
@router.get("/releases", response_model=list[ReleaseListItem])
async def list_releases(
db: AsyncSession = Depends(get_db),
):
"""获取所有版本发布列表(管理员接口)"""
result = await db.execute(select(AppRelease).order_by(AppRelease.release_date.desc()))
releases = result.scalars().all()
return [
ReleaseListItem(
id=r.id,
version=r.version,
release_date=r.release_date,
notes=r.notes,
mandatory=r.mandatory,
package_count=len(r.packages),
)
for r in releases
]
@router.delete("/releases/{version}")
async def delete_release(
version: str,
db: AsyncSession = Depends(get_db),
):
"""删除版本发布(管理员接口)"""
result = await db.execute(select(AppRelease).where(AppRelease.version == version))
release = result.scalar_one_or_none()
if not release:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"版本 {version} 不存在",
)
await db.delete(release)
await db.commit()
return {"status": "success", "message": f"版本 {version} 已删除"}
+64
View File
@@ -0,0 +1,64 @@
"""
应用更新模型
============
Tauri updater 插件所需的数据结构。
"""
from datetime import UTC, datetime
from sqlalchemy import Boolean, BigInteger, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
class AppRelease(Base):
"""应用版本发布记录"""
__tablename__ = "app_releases"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
version: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True)
release_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
mandatory: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
packages: Mapped[list["ReleasePackage"]] = relationship(
"ReleasePackage",
back_populates="release",
cascade="all, delete-orphan",
lazy="selectin",
)
class ReleasePackage(Base):
"""平台安装包信息"""
__tablename__ = "release_packages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
release_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("app_releases.id", ondelete="CASCADE"),
nullable=False,
)
platform: Mapped[str] = mapped_column(String(20), nullable=False)
architecture: Mapped[str] = mapped_column(String(20), nullable=False)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
file_url: Mapped[str] = mapped_column(String(500), nullable=False)
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
signature: Mapped[str] = mapped_column(Text, nullable=False)
download_count: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
release: Mapped["AppRelease"] = relationship("AppRelease", back_populates="packages")
__table_args__ = (
UniqueConstraint("release_id", "platform", "architecture", name="uix_pkg_platform_arch"),
)
+80
View File
@@ -0,0 +1,80 @@
"""
应用更新 Schema
===============
Tauri updater 插件所需的请求/响应模型。
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class PackageInfo(BaseModel):
"""包信息"""
platform: str
architecture: str
filename: str
file_url: str
file_size: int
signature: str
class ReleaseCreate(BaseModel):
"""创建版本发布请求"""
version: str = Field(..., description="版本号(语义化版本)", examples=["1.6.0"])
notes: str = Field(default="", description="更新说明(支持换行)")
mandatory: bool = Field(default=False, description="是否强制更新")
packages: list[PackageInfo]
class ReleaseResponse(BaseModel):
"""版本发布响应"""
id: int
version: str
release_date: datetime
notes: str
mandatory: bool
created_at: datetime
packages: list[PackageInfo]
class ReleaseListItem(BaseModel):
"""版本列表项"""
id: int
version: str
release_date: datetime
notes: str
mandatory: bool
package_count: int
# ------------------------------------------------------------------
# Tauri updater 插件所需的 JSON 格式
# ------------------------------------------------------------------
class TauriPlatformInfo(BaseModel):
"""Tauri updater 单平台信息"""
url: str = Field(..., description="安装包下载地址")
signature: str = Field(..., description="Ed25519 签名内容(.sig 文件全文)")
class TauriUpdateResponse(BaseModel):
"""Tauri updater 检查更新响应
参考:https://tauri.app/plugin/updater/
"""
version: str = Field(..., description="新版本号")
notes: str = Field(default="", description="更新说明")
pub_date: Optional[str] = Field(default=None, description="发布时间(RFC 3339")
mandatory: bool = Field(default=False, description="是否强制更新(自定义扩展字段)")
platforms: dict[str, TauriPlatformInfo] = Field(
..., description="平台安装包映射,key 格式:OS-ARCH"
)
-111
View File
@@ -1,111 +0,0 @@
#!/usr/bin/env python3
"""
创建用户 CLI 工具
================
绕过短信验证码,直接操作数据库创建用户。
用法:
cd python-api
python -m scripts.create_user --mobile 13800138000 --nickname 测试用户
python -m scripts.create_user --mobile 13800138001 --nickname 用户2 --device-id dev-001
环境变量:
DATABASE_URL 数据库连接(默认从 .env 读取)
SECRET_KEY JWT 密钥(默认从 .env 读取)
"""
import argparse
import asyncio
import os
import sys
import uuid
# 将项目根目录加入路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import select
from app.db.session import AsyncSessionLocal, close_db
from app.models.user import User
from app.models.user_device import UserDevice
from app.models.user_point import UserPoint
async def create_user(
mobile: str,
nickname: str | None = None,
device_id: str | None = None,
device_name: str | None = None,
source: str = "cli",
) -> User:
"""创建用户并返回 User 实例。"""
async with AsyncSessionLocal() as session:
# 检查手机号是否已存在
stmt = select(User).where(User.mobile == mobile)
result = await session.execute(stmt)
existing = result.scalar_one_or_none()
if existing is not None:
print(f"⚠️ 手机号 {mobile} 已存在(id={existing.id}),跳过创建")
return existing
# 创建用户
user = User(
id=uuid.uuid4(),
mobile=mobile,
nickname=nickname,
source=source,
)
session.add(user)
# 需要先 flush 获取 user.id(虽然 uuid 已预生成,但 flush 确保数据库一致)
await session.flush()
# 创建积分汇总记录
user_point = UserPoint(user_id=user.id)
session.add(user_point)
# 可选:创建设备记录
if device_id:
from datetime import UTC, datetime
device = UserDevice(
user_id=user.id,
device_id=device_id,
device_name=device_name,
last_active_at=datetime.now(UTC),
)
session.add(device)
await session.commit()
print(f"✅ 用户创建成功: id={user.id}, mobile={mobile}, nickname={nickname or '(未设置)'}")
return user
async def run(args: argparse.Namespace) -> None:
"""主执行逻辑。"""
try:
await create_user(
mobile=args.mobile,
nickname=args.nickname,
device_id=args.device_id,
device_name=args.device_name,
source=args.source,
)
finally:
await close_db()
def main() -> None:
parser = argparse.ArgumentParser(description="直接操作数据库创建用户(绕过短信验证)")
parser.add_argument("--mobile", required=True, help="手机号")
parser.add_argument("--nickname", default=None, help="用户昵称")
parser.add_argument("--device-id", default=None, help="设备标识(可选)")
parser.add_argument("--device-name", default=None, help="设备名称(可选)")
parser.add_argument("--source", default="cli", help="注册来源(默认: cli")
args = parser.parse_args()
asyncio.run(run(args))
if __name__ == "__main__":
main()
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""
手动发布版本脚本
================
用法:
python scripts/publish_release.py \
--version 1.6.0 \
--notes "修复视频导出崩溃\n优化启动速度" \
--bundle-dir ../tauri-app/src-tauri/target/release/bundle
流程:
1. 扫描 bundle 目录,找到 .app.tar.gz / .exe / .AppImage 和对应的 .sig 文件
2. 上传安装包到七牛云
3. 读取 .sig 文件内容
4. 调用后端 API 创建版本发布记录
依赖:
pip install qiniu httpx
"""
import argparse
import sys
from pathlib import Path
import httpx
def find_packages(bundle_dir: Path) -> list[dict]:
"""扫描构建产物目录,找到安装包和对应的签名文件"""
packages = []
# macOS: .app.tar.gz + .app.tar.gz.sig
for sig_file in bundle_dir.rglob("*.app.tar.gz.sig"):
pkg_file = sig_file.with_suffix("") # 去掉 .sig
if not pkg_file.exists():
print(f"警告: 签名文件存在但安装包缺失: {pkg_file}")
continue
# 解析文件名:{name}_{version}_{target}_{arch}.app.tar.gz
parts = pkg_file.stem.split("_")
# 最后一个部分可能是 arch,需要处理
arch = "aarch64" if "aarch64" in pkg_file.name or "arm64" in pkg_file.name else "x86_64"
signature = sig_file.read_text().strip()
packages.append({
"platform": "darwin",
"architecture": arch,
"filename": pkg_file.name,
"local_path": str(pkg_file),
"signature": signature,
"file_size": pkg_file.stat().st_size,
})
# Windows: .exe + .exe.sig
for sig_file in bundle_dir.rglob("*.exe.sig"):
pkg_file = sig_file.with_suffix("")
if not pkg_file.exists():
print(f"警告: 签名文件存在但安装包缺失: {pkg_file}")
continue
arch = "x86_64" # Windows 目前只打 x86_64
signature = sig_file.read_text().strip()
packages.append({
"platform": "windows",
"architecture": arch,
"filename": pkg_file.name,
"local_path": str(pkg_file),
"signature": signature,
"file_size": pkg_file.stat().st_size,
})
# Linux: .AppImage + .AppImage.sig
for sig_file in bundle_dir.rglob("*.AppImage.sig"):
pkg_file = sig_file.with_suffix("")
if not pkg_file.exists():
print(f"警告: 签名文件存在但安装包缺失: {pkg_file}")
continue
arch = "x86_64" # Linux 目前只打 x86_64
signature = sig_file.read_text().strip()
packages.append({
"platform": "linux",
"architecture": arch,
"filename": pkg_file.name,
"local_path": str(pkg_file),
"signature": signature,
"file_size": pkg_file.stat().st_size,
})
return packages
def upload_to_qiniu(local_path: str, key: str) -> str:
"""上传文件到七牛云,返回访问 URL"""
# 这里使用七牛云 SDK,需要配置环境变量
# 复用现有七牛云密钥和 media bucket
import os
from qiniu import Auth, put_file
access_key = os.environ["QINIU_ACCESS_KEY"]
secret_key = os.environ["QINIU_SECRET_KEY"]
bucket_name = os.environ["QINIU_VIDEO_BUCKET"]
domain = os.environ["QINIU_VIDEO_DOMAIN"]
auth = Auth(access_key, secret_key)
token = auth.upload_token(bucket_name, key, 3600)
ret, info = put_file(token, key, local_path, version="v2")
if ret is None:
raise RuntimeError(f"上传失败: {info}")
return f"https://{domain}/{key}"
def create_release(api_base_url: str, version: str, notes: str, mandatory: bool, packages: list[dict]):
"""调用后端 API 创建版本发布记录"""
url = f"{api_base_url}/api/v1/update/releases"
payload = {
"version": version,
"notes": notes,
"mandatory": mandatory,
"packages": packages,
}
response = httpx.post(url, json=payload, timeout=30)
response.raise_for_status()
return response.json()
def main():
parser = argparse.ArgumentParser(description="手动发布应用版本")
parser.add_argument("--version", required=True, help="版本号,如 1.6.0")
parser.add_argument("--notes", default="", help="更新说明(支持 \\n 换行)")
parser.add_argument("--mandatory", action="store_true", help="强制更新")
parser.add_argument("--bundle-dir", required=True, help="Tauri 构建产物目录")
parser.add_argument("--api-url", default="https://dev.tapi.meijiaka.cn", help="后端 API 地址")
parser.add_argument("--skip-upload", action="store_true", help="跳过七牛云上传(包已上传)")
parser.add_argument("--base-url", default="", help="安装包基础 URL(跳过上传时使用)")
args = parser.parse_args()
bundle_dir = Path(args.bundle_dir)
if not bundle_dir.exists():
print(f"错误: 构建目录不存在: {bundle_dir}")
sys.exit(1)
# 1. 扫描构建产物
print(f"扫描构建目录: {bundle_dir}")
packages = find_packages(bundle_dir)
if not packages:
print("错误: 未找到任何安装包或签名文件")
print("请确认已运行: export TAURI_SIGNING_PRIVATE_KEY=... && npm run tauri build")
sys.exit(1)
print(f"发现 {len(packages)} 个平台包:")
for p in packages:
print(f" - {p['platform']}-{p['architecture']}: {p['filename']} ({p['file_size'] / 1024 / 1024:.1f} MB)")
# 2. 上传到七牛云(或构造 URL)
for p in packages:
if args.skip_upload:
if not args.base_url:
print("错误: --skip-upload 时必须提供 --base-url")
sys.exit(1)
p["file_url"] = f"{args.base_url.rstrip('/')}/{p['filename']}"
else:
key = f"releases/{args.version}/{p['filename']}"
print(f"上传 {p['filename']} 到七牛云...")
p["file_url"] = upload_to_qiniu(p["local_path"], key)
# 删除临时字段
del p["local_path"]
# 3. 创建版本发布记录
print(f"创建版本发布记录: {args.version}")
try:
result = create_release(
api_base_url=args.api_url,
version=args.version,
notes=args.notes.replace("\\n", "\n"),
mandatory=args.mandatory,
packages=packages,
)
print(f"发布成功!ID: {result['id']}")
print(f"更新接口: {args.api_url}/api/v1/update/check?version={{current_version}}&target={{target}}&arch={{arch}}")
except httpx.HTTPStatusError as e:
print(f"发布失败: {e.response.status_code} - {e.response.text}")
sys.exit(1)
if __name__ == "__main__":
main()
+3 -4
View File
@@ -92,16 +92,15 @@ def clean_source_icon(source: Image.Image) -> Image.Image:
def _prepare_m_icon(source: Image.Image, plate_size: int) -> Image.Image:
"""裁剪 M 的透明边距,并缩放到合适的视觉比例"""
"""裁剪 M 的透明边距,宽度充满底板,保持完整形状(不裁剪)"""
bbox = source.getbbox()
if bbox:
m_cropped = source.crop(bbox)
else:
m_cropped = source
# 参考 macOS 有底板图标(Xcode、系统设置等),内容占底板约 65%
target_size = int(plate_size * 0.65)
m_cropped.thumbnail((target_size, target_size), Image.LANCZOS)
# 保持宽高比,宽度充满底板,高度自然留白
m_cropped.thumbnail((plate_size, plate_size), Image.LANCZOS)
return m_cropped
-275
View File
@@ -1,275 +0,0 @@
#!/usr/bin/env python3
"""
视频片段替换 MVP
================
基于音频文字内容,用人物视频的对应片段替换空镜视频的对应片段。
前置依赖:
pip install openai-whisper
用法示例:
python scripts/video-replace-mvp.py \
--person person.mp4 \
--broll broll.mp4 \
--query "水电改造要注意"
原理:
1. Whisper 识别人物视频音频 → 输出带时间戳的文案
2. 文本匹配找到目标时间段 [start, end]
3. FFmpeg overlay 滤镜:在 [start, end] 区间用人物画面覆盖空镜画面
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
from difflib import SequenceMatcher
from pathlib import Path
def check_dep(name: str) -> str | None:
"""检查系统命令是否存在"""
path = shutil.which(name)
return path
def ensure_whisper():
"""确保 whisper 可用"""
try:
import whisper # noqa: F401
return True
except ImportError:
print("❌ 未安装 openai-whisper")
print(" 安装命令:pip install openai-whisper")
print(" (首次会自动下载模型,base 模型约 150MB)")
return False
def run_whisper(video_path: str, model: str = "base") -> list[dict]:
"""Whisper 识别,返回 segment 列表(含 start/end/text"""
import whisper
print(f" 加载模型:{model}")
model_obj = whisper.load_model(model)
print(f" 识别中...(模型:{model},视频:{Path(video_path).name}")
result = model_obj.transcribe(
video_path,
language="zh",
word_timestamps=False, # segment 级别够用了
fp16=False, # CPU 友好
)
return result["segments"]
def find_time_range(
segments: list[dict],
query: str,
threshold: float = 0.6,
) -> tuple[float, float, str] | None:
"""
根据查询文字匹配时间段
匹配策略(优先级递减):
1. 精确子串匹配
2. 模糊匹配(最长公共子序列相似度 ≥ threshold)
"""
query = query.strip()
# 1. 精确子串匹配
for seg in segments:
text = seg["text"].strip()
if query in text:
return seg["start"], seg["end"], text
# 2. 模糊匹配
best = None
best_score = 0.0
for seg in segments:
text = seg["text"].strip()
score = SequenceMatcher(None, query, text).ratio()
if score > best_score and score >= threshold:
best_score = score
best = seg
if best:
return best["start"], best["end"], best["text"].strip()
return None
def get_video_info(video_path: str) -> dict:
"""ffprobe 获取视频信息"""
cmd = [
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height,r_frame_rate,duration",
"-show_entries", "format=duration",
"-of", "json",
video_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
data = json.loads(result.stdout)
stream = data.get("streams", [{}])[0]
fmt = data.get("format", {})
# 解析帧率(如 "25/1" → 25.0
fps_str = stream.get("r_frame_rate", "25/1")
if "/" in fps_str:
num, den = fps_str.split("/")
fps = float(num) / float(den)
else:
fps = float(fps_str)
return {
"width": stream.get("width", 1920),
"height": stream.get("height", 1080),
"fps": fps,
"duration": float(fmt.get("duration", stream.get("duration", 0))),
}
def replace_with_overlay(
person_video: str,
broll_video: str,
start: float,
end: float,
output: str,
crf: int = 18,
):
"""
用 FFmpeg overlay 滤镜替换片段
逻辑:
- 输入0 (broll):底图 + 音频
- 输入1 (person):被截取的画面片段
- [1:v] trim → 截取 [start, end] → setpts 归零 → scale 适配分辨率
- [0:v][clip] overlay → 在 between(t,start,end) 时显示 clip
- 输出:画面 = 替换后的视频,音频 = 原 broll 音频
"""
duration = end - start
broll_info = get_video_info(broll_video)
w, h = broll_info["width"], broll_info["height"]
print(f" 空镜分辨率:{w}x{h}, 帧率:{broll_info['fps']:.2f}fps")
print(f" 截取人物片段:{start:.3f}s ~ {end:.3f}s{duration:.3f}s")
print(f" 正在渲染...CRF={crf}")
# overlay filter
# 注意:between(t,start,end) 中的逗号需要转义
filter_graph = (
f"[1:v]trim=start={start}:end={end},"
f"setpts=PTS-STARTPTS,"
f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:black[clip];"
f"[0:v][clip]overlay="
f"enable='between(t\\,{start}\\,{end})':"
f"x=(W-w)/2:y=(H-h)/2[v]"
)
cmd = [
"ffmpeg", "-y",
"-i", broll_video,
"-i", person_video,
"-filter_complex", filter_graph,
"-map", "[v]",
"-map", "0:a",
"-c:v", "libx264", "-crf", str(crf), "-preset", "fast",
"-c:a", "copy",
"-movflags", "+faststart",
output,
]
subprocess.run(cmd, check=True, capture_output=True)
print(f"✅ 输出完成:{output}")
def save_srt(segments: list[dict], path: str):
"""保存 SRT 字幕供人工校对"""
def fmt(s: float) -> str:
h = int(s // 3600)
m = int((s % 3600) // 60)
sec = int(s % 60)
ms = int((s % 1) * 1000)
return f"{h:02d}:{m:02d}:{sec:02d},{ms:03d}"
with open(path, "w", encoding="utf-8") as f:
for i, seg in enumerate(segments, 1):
f.write(f"{i}\n{fmt(seg['start'])} --> {fmt(seg['end'])}\n{seg['text'].strip()}\n\n")
def main():
parser = argparse.ArgumentParser(description="基于音频文字的视频片段替换 MVP")
parser.add_argument("--person", required=True, help="人物出镜视频路径(提供画面)")
parser.add_argument("--broll", required=True, help="空镜视频路径(提供底图+音频)")
parser.add_argument("--query", required=True, help="要替换的文案(如:水电改造要注意)")
parser.add_argument("--output", default="output_replaced.mp4", help="输出文件路径")
parser.add_argument("--model", default="base", choices=["tiny", "base", "small"],
help="Whisper 模型,tiny 最快,small 最准")
parser.add_argument("--crf", type=int, default=18, help="视频质量(0=无损,23=默认,越大越小)")
parser.add_argument("--threshold", type=float, default=0.6,
help="模糊匹配阈值(0~1),低于此值视为未匹配")
args = parser.parse_args()
# 0. 依赖检查
if not check_dep("ffmpeg"):
print("❌ 未找到 ffmpeg")
sys.exit(1)
if not check_dep("ffprobe"):
print("❌ 未找到 ffprobe")
sys.exit(1)
if not ensure_whisper():
sys.exit(1)
for p in (args.person, args.broll):
if not Path(p).exists():
print(f"❌ 文件不存在:{p}")
sys.exit(1)
# 1. ASR 识别人物视频
print(f"\n🎙️ Step 1/3:识别人物视频音频")
segments = run_whisper(args.person, args.model)
print(f" 识别到 {len(segments)} 句话")
# 保存字幕供参考
srt_path = str(Path(args.output).with_suffix(".srt"))
save_srt(segments, srt_path)
print(f"📝 字幕已保存:{srt_path}")
# 2. 文本匹配
print(f"\n🔍 Step 2/3:查找文案「{args.query}")
result = find_time_range(segments, args.query, threshold=args.threshold)
if not result:
print(f"❌ 未找到匹配文案(阈值 {args.threshold}")
print(f" 建议:查看 {srt_path} 里的实际文案,调整 --query 内容")
sys.exit(1)
start, end, matched_text = result
print(f" 匹配文案:「{matched_text}")
print(f" 时间段: {start:.3f}s ~ {end:.3f}s(时长 {end - start:.3f}s")
# 3. FFmpeg 替换
print(f"\n🎬 Step 3/3:替换片段")
replace_with_overlay(
args.person,
args.broll,
start,
end,
args.output,
crf=args.crf,
)
print(f"\n🎉 全部完成!")
print(f" 输出文件:{args.output}")
print(f" 字幕参考:{srt_path}")
if __name__ == "__main__":
main()
+20
View File
@@ -14,6 +14,8 @@
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"date-fns": "^4.1.0",
"fabric": "^7.3.1",
"immer": "^11.1.4",
@@ -2053,6 +2055,24 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-process": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-updater": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz",
"integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+2
View File
@@ -25,6 +25,8 @@
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"date-fns": "^4.1.0",
"fabric": "^7.3.1",
"immer": "^11.1.4",
+194
View File
@@ -47,6 +47,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -680,6 +689,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -992,6 +1012,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -2209,6 +2239,12 @@ dependencies = [
"unicase",
]
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2433,6 +2469,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.11.1",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2559,6 +2607,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -3236,15 +3298,20 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3329,12 +3396,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
@@ -3344,6 +3424,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.12"
@@ -4041,6 +4148,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tar"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4115,7 +4233,9 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-updater",
"thiserror 1.0.69",
"tokio",
"uuid",
@@ -4265,6 +4385,16 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.3.5"
@@ -4286,6 +4416,39 @@ dependencies = [
"tokio",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs 6.0.0",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.2",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-runtime"
version = "2.10.1"
@@ -5155,6 +5318,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -5904,6 +6076,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.2"
@@ -6068,6 +6250,18 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.0",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
+2
View File
@@ -23,6 +23,8 @@ tauri-plugin-opener = "2"
tauri-plugin-shell = "2"
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "5"
-108
View File
@@ -1,108 +0,0 @@
# Rust 依赖重复分析报告
## 📊 当前状态
- **重复条目数**: 54
- **直接依赖**: 9 个
- **传递依赖重复**: 45 个
## 🔴 需要关注的重复(版本冲突)
| 依赖 | 版本 A | 版本 B | 影响 | 解决方案 |
|------|--------|--------|------|---------|
| **base64** | 0.21.7 | 0.22.1 | ⚠️ 中等 | Tauri 内部锁定 0.21,无法避免 |
| **bitflags** | 1.3.2 | 2.11.0 | ⚠️ 中等 | png/selectors 使用旧版,无法避免 |
| **thiserror** | 1.0.69 | 2.0.18 | ⚠️ 低 | 旧 crate 未升级,可等待更新 |
| **syn** | 1.0.109 | 2.0.117 | ⚠️ 低 | 过程宏依赖,编译时 only |
| **phf** | 0.8/0.10/0.11 | 多版本 | ⚠️ 低 | 深度传递依赖,无法避免 |
| **rand** | 0.7.3 | 0.8.5 | ⚠️ 低 | 旧 crate 依赖,无法避免 |
| **indexmap** | 1.9.3 | 2.13.0 | ⚠️ 低 | 旧 crate 依赖,无法避免 |
## ✅ 同一版本多处引用(正常,非问题)
以下依赖虽然显示"重复",但其实是**同一版本被多处引用**,不是真正的版本冲突:
- `time v0.3.47` (2 处引用)
- `serde v1.0.228` (2 处引用)
- `serde_json v1.0.149` (2 处引用)
- `semver v1.0.27` (2 处引用)
- `smallvec v1.15.1` (2 处引用)
- `scopeguard v1.2.0` (2 处引用)
- `rand v0.8.5` (2 处引用)
这些都是正常的依赖树结构,无需处理。
## 🔧 优化建议
### 1. 已完成的优化
- [x] 更新 Cargo.lock`cargo update`
- [x] 锁定 base64 版本(选择 0.22
### 2. 可考虑的优化
#### 方案 A: 使用 `[patch]` 强制统一(不推荐)
```toml
[patch.crates-io]
# 强制所有依赖使用 base64 0.22
base64 = "0.22"
```
⚠️ **风险**: 可能导致编译错误或运行时问题,因为 Tauri 内部代码是为 0.21 编写的。
#### 方案 B: 等待上游更新(推荐)
- Tauri 未来版本可能会更新 swift-rs 到使用 base64 0.22
- png crate 未来可能会升级到 bitflags 2.x
#### 方案 C: 禁用未使用的功能(已实施)
检查 `Cargo.toml`,确保启用的功能是最小化的:
```toml
[dependencies]
# 只启用需要的功能
reqwest = { version = "0.12", features = ["json"] } # ✅ 正确,只启用 json
```
## 📈 影响评估
| 指标 | 当前值 | 评估 |
|------|--------|------|
| 编译时间 | 正常 | 重复依赖对编译时间影响 < 5% |
| 二进制大小 | 正常 | base64 0.21 + 0.22 ≈ +50KB |
| 运行时性能 | 无影响 | 只是代码重复,运行时只使用一个版本 |
| 安全风险 | 低 | base64 和 bitflags 都是成熟库,两个版本都安全 |
## 🎯 结论
**当前依赖状态**: ✅ **可接受**
虽然存在 54 个"重复"条目,但:
1. 大部分是同一版本的多处引用(非真正重复)
2. 真正的版本冲突(如 base64 0.21/0.22)是传递依赖导致,无法避免
3. 对编译时间和二进制大小影响很小
4. 不影响运行时性能
**建议**: 保持现状,等待上游 crate 更新自然解决。
## 📝 维护命令
```bash
# 检查依赖重复
cargo tree --duplicates
# 更新依赖
cargo update
# 查看依赖树
cargo tree
# 检查是否有未使用的依赖
cargo +nightly udeps # 需要安装 cargo-udeps
```
---
*生成时间*: 2026-04-07
*Cargo.toml 版本*: 0.1.0
-159
View File
@@ -1,159 +0,0 @@
# Rust 依赖优化报告
## 📊 优化前后对比
### 直接依赖变更
| 依赖 | 优化前 | 优化后 | 变更 |
|------|--------|--------|------|
| rand | 0.8 | ❌ 移除 | 未使用 |
| base64 | 0.22 | 0.22 | 保留 |
| reqwest | 0.12 + default | 0.12 + json | 精简 |
| uuid | 1.x + v4 | 1.x + v4 | 保留 |
**直接依赖从 9 个减少到 8 个**
### 传递依赖影响
| 指标 | 优化前 | 预计优化后 |
|------|--------|-----------|
| 总 crate 数 | ~376 | ~360 (-16) |
| 编译单元 | 较多 | 减少 |
| 二进制大小 | 基准 | -50KB~100KB |
---
## 🔍 详细分析
### 1. 已移除: `rand`
**原因**:
```bash
$ grep -rn "rand::" src/
# 无输出 - 代码中未直接使用
```
`uuid` crate 内部已经包含了 v4 UUID 生成所需的随机数功能,无需额外依赖 `rand`
**影响**:
- 移除 `rand` 及其传递依赖 (`rand_core`, `rand_chacha` 等)
- 减少约 5-10 个间接依赖
---
### 2. 检查: `reqwest` features
当前配置:
```toml
reqwest = { version = "0.12", features = ["json"] }
```
Default features 包含:
- `default-tls` - 需要(HTTPS 支持)
- `charset` - 需要(编码处理)
- `http2` - 可能需要
- `macos-system-configuration` - macOS 需要
**结论**: 当前配置已是最小化,无需进一步精简。
---
### 3. 无法避免的重复
以下重复由 Tauri 生态决定,**无法优化**:
| 依赖 | 版本冲突 | 原因 |
|------|---------|------|
| base64 | 0.21 vs 0.22 | swift-rs (Tauri 内部) 锁定 0.21 |
| bitflags | 1.3 vs 2.11 | png/selectors 使用旧版 |
| phf | 0.8/0.10/0.11 | 深度传递依赖 |
| thiserror | 1.0 vs 2.0 | 新旧 crate 混用 |
---
## 📈 性能影响
### 编译时间
- **Debug**: 预计减少 5-10 秒
- **Release**: 预计减少 10-20 秒
### 二进制大小
- **预估减少**: 100-200KB
- **主要来源**: 移除 rand 相关代码
---
## ✅ 验证清单
```bash
# 1. 检查代码编译
cargo check
cargo check --release
# 2. 检查测试通过
cargo test
# 3. 检查依赖树
cargo tree --duplicates | wc -l # 应该减少
# 4. 检查未使用依赖(可选)
cargo +nightly udeps
```
---
## 🎯 结论
### 是否达到最佳状态?
**是的,当前已达到实际最佳状态。**
理由:
1. ✅ 移除了未使用的 `rand`
2.`reqwest` 功能已最小化
3. ✅ 其余依赖都是必需的
4. ❌ 剩余重复是 Tauri 生态限制,无法控制
### 进一步优化途径(不推荐)
1. **使用 `[patch]` 强制统一版本**
```toml
[patch.crates-io]
base64 = "0.22"
```
⚠️ 风险: 可能导致编译错误或运行时崩溃
2. **等待上游更新**
- Tauri 更新 swift-rs 到 base64 0.22
- png crate 更新到 bitflags 2.x
- 这需要社区推动,无法控制
3. **Fork 并修改依赖**
⚠️ 维护成本过高,不推荐
---
## 📝 维护建议
```bash
# 每月检查
$ cargo update # 更新依赖
$ cargo tree --duplicates # 检查重复
$ cargo check --release # 验证编译
# 每季度审查
$ cargo +nightly udeps # 检查未使用依赖
```
---
## 🔗 相关链接
- [Cargo 依赖优化指南](https://doc.rust-lang.org/cargo/reference/profiles.html)
- [Tauri 依赖说明](https://tauri.app/v1/guides/building/app-size/)
- [Rust 二进制大小优化](https://github.com/johnthagen/min-sized-rust)
---
*优化日期*: 2026-04-07
*Cargo.toml 版本*: 0.1.0 (已优化)
@@ -50,6 +50,7 @@
]
},
"dialog:default",
"dialog:allow-open"
"dialog:allow-open",
"updater:default"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 913 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 760 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

+2
View File
@@ -61,6 +61,8 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.invoke_handler(tauri::generate_handler![
storage::config::load_app_config,
storage::config::save_app_config,
+7 -1
View File
@@ -39,7 +39,13 @@
}
},
"plugins": {
"opener": {}
"opener": {},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQxMzg5NDcwQTVDMUY3MEIKUldRTDk4R2xjSlE0MFZPUzQwaDd4RzMwRHdsMWQzVy8wZGJndDVoYngwRlN6cU5wODgva215blcK",
"endpoints": [
"https://dev.tapi.meijiaka.cn/api/v1/update/check?version={{current_version}}&target={{target}}&arch={{arch}}"
]
}
},
"bundle": {
"active": true,
+4
View File
@@ -17,6 +17,7 @@ import Profile from './pages/Profile/Profile';
import UsageDetail from './pages/Profile/UsageDetail';
import ToastContainer from './components/Toast/ToastContainer';
import ProgressModal from './components/ProgressModal/ProgressModal';
import UpdateDialog from './components/UpdateDialog/UpdateDialog';
import { ErrorBoundary } from './components/ErrorBoundary';
import ConfirmModal from './components/Modal/ConfirmModal';
import { useAuthStore, useSettingsStore } from './store';
@@ -122,6 +123,9 @@ function App() {
{/* Toast 全局挂载 - 不受登录状态影响 */}
<ToastContainer />
{/* 自动更新检查对话框 - 全局挂载 */}
<UpdateDialog />
{/* 登录层 - 未登录时覆盖全屏 */}
{!isAuthenticated && <Login />}
+32 -1
View File
@@ -40,15 +40,46 @@ interface GenerateTitleResponse {
/**
* 脚本相关 API
*/
const CATEGORIES_CACHE_KEY = 'script-categories-v1';
export const scriptApi = {
/**
* 获取提示词分类列表
* 获取提示词分类列表(直接请求)
* GET /script/categories
*/
getCategories: async (): Promise<CategoryItem[]> => {
return client.get<CategoryItem[]>('/script/categories');
},
/**
* 获取提示词分类列表(带本地缓存 + 静默刷新)
*
* 使用方式:
* const { cached, refresh } = scriptApi.getCategoriesCached();
* if (cached) setCategories(cached); // 立即可见
* refresh.then(setCategories); // 后台更新
*/
getCategoriesCached: (): {
cached: CategoryItem[] | null;
refresh: Promise<CategoryItem[]>;
} => {
let cached: CategoryItem[] | null = null;
const raw = localStorage.getItem(CATEGORIES_CACHE_KEY);
if (raw) {
try {
const parsed = JSON.parse(raw);
cached = parsed.data;
} catch { /* 缓存损坏,忽略 */ }
}
const refresh = client.get<CategoryItem[]>('/script/categories').then(data => {
localStorage.setItem(CATEGORIES_CACHE_KEY, JSON.stringify({ data, ts: Date.now() }));
return data;
});
return { cached, refresh };
},
/**
* 智能生成标题
* POST /script/generate-title
@@ -6,6 +6,7 @@
.terms-tabs {
display: flex;
justify-content: center;
gap: 4px;
padding: 0 20px;
border-bottom: 1px solid var(--border-light);
@@ -0,0 +1,272 @@
/* 更新对话框 */
.update-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.update-dialog {
background: var(--bg-card, #ffffff);
border-radius: var(--radius-xl, 16px);
border: 1px solid var(--border-light, #e5e7eb);
box-shadow: 0 20px 60px -10px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 440px;
animation: slideUp 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* 头部 */
.update-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 20px 0;
}
.update-dialog-title {
font-size: var(--font-lg, 18px);
font-weight: 600;
color: var(--text-primary, #1f2937);
margin: 0;
}
.update-dialog-close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: var(--radius-md, 8px);
color: var(--text-tertiary, #9ca3af);
cursor: pointer;
transition: all 0.2s;
}
.update-dialog-close:hover {
background: var(--bg-hover, #f3f4f6);
color: var(--text-primary, #1f2937);
}
/* 内容 */
.update-dialog-body {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* 版本对比 */
.update-dialog-version {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--bg-input, #f9fafb);
border-radius: var(--radius-md, 8px);
font-size: var(--font-sm, 14px);
}
.update-dialog-current {
color: var(--text-secondary, #6b7280);
}
.update-dialog-arrow {
color: var(--primary, #10b981);
font-weight: 600;
}
.update-dialog-new {
color: var(--primary, #10b981);
font-weight: 600;
}
/* 更新说明 */
.update-dialog-notes-title {
font-size: var(--font-sm, 14px);
font-weight: 500;
color: var(--text-secondary, #6b7280);
margin-bottom: 6px;
}
.update-dialog-notes-content {
font-size: var(--font-sm, 14px);
color: var(--text-primary, #1f2937);
line-height: 1.7;
white-space: pre-wrap;
margin: 0;
padding: 10px 12px;
background: var(--bg-input, #f9fafb);
border-radius: var(--radius-md, 8px);
max-height: 160px;
overflow-y: auto;
}
/* 强制更新提示 */
.update-dialog-mandatory {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: var(--radius-md, 8px);
font-size: var(--font-sm, 14px);
color: #92400e;
}
.update-dialog-mandatory svg {
flex-shrink: 0;
color: #f59e0b;
}
/* 加载中 */
.update-dialog-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 0;
color: var(--text-secondary, #6b7280);
}
.update-dialog-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-light, #e5e7eb);
border-top-color: var(--primary, #10b981);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 进度条 */
.update-dialog-progress {
display: flex;
flex-direction: column;
gap: 8px;
}
.update-dialog-progress-bar {
height: 6px;
background: var(--border-light, #e5e7eb);
border-radius: 3px;
overflow: hidden;
}
.update-dialog-progress-fill {
height: 100%;
background: var(--primary, #10b981);
border-radius: 3px;
transition: width 0.3s ease;
}
.update-dialog-progress-info {
display: flex;
justify-content: space-between;
font-size: var(--font-xs, 12px);
color: var(--text-tertiary, #9ca3af);
}
/* 成功状态 */
.update-dialog-success {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 0;
color: var(--primary, #10b981);
}
.update-dialog-success p {
margin: 0;
font-size: var(--font-sm, 14px);
color: var(--text-primary, #1f2937);
}
.update-dialog-hint {
font-size: var(--font-xs, 12px) !important;
color: var(--text-tertiary, #9ca3af) !important;
}
/* 错误 */
.update-dialog-error {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: var(--radius-md, 8px);
font-size: var(--font-sm, 14px);
color: #dc2626;
}
/* 底部按钮 */
.update-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 0 20px 20px;
}
.update-dialog-btn {
padding: 10px 20px;
font-size: var(--font-sm, 14px);
font-weight: 500;
font-family: inherit;
border-radius: var(--radius-lg, 10px);
border: none;
cursor: pointer;
transition: all 0.2s;
}
.update-dialog-btn.primary {
background: var(--primary, #10b981);
color: white;
}
.update-dialog-btn.primary:hover {
opacity: 0.9;
}
.update-dialog-btn.secondary {
background: var(--bg-input, #f3f4f6);
color: var(--text-secondary, #6b7280);
border: 1px solid var(--border-light, #e5e7eb);
}
.update-dialog-btn.secondary:hover {
background: var(--bg-hover, #e5e7eb);
}
.update-dialog-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@@ -0,0 +1,191 @@
/**
* 更新对话框组件
* ==============
*
* 应用启动时自动检查更新,发现新版本后弹出此对话框。
* 支持强制更新(无法跳过)。
*/
import { useEffect } from 'react';
import { useUpdater } from '../../hooks/useUpdater';
import './UpdateDialog.css';
const CURRENT_VERSION = __APP_VERSION__;
export default function UpdateDialog() {
const {
hasUpdate,
updateInfo,
isMandatory,
checking,
downloading,
installing,
progress,
downloadedBytes,
totalBytes,
error,
check,
downloadAndInstall,
dismiss,
relaunch,
} = useUpdater();
// 应用启动时自动检查更新(延迟 3 秒,避免阻塞首屏)
useEffect(() => {
const timer = setTimeout(() => {
check();
}, 3000);
return () => clearTimeout(timer);
}, [check]);
if (!hasUpdate && !checking && !downloading && !installing && !error) {
return null;
}
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
};
const title = checking
? '正在检查更新...'
: downloading
? '正在下载更新...'
: installing
? '更新已就绪'
: `发现新版本 ${updateInfo?.version ?? ''}`;
return (
<div className="update-dialog-overlay">
<div className="update-dialog">
{/* 标题 */}
<div className="update-dialog-header">
<h3 className="update-dialog-title">{title}</h3>
{!isMandatory && !downloading && !installing && (
<button className="update-dialog-close" onClick={dismiss} aria-label="关闭">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
{/* 内容 */}
<div className="update-dialog-body">
{/* 检查中 */}
{checking && (
<div className="update-dialog-loading">
<div className="update-dialog-spinner" />
<p>...</p>
</div>
)}
{/* 发现更新 */}
{!checking && hasUpdate && updateInfo && (
<>
<div className="update-dialog-version">
<span className="update-dialog-current">: {CURRENT_VERSION}</span>
<span className="update-dialog-arrow"></span>
<span className="update-dialog-new">: {updateInfo.version}</span>
</div>
{updateInfo.body && (
<div className="update-dialog-notes">
<div className="update-dialog-notes-title"></div>
<pre className="update-dialog-notes-content">{updateInfo.body}</pre>
</div>
)}
{isMandatory && (
<div className="update-dialog-mandatory">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
使
</div>
)}
</>
)}
{/* 下载进度 */}
{downloading && (
<div className="update-dialog-progress">
<div className="update-dialog-progress-bar">
<div
className="update-dialog-progress-fill"
style={{ width: `${progress}%` }}
/>
</div>
<div className="update-dialog-progress-info">
<span>{progress}%</span>
{totalBytes > 0 && (
<span>
{formatBytes(downloadedBytes)} / {formatBytes(totalBytes)}
</span>
)}
</div>
</div>
)}
{/* 安装完成 */}
{installing && (
<div className="update-dialog-success">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<p></p>
<p className="update-dialog-hint">使</p>
</div>
)}
{/* 错误 */}
{error && (
<div className="update-dialog-error">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span>{error}</span>
</div>
)}
</div>
{/* 按钮 */}
<div className="update-dialog-footer">
{!checking && !downloading && !installing && (
<>
{!isMandatory && (
<button className="update-dialog-btn secondary" onClick={dismiss}>
</button>
)}
<button className="update-dialog-btn primary" onClick={downloadAndInstall}>
</button>
</>
)}
{downloading && (
<button className="update-dialog-btn secondary" disabled>
...
</button>
)}
{installing && (
<button className="update-dialog-btn primary" onClick={relaunch}>
</button>
)}
</div>
</div>
</div>
);
}
+200
View File
@@ -0,0 +1,200 @@
/**
* Tauri 自动更新 Hook
* ===================
*
* 封装 Tauri updater 插件的 check / download / install / relaunch 流程。
*/
import { useState, useCallback, useRef } from 'react';
import { check, type Update, type DownloadEvent } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
export interface UpdaterState {
/** 是否有可用更新 */
hasUpdate: boolean;
/** 更新信息 */
updateInfo: Update | null;
/** 是否强制更新 */
isMandatory: boolean;
/** 是否正在检查 */
checking: boolean;
/** 是否正在下载 */
downloading: boolean;
/** 是否正在安装 */
installing: boolean;
/** 下载进度(0-100 */
progress: number;
/** 已下载字节数 */
downloadedBytes: number;
/** 总字节数 */
totalBytes: number;
/** 错误信息 */
error: string | null;
}
export interface UpdaterActions {
/** 检查更新 */
check: () => Promise<void>;
/** 下载并安装 */
downloadAndInstall: () => Promise<void>;
/** 忽略本次更新 */
dismiss: () => void;
/** 重启应用 */
relaunch: () => Promise<void>;
}
/**
* 解析更新说明中的强制更新标记
* 如果 notes 中包含 [强制更新] 或 [mandatory],视为强制更新
*/
function parseMandatory(notes: string | undefined): boolean {
if (!notes) return false;
return notes.includes('[强制更新]') || notes.includes('[mandatory]');
}
export function useUpdater(): UpdaterState & UpdaterActions {
const [state, setState] = useState<UpdaterState>({
hasUpdate: false,
updateInfo: null,
isMandatory: false,
checking: false,
downloading: false,
installing: false,
progress: 0,
downloadedBytes: 0,
totalBytes: 0,
error: null,
});
// 用 ref 保存 update 实例,避免闭包问题
const updateRef = useRef<Update | null>(null);
const checkUpdate = useCallback(async () => {
setState(s => ({ ...s, checking: true, error: null }));
try {
const update = await check();
updateRef.current = update ?? null;
if (update) {
const mandatory = parseMandatory(update.body ?? undefined);
setState(s => ({
...s,
hasUpdate: true,
updateInfo: update,
isMandatory: mandatory,
checking: false,
}));
} else {
setState(s => ({
...s,
hasUpdate: false,
updateInfo: null,
isMandatory: false,
checking: false,
}));
}
} catch (err) {
console.error('[useUpdater] 检查更新失败:', err);
setState(s => ({
...s,
hasUpdate: false,
checking: false,
error: err instanceof Error ? err.message : '检查更新失败',
}));
}
}, []);
const downloadAndInstall = useCallback(async () => {
const update = updateRef.current;
if (!update) return;
setState(s => ({
...s,
downloading: true,
installing: false,
progress: 0,
downloadedBytes: 0,
totalBytes: 0,
error: null,
}));
try {
await update.downloadAndInstall((event: DownloadEvent) => {
switch (event.event) {
case 'Started':
setState(s => ({
...s,
totalBytes: event.data.contentLength ?? 0,
}));
break;
case 'Progress':
setState(s => {
const downloaded = s.downloadedBytes + event.data.chunkLength;
const total = s.totalBytes;
const progress = total > 0 ? Math.round((downloaded / total) * 100) : 0;
return {
...s,
downloadedBytes: downloaded,
progress: Math.min(progress, 100),
};
});
break;
case 'Finished':
setState(s => ({ ...s, progress: 100 }));
break;
}
});
setState(s => ({
...s,
downloading: false,
installing: true,
}));
} catch (err) {
console.error('[useUpdater] 下载安装失败:', err);
setState(s => ({
...s,
downloading: false,
installing: false,
error: err instanceof Error ? err.message : '下载安装失败',
}));
}
}, []);
const dismiss = useCallback(() => {
updateRef.current = null;
setState({
hasUpdate: false,
updateInfo: null,
isMandatory: false,
checking: false,
downloading: false,
installing: false,
progress: 0,
downloadedBytes: 0,
totalBytes: 0,
error: null,
});
}, []);
const doRelaunch = useCallback(async () => {
try {
await relaunch();
} catch (err) {
console.error('[useUpdater] 重启失败:', err);
setState(s => ({
...s,
error: err instanceof Error ? err.message : '重启失败',
}));
}
}, []);
return {
...state,
check: checkUpdate,
downloadAndInstall,
dismiss,
relaunch: doRelaunch,
};
}
@@ -1071,6 +1071,32 @@
color: var(--primary);
}
.draft-delete-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin-right: 8px;
padding: 0;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s, background 0.2s, color 0.2s;
}
.draft-list-item:hover .draft-delete-btn {
opacity: 1;
}
.draft-delete-btn:hover {
background: var(--bg-hover);
color: var(--danger);
}
.draft-meta {
display: flex;
align-items: center;
@@ -222,7 +222,7 @@ function ProductCard({ product, onDelete, onRename }: {
);
}
function DraftListItem({ draft, onClick, isEditing, editValue, onEdit, onSave, onChange }: {
function DraftListItem({ draft, onClick, isEditing, editValue, onEdit, onSave, onChange, onDelete }: {
draft: DraftItem;
onClick: (id: string) => void;
isEditing: boolean;
@@ -230,6 +230,7 @@ function DraftListItem({ draft, onClick, isEditing, editValue, onEdit, onSave, o
onEdit: (e: React.MouseEvent) => void;
onSave: () => void;
onChange: (value: string) => void;
onDelete: (e: React.MouseEvent) => void;
}) {
return (
<div className="draft-list-item" onClick={() => !isEditing && onClick(draft.id)}>
@@ -254,9 +255,14 @@ function DraftListItem({ draft, onClick, isEditing, editValue, onEdit, onSave, o
</div>
</div>
{!isEditing && (
<button className="draft-edit-btn" onClick={onEdit} title="重命名">
<svg width="16" height="16" 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="draft-edit-btn" onClick={onEdit} title="重命名">
<svg width="16" height="16" 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="draft-delete-btn" onClick={onDelete} title="删除">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" /></svg>
</button>
</>
)}
<svg className="draft-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 18 15 12 9 6"></polyline></svg>
</div>
@@ -271,6 +277,9 @@ export default function MyWorks() {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deletingProduct, setDeletingProduct] = useState<ProductItem | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteDraftModal, setShowDeleteDraftModal] = useState(false);
const [deletingDraft, setDeletingDraft] = useState<DraftItem | null>(null);
const [isDeletingDraft, setIsDeletingDraft] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [editingDraftId, setEditingDraftId] = useState<string | null>(null);
const [editDraftTitle, setEditDraftTitle] = useState('');
@@ -316,6 +325,26 @@ export default function MyWorks() {
setIsDeleting(false);
};
const openDeleteDraftModal = (e: React.MouseEvent, draft: DraftItem) => {
e.stopPropagation();
setDeletingDraft(draft);
setShowDeleteDraftModal(true);
};
const confirmDeleteDraft = async () => {
if (!deletingDraft) {return;}
setIsDeletingDraft(true);
try {
await localProjectApi.deleteProject(deletingDraft.id);
await loadDrafts();
} catch (err) {
console.error('[MyWorks] Failed to delete draft:', err);
}
setShowDeleteDraftModal(false);
setDeletingDraft(null);
setIsDeletingDraft(false);
};
const handleOpenDraft = async (draftId: string) => { await switchProject(draftId); navigate('video-creation'); };
const handleEditDraft = (e: React.MouseEvent, draft: DraftItem) => {
@@ -409,6 +438,7 @@ export default function MyWorks() {
onEdit={e => handleEditDraft(e, draft)}
onSave={saveDraftTitle}
onChange={setEditDraftTitle}
onDelete={e => openDeleteDraftModal(e, draft)}
/>
))}
</div>
@@ -435,6 +465,17 @@ export default function MyWorks() {
onConfirm={confirmDelete}
onCancel={() => !isDeleting && setShowDeleteModal(false)}
/>
<ConfirmModal
open={showDeleteDraftModal && !!deletingDraft}
type="danger"
title={deletingDraft ? <>稿 <strong>{deletingDraft.title}</strong> </> : ''}
description="此操作不可撤销,草稿及相关素材将从本地删除"
confirmText={isDeletingDraft ? '删除中...' : '确认删除'}
cancelText="取消"
confirmButtonType="danger"
onConfirm={confirmDeleteDraft}
onCancel={() => !isDeletingDraft && setShowDeleteDraftModal(false)}
/>
</div>
);
}
+152 -23
View File
@@ -1,17 +1,108 @@
/**
*
* ============
*
*
*/
import { useState } from 'react';
import { check, type Update, type DownloadEvent } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import '../ContentManagement/ContentManagement.css';
const CURRENT_VERSION = __APP_VERSION__;
export default function SystemUpdate() {
const [checking, setChecking] = useState(false);
const [checked, setChecked] = useState(false);
const [checkResult, setCheckResult] = useState<'none' | 'latest' | 'available'>('none');
const [updateInfo, setUpdateInfo] = useState<Update | null>(null);
const [downloading, setDownloading] = useState(false);
const [installing, setInstalling] = useState(false);
const [progress, setProgress] = useState(0);
const [downloadedBytes, setDownloadedBytes] = useState(0);
const [totalBytes, setTotalBytes] = useState(0);
const [error, setError] = useState<string | null>(null);
const handleCheck = () => {
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
};
const handleCheck = async () => {
setChecking(true);
setChecked(false);
setTimeout(() => {
setCheckResult('none');
setError(null);
setUpdateInfo(null);
try {
const update = await check();
if (update) {
setUpdateInfo(update);
setCheckResult('available');
} else {
setCheckResult('latest');
}
} catch (err) {
console.error('[SystemUpdate] 检查更新失败:', err);
setError(err instanceof Error ? err.message : '检查更新失败');
setCheckResult('none');
} finally {
setChecking(false);
setChecked(true);
}, 2000);
}
};
const handleDownloadAndInstall = async () => {
if (!updateInfo) return;
setDownloading(true);
setProgress(0);
setDownloadedBytes(0);
setTotalBytes(0);
setError(null);
// 用局部变量保存总大小,避免 Progress 回调里的闭包问题
let totalSize = 0;
try {
await updateInfo.downloadAndInstall((event: DownloadEvent) => {
switch (event.event) {
case 'Started':
totalSize = event.data.contentLength ?? 0;
setTotalBytes(totalSize);
break;
case 'Progress':
setDownloadedBytes(prev => {
const next = prev + event.data.chunkLength;
if (totalSize > 0) {
setProgress(Math.min(Math.round((next / totalSize) * 100), 100));
}
return next;
});
break;
case 'Finished':
setProgress(100);
break;
}
});
setDownloading(false);
setInstalling(true);
} catch (err) {
console.error('[SystemUpdate] 下载安装失败:', err);
setError(err instanceof Error ? err.message : '下载安装失败');
setDownloading(false);
}
};
const handleRelaunch = async () => {
try {
await relaunch();
} catch (err) {
setError(err instanceof Error ? err.message : '重启失败');
}
};
return (
@@ -19,42 +110,80 @@ export default function SystemUpdate() {
<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 1.0.2</span>
<span className="settings-row-value">v{CURRENT_VERSION}</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' }}>
{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' }}
>
<svg width="16" height="16" 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>
) : checked ? (
) : checkResult === 'latest' ? (
<span style={{ color: 'var(--success)', fontWeight: 500 }}></span>
) : checkResult === 'available' ? (
<span style={{ color: 'var(--primary)', fontWeight: 500 }}> {updateInfo?.version}</span>
) : (
<span style={{ color: 'var(--text-tertiary)' }}></span>
<span style={{ color: 'var(--text-tertiary)' }}>--</span>
)}
<button
className="btn btn-primary btn-sm"
onClick={handleCheck}
disabled={checking}
>
<button className="btn btn-primary btn-sm" 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)' }}>
{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>
)}
{/* 下载进度 */}
{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>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--font-xs)', color: 'var(--text-tertiary)', marginTop: 4 }}>
<span>{progress}%</span>
{totalBytes > 0 && <span>{formatBytes(downloadedBytes)} / {formatBytes(totalBytes)}</span>}
</div>
</div>
)}
{/* 按钮 */}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{installing ? (
<button className="btn btn-primary btn-sm" onClick={handleRelaunch}>
</button>
) : (
<button className="btn btn-primary btn-sm" onClick={handleDownloadAndInstall} disabled={downloading}>
{downloading ? '下载中...' : '立即更新'}
</button>
)}
</div>
</div>
)}
{/* 错误 */}
{error && (
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border-light)', background: '#fef2f2', color: '#dc2626', fontSize: 'var(--font-sm)' }}>
{error}
</div>
)}
</div>
</div>
</div>
@@ -33,11 +33,21 @@ export default function ScriptCreation() {
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [selectedSubcategory, setSelectedSubcategory] = useState<string>('');
// 加载分类列表
// 加载分类列表(带本地缓存 + 静默刷新)
useEffect(() => {
scriptApi.getCategories().then(setCategories).catch(() => {
toast.error('加载分类列表失败');
});
const { cached, refresh } = scriptApi.getCategoriesCached();
if (cached) {
setCategories(cached);
}
refresh
.then(data => {
setCategories(data);
})
.catch(() => {
if (!cached) {
toast.error('加载分类列表失败');
}
});
}, []);
// 编辑状态:存储正在编辑的字段键(如 "1-scene", "2-voiceover"
+2
View File
@@ -1 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
+9
View File
@@ -1,5 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { readFileSync } from "fs";
import { resolve } from "path";
// 读取 package.json 版本号,注入为全局常量
const pkg = JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf-8"));
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
@@ -17,6 +22,10 @@ export default defineConfig(async () => ({
format: "es",
},
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
server: {
port: 1420,
strictPort: true,