diff --git a/python-api/alembic/env.py b/python-api/alembic/env.py index 772192c..26b542f 100644 --- a/python-api/alembic/env.py +++ b/python-api/alembic/env.py @@ -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 diff --git a/python-api/alembic/versions/d0a7c5a375c6_add_app_update_tables.py b/python-api/alembic/versions/d0a7c5a375c6_add_app_update_tables.py new file mode 100644 index 0000000..17ab7a0 --- /dev/null +++ b/python-api/alembic/versions/d0a7c5a375c6_add_app_update_tables.py @@ -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 ### diff --git a/python-api/app/api/v1/router.py b/python-api/app/api/v1/router.py index 3ea1330..41e9ec0 100644 --- a/python-api/app/api/v1/router.py +++ b/python-api/app/api/v1/router.py @@ -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"]) diff --git a/python-api/app/api/v1/update.py b/python-api/app/api/v1/update.py new file mode 100644 index 0000000..db158a0 --- /dev/null +++ b/python-api/app/api/v1/update.py @@ -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} 已删除"} diff --git a/python-api/app/models/update.py b/python-api/app/models/update.py new file mode 100644 index 0000000..0a9c459 --- /dev/null +++ b/python-api/app/models/update.py @@ -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"), + ) diff --git a/python-api/app/schemas/update.py b/python-api/app/schemas/update.py new file mode 100644 index 0000000..e33b97c --- /dev/null +++ b/python-api/app/schemas/update.py @@ -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" + ) diff --git a/python-api/scripts/create_user.py b/python-api/scripts/create_user.py deleted file mode 100644 index 9572722..0000000 --- a/python-api/scripts/create_user.py +++ /dev/null @@ -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() diff --git a/python-api/scripts/publish_release.py b/python-api/scripts/publish_release.py new file mode 100644 index 0000000..b8e64d9 --- /dev/null +++ b/python-api/scripts/publish_release.py @@ -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() diff --git a/scripts/generate-rounded-icon.py b/scripts/generate-rounded-icon.py index 8420875..546e276 100644 --- a/scripts/generate-rounded-icon.py +++ b/scripts/generate-rounded-icon.py @@ -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 diff --git a/scripts/video-replace-mvp.py b/scripts/video-replace-mvp.py deleted file mode 100755 index df23e97..0000000 --- a/scripts/video-replace-mvp.py +++ /dev/null @@ -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() diff --git a/tauri-app/package-lock.json b/tauri-app/package-lock.json index d1abdac..56d4a44 100644 --- a/tauri-app/package-lock.json +++ b/tauri-app/package-lock.json @@ -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", diff --git a/tauri-app/package.json b/tauri-app/package.json index 8a4b7b8..5dfbfcc 100644 --- a/tauri-app/package.json +++ b/tauri-app/package.json @@ -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", diff --git a/tauri-app/src-tauri/Cargo.lock b/tauri-app/src-tauri/Cargo.lock index 5c22671..0b23433 100644 --- a/tauri-app/src-tauri/Cargo.lock +++ b/tauri-app/src-tauri/Cargo.lock @@ -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" diff --git a/tauri-app/src-tauri/Cargo.toml b/tauri-app/src-tauri/Cargo.toml index 1d4ad77..1205c20 100644 --- a/tauri-app/src-tauri/Cargo.toml +++ b/tauri-app/src-tauri/Cargo.toml @@ -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" diff --git a/tauri-app/src-tauri/DEPS_ANALYSIS.md b/tauri-app/src-tauri/DEPS_ANALYSIS.md deleted file mode 100644 index c481409..0000000 --- a/tauri-app/src-tauri/DEPS_ANALYSIS.md +++ /dev/null @@ -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 diff --git a/tauri-app/src-tauri/DEPS_OPTIMIZATION.md b/tauri-app/src-tauri/DEPS_OPTIMIZATION.md deleted file mode 100644 index 570a5b5..0000000 --- a/tauri-app/src-tauri/DEPS_OPTIMIZATION.md +++ /dev/null @@ -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 (已优化) diff --git a/tauri-app/src-tauri/capabilities/default.json b/tauri-app/src-tauri/capabilities/default.json index 0298486..2e6b7bf 100644 --- a/tauri-app/src-tauri/capabilities/default.json +++ b/tauri-app/src-tauri/capabilities/default.json @@ -50,6 +50,7 @@ ] }, "dialog:default", - "dialog:allow-open" + "dialog:allow-open", + "updater:default" ] } diff --git a/tauri-app/src-tauri/icons/128x128.png b/tauri-app/src-tauri/icons/128x128.png index 0b2f4a6..1f0ca8a 100644 Binary files a/tauri-app/src-tauri/icons/128x128.png and b/tauri-app/src-tauri/icons/128x128.png differ diff --git a/tauri-app/src-tauri/icons/128x128@2x.png b/tauri-app/src-tauri/icons/128x128@2x.png index fba24fd..cea57e2 100644 Binary files a/tauri-app/src-tauri/icons/128x128@2x.png and b/tauri-app/src-tauri/icons/128x128@2x.png differ diff --git a/tauri-app/src-tauri/icons/32x32.png b/tauri-app/src-tauri/icons/32x32.png index 91d4db0..90edce7 100644 Binary files a/tauri-app/src-tauri/icons/32x32.png and b/tauri-app/src-tauri/icons/32x32.png differ diff --git a/tauri-app/src-tauri/icons/64x64.png b/tauri-app/src-tauri/icons/64x64.png index f66c4b4..ad3ebda 100644 Binary files a/tauri-app/src-tauri/icons/64x64.png and b/tauri-app/src-tauri/icons/64x64.png differ diff --git a/tauri-app/src-tauri/icons/Square107x107Logo.png b/tauri-app/src-tauri/icons/Square107x107Logo.png index c53d0a1..8cc69a9 100644 Binary files a/tauri-app/src-tauri/icons/Square107x107Logo.png and b/tauri-app/src-tauri/icons/Square107x107Logo.png differ diff --git a/tauri-app/src-tauri/icons/Square142x142Logo.png b/tauri-app/src-tauri/icons/Square142x142Logo.png index 889ec0e..9030f27 100644 Binary files a/tauri-app/src-tauri/icons/Square142x142Logo.png and b/tauri-app/src-tauri/icons/Square142x142Logo.png differ diff --git a/tauri-app/src-tauri/icons/Square150x150Logo.png b/tauri-app/src-tauri/icons/Square150x150Logo.png index edb2d62..d5b1f2e 100644 Binary files a/tauri-app/src-tauri/icons/Square150x150Logo.png and b/tauri-app/src-tauri/icons/Square150x150Logo.png differ diff --git a/tauri-app/src-tauri/icons/Square284x284Logo.png b/tauri-app/src-tauri/icons/Square284x284Logo.png index 6a932fa..9704078 100644 Binary files a/tauri-app/src-tauri/icons/Square284x284Logo.png and b/tauri-app/src-tauri/icons/Square284x284Logo.png differ diff --git a/tauri-app/src-tauri/icons/Square30x30Logo.png b/tauri-app/src-tauri/icons/Square30x30Logo.png index 17107f6..78aef23 100644 Binary files a/tauri-app/src-tauri/icons/Square30x30Logo.png and b/tauri-app/src-tauri/icons/Square30x30Logo.png differ diff --git a/tauri-app/src-tauri/icons/Square310x310Logo.png b/tauri-app/src-tauri/icons/Square310x310Logo.png index adf9dea..738236b 100644 Binary files a/tauri-app/src-tauri/icons/Square310x310Logo.png and b/tauri-app/src-tauri/icons/Square310x310Logo.png differ diff --git a/tauri-app/src-tauri/icons/Square44x44Logo.png b/tauri-app/src-tauri/icons/Square44x44Logo.png index 29cb104..29ec5ad 100644 Binary files a/tauri-app/src-tauri/icons/Square44x44Logo.png and b/tauri-app/src-tauri/icons/Square44x44Logo.png differ diff --git a/tauri-app/src-tauri/icons/Square71x71Logo.png b/tauri-app/src-tauri/icons/Square71x71Logo.png index e4e0f99..41ac631 100644 Binary files a/tauri-app/src-tauri/icons/Square71x71Logo.png and b/tauri-app/src-tauri/icons/Square71x71Logo.png differ diff --git a/tauri-app/src-tauri/icons/Square89x89Logo.png b/tauri-app/src-tauri/icons/Square89x89Logo.png index a581cfa..c8450cd 100644 Binary files a/tauri-app/src-tauri/icons/Square89x89Logo.png and b/tauri-app/src-tauri/icons/Square89x89Logo.png differ diff --git a/tauri-app/src-tauri/icons/StoreLogo.png b/tauri-app/src-tauri/icons/StoreLogo.png index c7699ff..27d987d 100644 Binary files a/tauri-app/src-tauri/icons/StoreLogo.png and b/tauri-app/src-tauri/icons/StoreLogo.png differ diff --git a/tauri-app/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/tauri-app/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png index 8f0b2ce..592300e 100644 Binary files a/tauri-app/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/tauri-app/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/tauri-app/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/tauri-app/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png index 40c915a..f3c6744 100644 Binary files a/tauri-app/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/tauri-app/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/tauri-app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/tauri-app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png index 32381dd..ee68e1c 100644 Binary files a/tauri-app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/tauri-app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/tauri-app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/tauri-app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png index f78030e..10e93a2 100644 Binary files a/tauri-app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/tauri-app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/tauri-app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/tauri-app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png index 494ca81..6b5d175 100644 Binary files a/tauri-app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/tauri-app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/tauri-app/src-tauri/icons/icon.icns b/tauri-app/src-tauri/icons/icon.icns index e8a1f17..789ac93 100644 Binary files a/tauri-app/src-tauri/icons/icon.icns and b/tauri-app/src-tauri/icons/icon.icns differ diff --git a/tauri-app/src-tauri/icons/icon.ico b/tauri-app/src-tauri/icons/icon.ico index 16aecdf..7b9d8c7 100644 Binary files a/tauri-app/src-tauri/icons/icon.ico and b/tauri-app/src-tauri/icons/icon.ico differ diff --git a/tauri-app/src-tauri/icons/icon.png b/tauri-app/src-tauri/icons/icon.png index 8a17d1e..3125642 100644 Binary files a/tauri-app/src-tauri/icons/icon.png and b/tauri-app/src-tauri/icons/icon.png differ diff --git a/tauri-app/src-tauri/icons/ios/AppIcon-20x20@1x.png b/tauri-app/src-tauri/icons/ios/AppIcon-20x20@1x.png index 82f6012..0602858 100644 Binary files a/tauri-app/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/tauri-app/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/tauri-app/src-tauri/icons/ios/AppIcon-20x20@2x.png b/tauri-app/src-tauri/icons/ios/AppIcon-20x20@2x.png index 2e5a9c2..66020ec 100644 Binary files a/tauri-app/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/tauri-app/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/tauri-app/src-tauri/icons/ios/AppIcon-20x20@3x.png b/tauri-app/src-tauri/icons/ios/AppIcon-20x20@3x.png index 9df33d2..1a46508 100644 Binary files a/tauri-app/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/tauri-app/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/tauri-app/src-tauri/icons/ios/AppIcon-29x29@1x.png b/tauri-app/src-tauri/icons/ios/AppIcon-29x29@1x.png index 84adbb1..78b5950 100644 Binary files a/tauri-app/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/tauri-app/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/tauri-app/src-tauri/icons/ios/AppIcon-29x29@2x.png b/tauri-app/src-tauri/icons/ios/AppIcon-29x29@2x.png index 816c1b4..a8b1c3f 100644 Binary files a/tauri-app/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/tauri-app/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/tauri-app/src-tauri/icons/ios/AppIcon-29x29@3x.png b/tauri-app/src-tauri/icons/ios/AppIcon-29x29@3x.png index ecade91..1c69899 100644 Binary files a/tauri-app/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/tauri-app/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/tauri-app/src-tauri/icons/ios/AppIcon-40x40@1x.png b/tauri-app/src-tauri/icons/ios/AppIcon-40x40@1x.png index 2e5a9c2..66020ec 100644 Binary files a/tauri-app/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/tauri-app/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/tauri-app/src-tauri/icons/ios/AppIcon-40x40@2x.png b/tauri-app/src-tauri/icons/ios/AppIcon-40x40@2x.png index 7c57b08..1eb0436 100644 Binary files a/tauri-app/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/tauri-app/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/tauri-app/src-tauri/icons/ios/AppIcon-40x40@3x.png b/tauri-app/src-tauri/icons/ios/AppIcon-40x40@3x.png index 341d345..cbfc19e 100644 Binary files a/tauri-app/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/tauri-app/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/tauri-app/src-tauri/icons/ios/AppIcon-512x512@1x.png b/tauri-app/src-tauri/icons/ios/AppIcon-512x512@1x.png index 8a17d1e..3125642 100644 Binary files a/tauri-app/src-tauri/icons/ios/AppIcon-512x512@1x.png and b/tauri-app/src-tauri/icons/ios/AppIcon-512x512@1x.png differ diff --git a/tauri-app/src-tauri/icons/ios/AppIcon-60x60@2x.png b/tauri-app/src-tauri/icons/ios/AppIcon-60x60@2x.png index 341d345..cbfc19e 100644 Binary files a/tauri-app/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/tauri-app/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/tauri-app/src-tauri/icons/ios/AppIcon-60x60@3x.png b/tauri-app/src-tauri/icons/ios/AppIcon-60x60@3x.png index 445ba68..5578f78 100644 Binary files a/tauri-app/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/tauri-app/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/tauri-app/src-tauri/icons/ios/AppIcon-76x76@1x.png b/tauri-app/src-tauri/icons/ios/AppIcon-76x76@1x.png index d7c7e8c..c7b560b 100644 Binary files a/tauri-app/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/tauri-app/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/tauri-app/src-tauri/icons/ios/AppIcon-76x76@2x.png b/tauri-app/src-tauri/icons/ios/AppIcon-76x76@2x.png index ff6b9ff..7ef7a43 100644 Binary files a/tauri-app/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/tauri-app/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/tauri-app/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/tauri-app/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png index ceb7c61..7903217 100644 Binary files a/tauri-app/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/tauri-app/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/tauri-app/src-tauri/icons/ios/AppIcon-Notification@3x.png b/tauri-app/src-tauri/icons/ios/AppIcon-Notification@3x.png index 9df33d2..1a46508 100644 Binary files a/tauri-app/src-tauri/icons/ios/AppIcon-Notification@3x.png and b/tauri-app/src-tauri/icons/ios/AppIcon-Notification@3x.png differ diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index f5cd402..7cc63f6 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -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, diff --git a/tauri-app/src-tauri/tauri.conf.json b/tauri-app/src-tauri/tauri.conf.json index 41fccda..901ae87 100644 --- a/tauri-app/src-tauri/tauri.conf.json +++ b/tauri-app/src-tauri/tauri.conf.json @@ -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, diff --git a/tauri-app/src/App.tsx b/tauri-app/src/App.tsx index 1783bb8..a87c0bd 100644 --- a/tauri-app/src/App.tsx +++ b/tauri-app/src/App.tsx @@ -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 全局挂载 - 不受登录状态影响 */} + {/* 自动更新检查对话框 - 全局挂载 */} + + {/* 登录层 - 未登录时覆盖全屏 */} {!isAuthenticated && } diff --git a/tauri-app/src/api/modules/script.ts b/tauri-app/src/api/modules/script.ts index 1cd3edc..24b4ad7 100644 --- a/tauri-app/src/api/modules/script.ts +++ b/tauri-app/src/api/modules/script.ts @@ -40,15 +40,46 @@ interface GenerateTitleResponse { /** * 脚本相关 API */ +const CATEGORIES_CACHE_KEY = 'script-categories-v1'; + export const scriptApi = { /** - * 获取提示词分类列表 + * 获取提示词分类列表(直接请求) * GET /script/categories */ getCategories: async (): Promise => { return client.get('/script/categories'); }, + /** + * 获取提示词分类列表(带本地缓存 + 静默刷新) + * + * 使用方式: + * const { cached, refresh } = scriptApi.getCategoriesCached(); + * if (cached) setCategories(cached); // 立即可见 + * refresh.then(setCategories); // 后台更新 + */ + getCategoriesCached: (): { + cached: CategoryItem[] | null; + refresh: Promise; + } => { + 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('/script/categories').then(data => { + localStorage.setItem(CATEGORIES_CACHE_KEY, JSON.stringify({ data, ts: Date.now() })); + return data; + }); + + return { cached, refresh }; + }, + /** * 智能生成标题 * POST /script/generate-title diff --git a/tauri-app/src/components/Modal/TermsModal.css b/tauri-app/src/components/Modal/TermsModal.css index 7c0afe6..8a5604c 100644 --- a/tauri-app/src/components/Modal/TermsModal.css +++ b/tauri-app/src/components/Modal/TermsModal.css @@ -6,6 +6,7 @@ .terms-tabs { display: flex; + justify-content: center; gap: 4px; padding: 0 20px; border-bottom: 1px solid var(--border-light); diff --git a/tauri-app/src/components/UpdateDialog/UpdateDialog.css b/tauri-app/src/components/UpdateDialog/UpdateDialog.css new file mode 100644 index 0000000..885a7f6 --- /dev/null +++ b/tauri-app/src/components/UpdateDialog/UpdateDialog.css @@ -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; +} diff --git a/tauri-app/src/components/UpdateDialog/UpdateDialog.tsx b/tauri-app/src/components/UpdateDialog/UpdateDialog.tsx new file mode 100644 index 0000000..21ec9a5 --- /dev/null +++ b/tauri-app/src/components/UpdateDialog/UpdateDialog.tsx @@ -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 ( +
+
+ {/* 标题 */} +
+

