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
@@ -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 ###
|
||||
@@ -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"])
|
||||
|
||||
@@ -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} 已删除"}
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 913 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 844 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 504 B After Width: | Height: | Size: 810 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 760 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
@@ -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,
|
||||
|
||||