{title}

+ {!isMandatory && !downloading && !installing && ( + + )} +
+ + {/* 内容 */} +
+ {/* 检查中 */} + {checking && ( +
+
+

正在检查是否有新版本...

+
+ )} + + {/* 发现更新 */} + {!checking && hasUpdate && updateInfo && ( + <> +
+ 当前版本: {CURRENT_VERSION} + + 新版本: {updateInfo.version} +
+ + {updateInfo.body && ( +
+
更新内容:
+
{updateInfo.body}
+
+ )} + + {isMandatory && ( +
+ + + + + + 此版本为强制更新,必须安装后才能继续使用 +
+ )} + + )} + + {/* 下载进度 */} + {downloading && ( +
+
+
+
+
+ {progress}% + {totalBytes > 0 && ( + + {formatBytes(downloadedBytes)} / {formatBytes(totalBytes)} + + )} +
+
+ )} + + {/* 安装完成 */} + {installing && ( +
+ + + + +

更新已下载并安装完成

+

重启应用后即可使用新版本

+
+ )} + + {/* 错误 */} + {error && ( +
+ + + + + + {error} +
+ )} +
+ + {/* 按钮 */} +
+ {!checking && !downloading && !installing && ( + <> + {!isMandatory && ( + + )} + + + )} + + {downloading && ( + + )} + + {installing && ( + + )} +
+
+
+ ); +} diff --git a/tauri-app/src/hooks/useUpdater.ts b/tauri-app/src/hooks/useUpdater.ts new file mode 100644 index 0000000..0151e30 --- /dev/null +++ b/tauri-app/src/hooks/useUpdater.ts @@ -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; + /** 下载并安装 */ + downloadAndInstall: () => Promise; + /** 忽略本次更新 */ + dismiss: () => void; + /** 重启应用 */ + relaunch: () => Promise; +} + +/** + * 解析更新说明中的强制更新标记 + * 如果 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({ + 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(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, + }; +} diff --git a/tauri-app/src/pages/ContentManagement/ContentManagement.css b/tauri-app/src/pages/ContentManagement/ContentManagement.css index 6da8d2a..28198ca 100644 --- a/tauri-app/src/pages/ContentManagement/ContentManagement.css +++ b/tauri-app/src/pages/ContentManagement/ContentManagement.css @@ -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; diff --git a/tauri-app/src/pages/ContentManagement/MyWorks.tsx b/tauri-app/src/pages/ContentManagement/MyWorks.tsx index 256e2c5..f94d5f3 100644 --- a/tauri-app/src/pages/ContentManagement/MyWorks.tsx +++ b/tauri-app/src/pages/ContentManagement/MyWorks.tsx @@ -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 (
!isEditing && onClick(draft.id)}> @@ -254,9 +255,14 @@ function DraftListItem({ draft, onClick, isEditing, editValue, onEdit, onSave, o
{!isEditing && ( - + <> + + + )}
@@ -271,6 +277,9 @@ export default function MyWorks() { const [showDeleteModal, setShowDeleteModal] = useState(false); const [deletingProduct, setDeletingProduct] = useState(null); const [isDeleting, setIsDeleting] = useState(false); + const [showDeleteDraftModal, setShowDeleteDraftModal] = useState(false); + const [deletingDraft, setDeletingDraft] = useState(null); + const [isDeletingDraft, setIsDeletingDraft] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [editingDraftId, setEditingDraftId] = useState(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)} /> ))} @@ -435,6 +465,17 @@ export default function MyWorks() { onConfirm={confirmDelete} onCancel={() => !isDeleting && setShowDeleteModal(false)} /> + 确认删除草稿 「{deletingDraft.title}」 吗? : ''} + description="此操作不可撤销,草稿及相关素材将从本地删除" + confirmText={isDeletingDraft ? '删除中...' : '确认删除'} + cancelText="取消" + confirmButtonType="danger" + onConfirm={confirmDeleteDraft} + onCancel={() => !isDeletingDraft && setShowDeleteDraftModal(false)} + /> ); } diff --git a/tauri-app/src/pages/Settings/SystemUpdate.tsx b/tauri-app/src/pages/Settings/SystemUpdate.tsx index a61a6b1..ffa45d3 100644 --- a/tauri-app/src/pages/Settings/SystemUpdate.tsx +++ b/tauri-app/src/pages/Settings/SystemUpdate.tsx @@ -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(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(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() {

系统更新

+ {/* 当前版本 */}
当前版本 - V 1.0.2 + v{CURRENT_VERSION}
+ + {/* 检查更新 */}
版本更新
{checking ? ( - + 检查中... - ) : checked ? ( + ) : checkResult === 'latest' ? ( 当前已是最新版本 + ) : checkResult === 'available' ? ( + 发现新版本 {updateInfo?.version} ) : ( - 未检查 + 上次检查时间:-- )} -
+ + {/* 更新详情 & 操作 */} + {checkResult === 'available' && updateInfo && ( +
+ {updateInfo.body && ( +
+
更新内容
+
{updateInfo.body}
+
+ )} + + {/* 下载进度 */} + {downloading && ( +
+
+
+
+
+ {progress}% + {totalBytes > 0 && {formatBytes(downloadedBytes)} / {formatBytes(totalBytes)}} +
+
+ )} + + {/* 按钮 */} +
+ {installing ? ( + + ) : ( + + )} +
+
+ )} + + {/* 错误 */} + {error && ( +
+ {error} +
+ )}
diff --git a/tauri-app/src/pages/VideoCreation/ScriptCreation.tsx b/tauri-app/src/pages/VideoCreation/ScriptCreation.tsx index e65aeec..134d385 100644 --- a/tauri-app/src/pages/VideoCreation/ScriptCreation.tsx +++ b/tauri-app/src/pages/VideoCreation/ScriptCreation.tsx @@ -33,11 +33,21 @@ export default function ScriptCreation() { const [selectedCategory, setSelectedCategory] = useState(''); const [selectedSubcategory, setSelectedSubcategory] = useState(''); - // 加载分类列表 + // 加载分类列表(带本地缓存 + 静默刷新) 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") diff --git a/tauri-app/src/vite-env.d.ts b/tauri-app/src/vite-env.d.ts index 11f02fe..dbb4c62 100644 --- a/tauri-app/src/vite-env.d.ts +++ b/tauri-app/src/vite-env.d.ts @@ -1 +1,3 @@ /// + +declare const __APP_VERSION__: string; diff --git a/tauri-app/vite.config.ts b/tauri-app/vite.config.ts index 01839c7..da465af 100644 --- a/tauri-app/vite.config.ts +++ b/tauri-app/vite.config.ts @@ -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,