83 Commits

Author SHA1 Message Date
小鱼开发 818fe7cc03 release: v1.6.0
- fix: finalVideoDuration 遗漏导致切换项目时残留旧值且无法保存
- feat: BGM 云端化(129 首上传七牛云,数据库保存 URL)
- feat: BGM 弹窗改为分类+列表+试听交互
- feat: 步骤页面 UI 统一(按钮规范、预览状态、去掉图标)
- feat: CoverDesign 视觉重构(绿色边框、角标、hover 遮罩)
- fix: 消除积分预检硬编码(CoverDesign/SubtitleBurning/VideoCompose)
- fix: success 提示积分去掉硬编码
- fix: BGM/封面形象持久化到 meta.json
2026-05-24 22:16:46 +08:00
小鱼开发 c6e3e6dd25 fix: success 提示中的积分消耗去掉硬编码
CoverDesign: 2 → coverDesignPoints
SubtitleBurning: 2 → subtitleBurnPoints
VideoCompose: 5 → composePoints
ScriptCreation: 5 → scriptPoints
2026-05-24 21:42:08 +08:00
小鱼开发 daba6dcc14 fix: 消除积分预检硬编码,统一使用 getRule() 动态读取
CoverDesign: 预检 COVER_POINTS=2 → getRule('cover_design')?.points || 2
SubtitleBurning: 预检 SUBTITLE_POINTS=2 → getRule('subtitle_burn')?.points || 2
VideoCompose: 预检 COMPOSE_POINTS=5 → getRule('compose')?.points || 5

确保预检口径与扣费口径一致,避免后端规则调整后出现余额误判
2026-05-24 21:29:04 +08:00
小鱼开发 6a2302401f fix: 修复 BGM 弹窗和封面持久化的多个 bug
VideoCompose:
- 修复 Audio 对象泄漏:弹窗关闭/组件卸载时清理 onended 回调
- 修复 bgmMusicId=0 被误判为未选择(falsy 判断改为 !== undefined/null)
- 修复 Slider 进度条不随值变化:动态传入 --slider-percent CSS 变量
- 修复试听无 url 时无法播放:回退到 filePath 拼接本地路径
- 去掉 webkitRelativePath 错误回退
- 弹窗默认分类:打开时根据当前选中 BGM 的 category 自动切换标签
- 本地上传 BGM 弹窗内显示当前选中提示条(含清除按钮)

CoverDesign:
- 自动保存去掉 trim(),避免输入时吃掉末尾空格
- 缩小自动保存依赖范围,只监听 backgroundImage/avatarImage

VideoGeneration.css:
- 本地上传提示条样式
2026-05-24 20:43:54 +08:00
小鱼开发 b76252b0ac fix: BGM/封面形象持久化 + 滑块颜色
- localStorage.saveMeta 的 orderedMeta 补充 bgmMusicId/bgMusicTitle/bgmMusicPath/bgmVolume,修复 BGM 数据无法写入 meta.json
- CoverDesign 新增 useEffect:背景图/形象变化时自动防抖保存 coverConfig
- VideoCompose BGM 音量滑块添加 slider-input 类,使用主题绿色替代浏览器默认蓝色
2026-05-24 20:34:52 +08:00
小鱼开发 bb84cb5604 style: BGM 分类标签圆角改为中等(去掉椭圆形) 2026-05-24 20:24:28 +08:00
小鱼开发 184ab8bce3 feat: BGM 弹窗改为分类+列表+试听交互
- 顶部分类标签栏(全部/知识科普/案例展示/促销活动/家居生活/智能家居)
- 列表布局:每项含试听按钮、标题、时长、选中标记
- 试听功能:点击圆形按钮播放/暂停,播放中按钮呼吸动画
- 选中 BGM 后关闭弹窗,本地上传入口移至底部
- 弹窗关闭时自动停止音频播放
2026-05-24 20:22:57 +08:00
小鱼开发 2c9e0f0015 feat: add BGM seed data and seed script for deployment
- bgm_seed_data.json: 129 首 BGM 元数据(含七牛云 URL)
- seed_bgm.py: 部署环境初始化脚本,容器内直接运行
2026-05-24 20:09:50 +08:00
小鱼开发 06ec0ee202 feat: BGM 云端化 + 步骤页面 UI 统一重构
后端:
- 新增 BGM 数据库模型、Schema、CRUD、API 路由
- BgmMusic 增加 url 字段存储七牛云地址
- Alembic 迁移: 创建 BGM 表 + 添加 url 字段
- import_bgm.py 导入时自动上传七牛云 (meijiaka-zy/bgm/...)

前端:
- VideoCompose BGM 选择改为卡片弹窗 (系统BGM + 本地上传)
- 去掉 BGM 硬编码本地路径, 直接使用云端 URL
- CoverDesign 视觉重构: 绿色边框卡片、角标、hover 遮罩
- CoverDesign 去掉预选背景, 默认空白需手动选择
- 所有步骤按钮规范统一: 左=重新生成(主色), 右=导出/预览(次色)
- 预览按钮状态统一: 文字变为'视频预览中...', 保持 btn-secondary
- 去掉所有步骤按钮的 svg/emoji 图标

Rust:
- mix_bgm_to_video 支持临时文件保护 (输入输出同路径时自动中转)
- FFmpeg BGM 混合使用 aloop 循环 + amix 滤镜
2026-05-24 15:39:54 +08:00
小鱼开发 616649c872 feat(cover-avatar): 前置余额检查 + 积分不足弹窗(与声音复刻一致) 2026-05-23 11:13:17 +08:00
小鱼开发 fae2a77734 feat(ui): 封面形象余额不足时不弹 toast;声音复刻弹窗增加积分消耗提示 2026-05-23 11:08:47 +08:00
小鱼开发 53371aabcd feat(image): 封面形象抠图增加积分消耗(每次 10 积分)
- config/points-config.yaml: 添加 cover_avatar: 10 固定积分
- point_service.py: _CATEGORY_MAP 添加 cover_avatar → 封面形象
- image.py: remove_background 接口前置余额检查 + 后置扣费
- CoverAvatarLibrary.tsx: 上传弹窗显示积分提示,余额不足友好提示
2026-05-23 10:59:47 +08:00
小鱼开发 9589d7c78a fix(cover-avatar): 修复卡片操作按钮误触 + 列表滚动 2026-05-23 10:19:01 +08:00
小鱼开发 bf51d8b423 style(cover-avatar): 列表改为 avatar-card 卡片样式(hover 操作按钮 + 图片缩放 + 4 列网格) 2026-05-23 10:16:28 +08:00
小鱼开发 db34090d5d feat(image): 人物描边宽度从 10px 调整为 20px 2026-05-23 10:10:21 +08:00
小鱼开发 d18e705a99 feat(image): 抠图增加人物白色描边(need_contour + contour_color + contour_size + need_crop_background)
- provider: 增加 need_contour/contour_color/contour_size/need_crop_background 参数
- service: 默认 scene=human,human/product 场景自动启用白色描边 + 裁剪背景
- adapter: 透传新参数到 provider
- API: scene 默认值改为 human
- 前端: removeBackground 默认 scene 改为 human
2026-05-23 10:04:34 +08:00
小鱼开发 6011225eec fix(image): 抠图结果下载后转存七牛云,解决前端 CORS 跨域加载失败 2026-05-23 09:50:40 +08:00
小鱼开发 222c468681 fix(mediakit): 兼容火山引擎抠图API的两种响应格式(code/data 和 success/result) 2026-05-23 09:44:27 +08:00
小鱼开发 430aea4aa8 fix(image): 增强抠图失败时的诊断日志,记录原始响应内容 2026-05-23 09:39:38 +08:00
小鱼开发 df6915191a chore(deploy): 恢复 deploy-test.sh 可执行权限 2026-05-23 09:35:15 +08:00
小鱼开发 9733a7f311 fix(progress-modal): 图标匹配改为互斥条件,避免标题同时命中多个关键词时重复渲染 2026-05-23 09:34:38 +08:00
小鱼开发 29f74f7afc chore(deploy): 让 deploy-test.sh 在 Git 中保持可执行权限 2026-05-23 09:26:15 +08:00
小鱼开发 8a5f0ace34 fix(update): 204 响应不通过 HTTPException 抛出,避免 Content-Length 校验失败 2026-05-23 09:24:53 +08:00
小鱼开发 f01f2c366a feat(cover-avatar): 封面形象功能
后端:
- 新增 POST /upload/image 图片上传(七牛云 image bucket)
- 新增 POST /image/remove-background AI 抠图(火山引擎 MediaKit)
- 提取 file_validation.py 共享模块

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

前端:
- 新增 CoverAvatarLibrary 页面(内容管理 → 封面形象)
- 新增 coverAvatar API 模块和 coverAvatarStore
- 封面设计集成:背景图/封面形象弹窗选择 + Fabric.js 叠加
- 优化左侧布局:视觉素材横向卡片(9:16)+ 文案配置分组
2026-05-22 18:38:18 +08:00
小鱼开发 c55c256dc7 style(settings): reduce section title size (22px -> 18px) 2026-05-22 15:38:07 +08:00
小鱼开发 a7c81c14eb refactor(sidebar): remove lightning icon, adjust avatar/text layout
- Remove lightning bolt SVG from balance display
- Increase avatar size (32px -> 36px)
- Tighten nickname/balance spacing in user-info
- Adjust user-name font weight (500 -> 600) and size (var(--font-sm) -> 14px)
- Reduce sidebar-user justify-content: center -> default (flex-start)
2026-05-22 15:33:10 +08:00
小鱼开发 7f43795b2e refactor(sidebar): move balance below nickname 2026-05-22 15:28:43 +08:00
小鱼开发 9870a8cbc8 refactor(app-header): move back button to right side 2026-05-22 15:25:16 +08:00
小鱼开发 538cb1a367 refactor(sidebar): merge balance and user into unified card 2026-05-22 15:22:50 +08:00
小鱼开发 a50c61bbb5 refactor(pricing): remove explanation section from pricing modal 2026-05-22 15:17:20 +08:00
小鱼开发 19a166a873 fix(pricing): line break after period for video rule detail 2026-05-22 15:14:29 +08:00
小鱼开发 1cb1c0b387 refactor(profile): remove logout button
Profile page no longer shows logout button — logout is accessible via Sidebar dropdown menu only.
2026-05-22 15:10:22 +08:00
小鱼开发 1a0679049e refactor(profile): restore recent transactions table
Replace menu list (使用明细 + 设置) with recent transactions table:
- Add back recentTx state and loading state
- Fetch last 5 transactions in loadData
- Display table with type/amount/description/time columns
- Add '查看全部' link to usage-detail page
- Remove unused icon components (FileTextIcon, SettingsIcon, ChevronRightIcon)
2026-05-22 15:02:11 +08:00
小鱼开发 91774f52ee refactor(Phase 3): merge SystemUpdate + AboutUs into Settings page
- New Settings page combines: system update check, version info, auth/copyright
- Remove standalone AboutUs.tsx and SystemUpdate.tsx
- Route: 'about-us' + 'system-update' → 'settings'
- Profile menu: merge '系统设置' + '关于我们' into single '设置' entry
- Sidebar dropdown: add '设置' back to user menu (besides '我的账户')
2026-05-22 14:48:29 +08:00
小鱼开发 34d6f671fe refactor(Phase 2): extract components + AppHeader
New components:
- PricingModal: standalone product pricing modal with lazy rule loading
- PointsCard: reusable points balance + today consumed + action buttons
- AppHeader: unified page header with title, back button, and right actions

Profile.tsx:
- Use PricingModal + PointsCard (380 lines -> ~200 lines)
- Remove embedded pricing table logic (~80 lines)
- Remove inline points display logic (~40 lines)

Pages updated with AppHeader:
- Profile: '我的账户' (no back button)
- UsageDetail: '积分明细' + back button
- SystemUpdate: '系统更新' + back button
- AboutUs: '关于我们' + back button

CSS:
- Add app-header, app-header-left, app-header-title, app-header-right classes
- Remove margin-bottom from page-back-btn (handled by app-header)
2026-05-22 12:45:41 +08:00
小鱼开发 5386a1dbf4 refactor: redesign Profile page, fix navigation & interaction logic
Profile page:
- Add page title '我的账户' in settings-section h2
- Restructure points section: horizontal action buttons (充值/明细/定价)
- Remove '产品定价' from menu list (already in points section)
- Remove '更多' heading

Interaction fixes:
- Profile logout now uses onNavigate('logout') → App.tsx ConfirmModal
- Sidebar dropdown: only '我的账户' + '退出登录' (others go through Profile)
- Add back button to usage-detail / system-update / about-us → navigate to profile
- content-management click: only expand/collapse, no auto-navigate to first child

CSS:
- Add --bg-secondary / --bg-tertiary to variables.css
- Add page-back-btn CSS class
- Convert profile-points-actions to horizontal row layout
2026-05-22 11:27:41 +08:00
小鱼开发 abf03712a5 refactor(profile): tighten spacing, simplify logout button
- Reduce points card padding (20px→16px vertical)
- Reduce points section padding (24px→20px vertical)
- Reduce section spacing (24px→16px)
- Section title margin (12px→8px)
- Logout button: remove card background/border, keep text-only with error hover
2026-05-22 11:13:10 +08:00
小鱼开发 31fec11c44 refactor(profile): zero inline styles
- Add profile-card-flat, profile-edit-wrap, profile-section-spaced,
  profile-pricing-loading, profile-pricing-detail-list CSS classes
- Profile.tsx: remove all remaining inline style attributes
2026-05-22 11:05:45 +08:00
小鱼开发 0a52195490 refactor(profile): extract CSS classes, fix undefined vars, remove inline styles
- variables.css: add --bg-secondary and --bg-tertiary (used but undefined)
- ContentManagement.css: add 30+ Profile CSS classes following design system
- Profile.tsx: rewrite with className, remove all inline styles and emoji
  - pricing modal tags use semantic colors via CSS classes
  - logout hover uses var(--error) instead of hardcoded #e74c3c
  - menu items use CSS hover instead of onMouseEnter/Leave handlers
2026-05-22 11:04:01 +08:00
小鱼开发 aebc9f6bcc refactor: Phase 1 Profile/Settings UX refactoring
- Sidebar: Remove '系统设置' from navItems, add balance badge + user dropdown
  menu in footer (我的账户/使用明细/系统设置/关于我们/退出登录)
- Profile: Remove inline recent transactions table (UsageDetail page exists),
  simplify to info + points + menu entries. Add inline pricing modal.
- GenerationControls: Show current balance alongside point cost in button text
- Points config: Adjust subtitle_burn/cover_design to 5 pts, recharge validity
2026-05-22 10:50:48 +08:00
小鱼开发 574874c856 feat: 视频生成使用系统素材时每个额外收取 2 积分 2026-05-22 09:40:23 +08:00
小鱼开发 497e65d86d fix: 视频生成积分扣费不允许欠费 2026-05-21 22:28:41 +08:00
小鱼开发 372b36becc feat: 音量滑块范围改为 1-5(对应传参 0-4) 2026-05-21 22:18:03 +08:00
小鱼开发 582068b599 fix: 匹配素材成功后自动切换镜头并加载播放 2026-05-21 21:42:04 +08:00
小鱼开发 1448cd54ab feat: 匹配素材成功后自动加载播放预览 2026-05-21 21:32:14 +08:00
小鱼开发 59237f1098 style: 匹配素材按钮移到上传素材旁边 2026-05-21 21:29:33 +08:00
小鱼开发 d6fe43b7c3 feat: 匹配素材改为针对单个镜头匹配
- 新增 matchSingleMaterial 函数,调用单条匹配 API
- 点击'匹配素材'只匹配当前镜头,不影响其他镜头
2026-05-21 21:24:57 +08:00
小鱼开发 e52513f452 refactor: 匹配素材与换一个合并为同个按钮的不同状态 2026-05-21 21:22:16 +08:00
小鱼开发 4123b66ab9 fix: 添加 Tauri window.show/set_focus 权限
- 修复文件选择对话框触发的权限拒绝错误
- core:window:allow-show, core:window:allow-set-focus
2026-05-21 21:19:29 +08:00
小鱼开发 54fc6b2638 feat: 空镜素材自动匹配改为手动匹配
- 移除页面加载时的自动批量匹配逻辑
- 每个未匹配空镜卡片新增'匹配素材'按钮
- 点击后触发批量匹配,已匹配后显示'换一个'按钮
2026-05-21 21:19:25 +08:00
小鱼开发 2cece72abe feat: 用户白名单免验证码登录
- Settings 新增 SMS_CODE_WHITELIST 配置(逗号分隔手机号)
- login_with_sms 中白名单手机号跳过验证码校验
- 方便内部测试和演示账号使用
2026-05-21 16:32:09 +08:00
小鱼开发 44ec2dceb7 feat: ffprobe 快速检测 H.264/yuv420p 视频,跳过不必要的预览转码
- 应用生成的成品视频已是 H.264/yuv420p,无需重复转码
- 超时后显式 kill ffprobe 子进程,避免僵尸进程
- 分辨率上限判断:4K 素材仍转码为 540p 代理保证预览流畅
2026-05-21 16:32:02 +08:00
小鱼开发 6def12995e style: 视频预览加载遮罩增加'加载中...'文案 2026-05-21 16:06:40 +08:00
小鱼开发 ec3b2b87ed feat: 视频缓存自动清理
- 应用启动时后台清理 video_cache 目录
- 删除超过 30 天未修改的缓存文件
- 总容量超 500MB 时,按修改时间删最旧文件直到 300MB
- 不阻塞首屏加载
2026-05-21 16:00:55 +08:00
小鱼开发 59bfadcb99 fix: FAT32 文件系统修改时间读取失败导致转码报错;更新 useLocalVideo 注释 2026-05-21 15:58:05 +08:00
小鱼开发 666842ce2b feat: 视频预览自动转码为浏览器兼容格式
- Rust: 新增 transcode_for_preview,用 FFmpeg 将任意视频转码为
  H.264 Baseline + YUV420p 540p,确保跨平台预览兼容
- Rust: 缓存按(路径hash + 文件大小 + 修改时间)管理,避免重复转码
- 前端: 新增 getPreviewVideoUrl 工具,统一替换视频预览的 URL 获取逻辑
- 根本性解决 Windows WebView2 视频黑屏问题,同时提升预览性能
2026-05-21 15:52:30 +08:00
小鱼开发 5250381579 fix: Windows 视频预览黑屏 — 禁用 D3D11 硬件视频解码
Chromium 在 Windows 上的 D3D11 视频解码器与部分显卡驱动/视频编码
不兼容,导致视频画面黑屏但音频正常。回退到软件解码解决此问题。
2026-05-21 15:26:12 +08:00
小鱼开发 c4a9c9c2eb style: 启动加载动画颜色从 #1a9e8a 改为 var(--primary) 绿色 2026-05-21 15:20:57 +08:00
小鱼开发 0e876384d6 ci: 构建产物自动上传到 GitHub Release,artifacts 保留 3 天 2026-05-21 15:12:38 +08:00
小鱼开发 81145fb9d0 fix: ffprobe duration 解析增加 format 回退,兼容 MPEG-TS 等格式 2026-05-21 14:34:08 +08:00
小鱼开发 a913c6e3da chore: 更新版本号至 1.5.19 2026-05-21 14:26:04 +08:00
小鱼开发 2720dacc1d fix: Windows 视频分辨率 0x0 问题 — 改用 Rust 层 ffprobe 读取元数据
- 新增 ffmpeg_cmd::get_video_metadata,通过 ffprobe sidecar 读取视频信息
- 新增 Tauri Command get_video_metadata_cmd 供前端调用
- 前端 videoValidation.ts 不再依赖 HTML5 <video> 标签,改为调用 Rust ffprobe
- 解决 macOS 与 Windows 浏览器视频解码器差异导致的元数据读取不一致问题
2026-05-21 14:23:44 +08:00
小鱼开发 3c4c765f2a ci: macOS 构建使用 gh CLI 下载私有仓库 sidecar,解决认证问题 2026-05-21 11:14:32 +08:00
小鱼开发 2be938d0a3 ci: 修复 macOS 构建 sidecar 下载 URL,使用动态仓库名 2026-05-21 11:07:56 +08:00
小鱼开发 71bad49710 ci: 恢复 GitHub Actions macOS 构建 2026-05-21 10:55:43 +08:00
小鱼开发 30396543ee chore: 删除未使用的 minisign 密钥,更新签名文档
- 从 git 仓库移除 minisign.key.pub(未被任何配置引用)
- 本地删除 minisign.key 私钥
- 更新 windows-signing.md:将密钥文件名修正为实际使用的 .tauri-signing-key.pub
2026-05-21 10:50:34 +08:00
小鱼开发 ec428ba1c8 chore: 删除重复的 tauri.key.pub(内容与 .tauri-signing-key.pub 完全相同) 2026-05-21 10:48:02 +08:00
小鱼开发 f8ee7c61b9 chore: 清理测试密钥文件,防止敏感信息泄露
- 从 git 仓库移除已提交的测试公钥(cargo-test.key.pub、npm-test.key.pub)
- 本地删除测试密钥文件(cargo-test.key、npm-test.key)
- 更新 .gitignore 排除所有测试相关密钥
2026-05-21 10:46:41 +08:00
小鱼开发 d7fa20a890 feat: 样式系统重构、图标更新、FFmpeg 模块调整及配置更新
- 更新 .gitignore 排除私钥和 IDE 配置
- 重构前端样式系统(新增 reset.css/animations.css/components/)
- 更新应用图标资源(多种尺寸)
- 调整 FFmpeg 命令模块
- 更新部署脚本和图标生成脚本
- 新增数据库迁移脚本
- 添加签名公钥文件
2026-05-21 10:45:04 +08:00
小鱼开发 4fc8ee58cb fix: Windows 下 convertFileSrc 使用 http://asset.localhost,CSP 需显式放行 2026-05-21 10:40:19 +08:00
小鱼开发 3ce29d5333 fix(updater): 使用带密码的签名密钥对,修复 CI 签名失败
Tauri CLI 2.x 生成无密码密钥存在已知 bug(tauri-apps/tauri#14829)。
按主流方案改为使用带密码的密钥对:
- 重新生成带密码的 updater 签名密钥
- 同步更新公钥到 tauri.conf.json 和 tauri.key.pub
- CI workflow 增加 TAURI_SIGNING_PRIVATE_KEY_PASSWORD 环境变量
2026-05-21 07:50:10 +08:00
小鱼开发 c42500d256 chore(deps): 升级 @tauri-apps/cli 到 2.11.2
修复 Tauri CLI 2.10.0 中 updater 签名无法识别空密码密钥的问题。
tauri-cli 2.11.2 在 CI 环境下会自动将未设置的密码视为空字符串。
2026-05-21 07:31:29 +08:00
小鱼开发 1dd934e0a2 fix(updater): 修复 Tauri 签名密钥对格式问题
Tauri CLI 2.x 的 tauri signer generate --ci 存在已知 bug:
生成的无密码私钥中 KDF byte 被错误设为非零值,导致签名阶段
报错 incorrect updater private key password。

通过 Python 脚本手动将 KDF byte 修正为 0x00,并同步更新公钥。

参考: tauri-apps/tauri#14829
2026-05-20 23:13:23 +08:00
小鱼开发 2a4a9511d6 fix: 重新生成 updater 密钥对,修复运行模式切换,启用 updater 产物生成 2026-05-20 22:19:39 +08:00
小鱼开发 20cca6e631 fix(tauri): add F12/Ctrl+Shift+I shortcut and shift+right-click for DevTools
- Add open_devtools IPC command
- Frontend keydown listener for F12 / Ctrl+Shift+I
- Allow contextmenu when Shift is held (for Inspect element)
- Auto open_devtools after window.show() with 1s delay
2026-05-20 15:20:44 +08:00
小鱼开发 501c5e8221 fix(tauri): ensure devtools opens after window is visible
- window.show() before open_devtools() since visible=false in config
- Add 1s delay in spawned thread for WebView init completion
2026-05-20 15:13:00 +08:00
小鱼开发 9f3ea6dece fix(ci): add missing sidecar download step for Windows build
- Windows job was missing Download sidecar binaries step, causing
  'ffmpeg-x86_64-pc-windows-msvc.exe doesn't exist' build failure
- Remove duplicate sidecar download step from disabled macOS job
2026-05-20 12:34:16 +08:00
小鱼开发 837fbc997d fix: 移除未使用的 React 导入,修复 TS6133 编译错误 2026-05-20 12:23:37 +08:00
小鱼开发 b6311bec9d fix(ci): 改回 gh release download,当前仓库已有 sidecar release 2026-05-20 12:09:52 +08:00
小鱼开发 41e495f0f0 fix(ci): sidecar 下载改回当前仓库 release 2026-05-20 11:52:38 +08:00
小鱼开发 b98df5a1a4 fix(ci): 用 curl 直接下载 sidecar,绕过 gh CLI 跨仓库权限限制 2026-05-20 11:23:04 +08:00
小鱼开发 98c14582d4 temp: 禁用 updater 签名,绕过私钥缺失问题 2026-05-20 11:17:05 +08:00
小鱼开发 f7b57d9fd8 temp: 固定 sidecar 仓库 + 禁用 macOS 构建 2026-05-20 11:11:51 +08:00
133 changed files with 8414 additions and 1745 deletions
+26 -1
View File
@@ -96,6 +96,7 @@ jobs:
env:
VITE_API_BASE_URL: https://dev.tapi.meijiaka.cn/api/v1
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -103,7 +104,18 @@ jobs:
name: macos-universal
path: |
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg.sig
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz.sig
retention-days: 3
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: |
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz.sig
build-windows:
name: Build Windows (x64)
@@ -144,6 +156,7 @@ jobs:
New-Item -ItemType Directory -Force -Path tauri-app/src-tauri/binaries
gh release download v0.0.0-sidecar --repo ${{ github.repository }} --pattern "sidecar-binaries.tar.gz" --dir $env:TEMP
tar xzf "$env:TEMP\sidecar-binaries.tar.gz" -C tauri-app/src-tauri/binaries/
Get-ChildItem tauri-app/src-tauri/binaries/
env:
GH_TOKEN: ${{ github.token }}
@@ -170,6 +183,7 @@ jobs:
env:
VITE_API_BASE_URL: https://dev.tapi.meijiaka.cn/api/v1
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -178,4 +192,15 @@ jobs:
path: |
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
retention-days: 3
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: |
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
+4
View File
@@ -27,3 +27,7 @@ test_kick.sh
.qiniu_pythonsdk_hostscache.json
tauri-app/src-tauri/binaries/*
.tauri-signing-key
*.key
*test*.key*
.atomcode/
mixkit_bgm/
+7 -3
View File
@@ -80,8 +80,9 @@ build-frontend-macos:
paths:
# DMG 安装包 (推荐用户下载)
- tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg
# .app bundle (供进一步分发或公证使用)
- tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app
# Updater 专用包 + 签名
- tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz
- tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz.sig
expire_in: "${ARTIFACT_EXPIRE_DAYS} days"
timeout: 45 minutes
retry:
@@ -114,8 +115,11 @@ build-frontend-windows:
artifacts:
name: "meijiaka-windows-${CI_COMMIT_REF_NAME}-${CI_COMMIT_SHORT_SHA}"
paths:
# NSIS 安装包 (推荐用户下载)
# Updater 专用包 + 签名
- tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
- tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
# NSIS 安装包 (推荐用户下载)
- tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
# MSI 安装包 (企业部署场景)
- tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
expire_in: "${ARTIFACT_EXPIRE_DAYS} days"
+1 -1
View File
@@ -1 +1 @@
1.5.18
1.6.0
+336
View File
@@ -0,0 +1,336 @@
# Mixkit 免版权音乐清单(装修行业口播短视频)
> 来源: Mixkit.co(免版税、无需署名、可商用)
> 下载时间: 2026-05-23
> 总计: 129 首 / 616 MB
---
## 分类说明
| 分类 | 适用场景 | 数量 |
|------|----------|------|
| **知识科普** | 装修避坑、材料选择、流程科普 | 66 首 |
| **案例展示** | 完工验收、前后对比、实景展示 | 49 首 |
| **促销活动** | 开业促销、团购活动、限时优惠 | 49 首 |
| **家居生活** | 软装搭配、生活 vlog、温馨家庭 | 54 首 |
| **智能家居** | 全屋智能、现代设计、灯光系统 | 48 首 |
---
## 一、知识科普(专业可信,不抢戏)
| ID | 音乐名 | 艺术家 |
|----|--------|--------|
| 22 | Piano Reflections | Ahjay Stelino |
| 105 | See Line Funk | Alejandro Magaña |
| 113 | House Fest | Alejandro Magaña (A. M.) |
| 114 | Kodama Night Town | Alejandro Magaña (A. M.) |
| 1167 | Close Up | Michael Ramir C. |
| 124 | Techno Fest Vibes | Alejandro Magaña (A. M.) |
| 127 | Valley Sunset | Alejandro Magaña (A. M.) |
| 132 | Hazy After Hours | Alejandro Magaña (A. M.) |
| 134 | Deep Techno Ambience | Alejandro Magaña (A. M.) |
| 138 | Forest Treasure | Alejandro Magaña (A. M.) |
| 139 | Spirit in the Woods | Alejandro Magaña (A. M.) |
| 147 | Spirit in the Woods 2 | Alejandro Magaña (A. M.) |
| 160 | Minimal Emotion | Alejandro Magaña (A. M.) |
| 162 | Minimal Techno 01 | Alejandro Magaña (A. M.) |
| 168 | Staring at the Night Sky | Alejandro Magaña (A. M.) |
| 169 | Zanarkand Forest | Alejandro Magaña (A. M.) |
| 175 | Digital Clouds | Alejandro Magaña (A. M.) |
| 184 | Vastness | Andrew Ev |
| 251 | Ambient | Arulo |
| 292 | Relax Beat | Arulo |
| 324 | Smooth Meditation | Arulo |
| 340 | Nap Time | Arulo |
| 371 | Cat Walk | Arulo |
| 416 | Young Trizzy | Arulo |
| 441 | Meditation | Arulo |
| 443 | Serene View | Arulo |
| 470 | Golden Storm | Diego Nava |
| 471 | Rising Forest | Diego Nava |
| 480 | Curiosity | Diego Nava |
| 493 | Beautiful Dream | Diego Nava |
| 568 | Focus on Yourself | Eugenio Mininni |
| 584 | Rest Now | Eugenio Mininni |
| 593 | Opalescent | Eugenio Mininni |
| 594 | River Flow | Eugenio Mininni |
| 616 | What it Takes | Eugenio Mininni |
| 617 | Wind Leaves | Eugenio Mininni |
| 620 | B.O.R.N | Eugenio Mininni |
| 623 | Deep Urban | Eugenio Mininni |
| 628 | Summer Dream | Eugenio Mininni |
| 629 | Skeeomaver Sound | Eugenio Mininni |
| 633 | Xanthos | Eugenio Mininni |
| 652 | Soul Jazz | Francisco Alvear |
| 664 | Pop One | Francisco Alvear |
| 695 | Pop 05 | Grigoriy Nuzhny |
| 700 | Pop 03 | Grigoriy Nuzhny |
| 713 | Classical 6 | Jonny S. |
| 720 | New Bass 01 | Lily J |
| 726 | Uplifting Bass | Lily J |
| 729 | Pop Track 03 | Lily J |
| 738 | Hip Hop 02 | Lily J |
| 744 | House 02 | Lily J |
| 749 | Relaxation 05 | Lily J |
| 759 | Romantic 05 | Lily J |
| 770 | Autofahren | Mauro Urbina |
| 779 | Oh | Michael Ramir C. |
| 801 | Happy Home | Michael Ramir C. |
| 802 | Here Comes The Train | Michael Ramir C. |
| 804 | I Love You Grandma | Michael Ramir C. |
| 813 | Magical moment | Michael Ramir C. |
| 816 | Please | Michael Ramir C. |
| 821 | At the Playhouse | Michael Ramir C. |
| 832 | I'm Going Home | Michael Ramir C. |
| 834 | It's Love | Michael Ramir C. |
| 837 | Life is a Dream | Michael Ramir C. |
| 839 | Tears of Joy | Michael Ramir C. |
| 840 | That's the Way of Life | Michael Ramir C. |
| 847 | It's April | Michael Ramir C. |
| 852 | Music and Life | Michael Ramir C. |
| 856 | Salty and Sweet | Michael Ramir C. |
| 872 | Gimme that Groove! | Michael Ramir C. |
| 897 | A Very Happy Christmas | Michael Ramir C. |
| 953 | Feel Alive | Michael Ramir C. |
| 963 | Just Keep Walking | Michael Ramir C. |
| 970 | Night Sky Hip Hop | Michael Ramir C. |
| 993 | Finding Myself | Michael Ramir C. |
| 1000 | I Can Hear Your Heartbeat | Michael Ramir C. |
| 1001 | I Do! | Michael Ramir C. |
| 1052 | Baby Yohan | Michael Ramir C. |
| 1081 | We'll Be Okay | Michael Ramir C. |
| 1140 | Funkee Monkeee | Michael Ramir C. |
| 1183 | Karma | Michael Ramir C. |
| 1210 | Can't Get You Off My Mind | Michael Ramir C. |
## 二、案例展示(有成就感、积极)
| ID | 音乐名 | 艺术家 |
|----|--------|--------|
| 3 | Dance with Me | Ahjay Stelino |
| 4 | Delightful | Ahjay Stelino |
| 5 | Feeling Happy | Ahjay Stelino |
| 8 | Jumping Around | Ahjay Stelino |
| 11 | Just Kidding | Ahjay Stelino |
| 12 | Playground Fun | Ahjay Stelino |
| 13 | Summer Fun | Ahjay Stelino |
| 31 | Dreaming Big | Ahjay Stelino |
| 32 | Driving Ambition | Ahjay Stelino |
| 34 | Raising Me Higher | Ahjay Stelino |
| 91 | Summer's Here | Ahjay Stelino |
| 288 | One More Dance | Arulo |
| 339 | Villa Penthouse | Arulo |
| 350 | Follow Me Home | Arulo |
| 528 | You Got Jazz | Diego Nava |
| 529 | Walking in the Park | Diego Nava |
| 532 | A Happy Child | Diego Nava |
| 621 | BRIDGE No 98 | Eugenio Mininni |
| 684 | Classical vibes 4 | Grigoriy Nuzhny |
| 801 | Happy Home | Michael Ramir C. |
| 823 | Be Happy 2 | Michael Ramir C. |
| 839 | Tears of Joy | Michael Ramir C. |
| 872 | Gimme that Groove! | Michael Ramir C. |
| 897 | A Very Happy Christmas | Michael Ramir C. |
| 953 | Feel Alive | Michael Ramir C. |
| 1000 | I Can Hear Your Heartbeat | Michael Ramir C. |
| 1001 | I Do! | Michael Ramir C. |
| 1052 | Baby Yohan | Michael Ramir C. |
| 1140 | Funkee Monkeee | Michael Ramir C. |
| 1183 | Karma | Michael Ramir C. |
| 1210 | Can't Get You Off My Mind | Michael Ramir C. |
## 三、促销活动(轻快、有能量)
同案例展示分类,推荐节奏更欢快的:
- 3.mp3, 4.mp3, 5.mp3, 8.mp3, 11.mp3, 12.mp3, 13.mp3, 31.mp3, 32.mp3, 34.mp3, 91.mp3
- 288.mp3, 339.mp3, 350.mp3, 528.mp3, 529.mp3, 532.mp3, 621.mp3
- 801.mp3, 823.mp3, 839.mp3, 872.mp3, 897.mp3, 953.mp3
- 1000.mp3, 1001.mp3, 1052.mp3, 1140.mp3, 1183.mp3, 1210.mp3
## 四、家居生活(温馨、治愈)
| ID | 音乐名 | 艺术家 |
|----|--------|--------|
| 22 | Piano Reflections | Ahjay Stelino |
| 127 | Valley Sunset | Alejandro Magaña (A. M.) |
| 138 | Forest Treasure | Alejandro Magaña (A. M.) |
| 139 | Spirit in the Woods | Alejandro Magaña (A. M.) |
| 147 | Spirit in the Woods 2 | Alejandro Magaña (A. M.) |
| 168 | Staring at the Night Sky | Alejandro Magaña (A. M.) |
| 169 | Zanarkand Forest | Alejandro Magaña (A. M.) |
| 199 | Loner | Arulo |
| 250 | Island Beat | Arulo |
| 282 | Sweet September | Arulo |
| 292 | Relax Beat | Arulo |
| 322 | Life's a Movie | Arulo |
| 324 | Smooth Meditation | Arulo |
| 340 | Nap Time | Arulo |
| 345 | Nature Meditation | Arulo |
| 350 | Follow Me Home | Arulo |
| 416 | Young Trizzy | Arulo |
| 441 | Meditation | Arulo |
| 442 | Nature Yoga | Arulo |
| 443 | Serene View | Arulo |
| 444 | Yoga Song | Arulo |
| 493 | Beautiful Dream | Diego Nava |
| 528 | You Got Jazz | Diego Nava |
| 529 | Walking in the Park | Diego Nava |
| 532 | A Happy Child | Diego Nava |
| 568 | Focus on Yourself | Eugenio Mininni |
| 584 | Rest Now | Eugenio Mininni |
| 593 | Opalescent | Eugenio Mininni |
| 594 | River Flow | Eugenio Mininni |
| 617 | Wind Leaves | Eugenio Mininni |
| 620 | B.O.R.N | Eugenio Mininni |
| 623 | Deep Urban | Eugenio Mininni |
| 628 | Summer Dream | Eugenio Mininni |
| 629 | Skeeomaver Sound | Eugenio Mininni |
| 633 | Xanthos | Eugenio Mininni |
| 652 | Soul Jazz | Francisco Alvear |
| 664 | Pop One | Francisco Alvear |
| 695 | Pop 05 | Grigoriy Nuzhny |
| 700 | Pop 03 | Grigoriy Nuzhny |
| 713 | Classical 6 | Jonny S. |
| 720 | New Bass 01 | Lily J |
| 726 | Uplifting Bass | Lily J |
| 729 | Pop Track 03 | Lily J |
| 738 | Hip Hop 02 | Lily J |
| 744 | House 02 | Lily J |
| 749 | Relaxation 05 | Lily J |
| 759 | Romantic 05 | Lily J |
| 770 | Autofahren | Mauro Urbina |
| 779 | Oh | Michael Ramir C. |
| 801 | Happy Home | Michael Ramir C. |
| 802 | Here Comes The Train | Michael Ramir C. |
| 804 | I Love You Grandma | Michael Ramir C. |
| 813 | Magical moment | Michael Ramir C. |
| 816 | Please | Michael Ramir C. |
| 821 | At the Playhouse | Michael Ramir C. |
| 832 | I'm Going Home | Michael Ramir C. |
| 834 | It's Love | Michael Ramir C. |
| 837 | Life is a Dream | Michael Ramir C. |
| 839 | Tears of Joy | Michael Ramir C. |
| 840 | That's the Way of Life | Michael Ramir C. |
| 847 | It's April | Michael Ramir C. |
| 852 | Music and Life | Michael Ramir C. |
| 856 | Salty and Sweet | Michael Ramir C. |
| 872 | Gimme that Groove! | Michael Ramir C. |
| 897 | A Very Happy Christmas | Michael Ramir C. |
| 953 | Feel Alive | Michael Ramir C. |
| 963 | Just Keep Walking | Michael Ramir C. |
| 970 | Night Sky Hip Hop | Michael Ramir C. |
| 993 | Finding Myself | Michael Ramir C. |
| 1000 | I Can Hear Your Heartbeat | Michael Ramir C. |
| 1001 | I Do! | Michael Ramir C. |
| 1052 | Baby Yohan | Michael Ramir C. |
| 1081 | We'll Be Okay | Michael Ramir C. |
| 1140 | Funkee Monkeee | Michael Ramir C. |
| 1167 | Close Up | Michael Ramir C. |
| 1183 | Karma | Michael Ramir C. |
| 1210 | Can't Get You Off My Mind | Michael Ramir C. |
## 五、智能家居(科技感、高级感)
| ID | 音乐名 | 艺术家 |
|----|--------|--------|
| 105 | See Line Funk | Alejandro Magaña |
| 113 | House Fest | Alejandro Magaña (A. M.) |
| 114 | Kodama Night Town | Alejandro Magaña (A. M.) |
| 122 | Slow Rain | Alejandro Magaña (A. M.) |
| 124 | Techno Fest Vibes | Alejandro Magaña (A. M.) |
| 130 | Tech House vibes | Alejandro Magaña (A. M.) |
| 132 | Hazy After Hours | Alejandro Magaña (A. M.) |
| 134 | Deep Techno Ambience | Alejandro Magaña (A. M.) |
| 136 | Infected Mushroom Vibes | Alejandro Magaña (A. M.) |
| 137 | Goa Trance Mantra | Alejandro Magaña (A. M.) |
| 140 | Cyberpunk City | Alejandro Magaña (A. M.) |
| 157 | Infected Vibes | Alejandro Magaña (A. M.) |
| 160 | Minimal Emotion | Alejandro Magaña (A. M.) |
| 162 | Minimal Techno 01 | Alejandro Magaña (A. M.) |
| 166 | Trance Party | Alejandro Magaña (A. M.) |
| 173 | Better Times are Coming | Alejandro Magaña (A. M.) |
| 175 | Digital Clouds | Alejandro Magaña (A. M.) |
| 180 | Gear | Andrew Ev |
| 181 | Pop | Andrew Ev |
| 184 | Vastness | Andrew Ev |
| 199 | Loner | Arulo |
| 251 | Ambient | Arulo |
| 292 | Relax Beat | Arulo |
| 324 | Smooth Meditation | Arulo |
| 340 | Nap Time | Arulo |
| 371 | Cat Walk | Arulo |
| 416 | Young Trizzy | Arulo |
| 441 | Meditation | Arulo |
| 442 | Nature Yoga | Arulo |
| 443 | Serene View | Arulo |
| 444 | Yoga Song | Arulo |
| 464 | Sci-Fi Score | Arulo |
| 470 | Golden Storm | Diego Nava |
| 471 | Rising Forest | Diego Nava |
| 480 | Curiosity | Diego Nava |
| 517 | Jungle Voices | Diego Nava |
| 568 | Focus on Yourself | Eugenio Mininni |
| 584 | Rest Now | Eugenio Mininni |
| 593 | Opalescent | Eugenio Mininni |
| 594 | River Flow | Eugenio Mininni |
| 609 | Moon Walk | Eugenio Mininni |
| 616 | What it Takes | Eugenio Mininni |
| 617 | Wind Leaves | Eugenio Mininni |
| 620 | B.O.R.N | Eugenio Mininni |
| 623 | Deep Urban | Eugenio Mininni |
| 628 | Summer Dream | Eugenio Mininni |
| 629 | Skeeomaver Sound | Eugenio Mininni |
| 633 | Xanthos | Eugenio Mininni |
| 652 | Soul Jazz | Francisco Alvear |
| 664 | Pop One | Francisco Alvear |
| 695 | Pop 05 | Grigoriy Nuzhny |
| 700 | Pop 03 | Grigoriy Nuzhny |
| 713 | Classical 6 | Jonny S. |
| 720 | New Bass 01 | Lily J |
| 726 | Uplifting Bass | Lily J |
| 729 | Pop Track 03 | Lily J |
| 738 | Hip Hop 02 | Lily J |
| 744 | House 02 | Lily J |
| 749 | Relaxation 05 | Lily J |
| 759 | Romantic 05 | Lily J |
| 770 | Autofahren | Mauro Urbina |
| 779 | Oh | Michael Ramir C. |
| 801 | Happy Home | Michael Ramir C. |
| 802 | Here Comes The Train | Michael Ramir C. |
| 804 | I Love You Grandma | Michael Ramir C. |
| 813 | Magical moment | Michael Ramir C. |
| 816 | Please | Michael Ramir C. |
| 821 | At the Playhouse | Michael Ramir C. |
| 832 | I'm Going Home | Michael Ramir C. |
| 834 | It's Love | Michael Ramir C. |
| 837 | Life is a Dream | Michael Ramir C. |
| 839 | Tears of Joy | Michael Ramir C. |
| 840 | That's the Way of Life | Michael Ramir C. |
| 847 | It's April | Michael Ramir C. |
| 852 | Music and Life | Michael Ramir C. |
| 856 | Salty and Sweet | Michael Ramir C. |
| 872 | Gimme that Groove! | Michael Ramir C. |
| 897 | A Very Happy Christmas | Michael Ramir C. |
| 953 | Feel Alive | Michael Ramir C. |
| 963 | Just Keep Walking | Michael Ramir C. |
| 970 | Night Sky Hip Hop | Michael Ramir C. |
| 993 | Finding Myself | Michael Ramir C. |
| 1000 | I Can Hear Your Heartbeat | Michael Ramir C. |
| 1001 | I Do! | Michael Ramir C. |
| 1052 | Baby Yohan | Michael Ramir C. |
| 1081 | We'll Be Okay | Michael Ramir C. |
| 1140 | Funkee Monkeee | Michael Ramir C. |
| 1167 | Close Up | Michael Ramir C. |
| 1183 | Karma | Michael Ramir C. |
| 1210 | Can't Get You Off My Mind | Michael Ramir C. |
---
## 使用说明
1. 所有音乐文件在 `mixkit_bgm/` 目录下,文件名为 `{ID}.mp3`
2. 运营人员可试听挑选,将选中的音乐上传到七牛云 CDN
3. 上传后通过 `POST /api/v1/update/releases` 或直接写 SQL 入库
4. 分类字段建议: `knowledge` | `showcase` | `promotion` | `lifestyle` | `tech`
+5
View File
@@ -47,6 +47,9 @@ VOLCENGINE_API_KEY=your-volcengine-api-key
VOLCENGINE_CAPTION_APPID=your-caption-appid
VOLCENGINE_CAPTION_TOKEN=your-caption-token
# 火山 MediaKit
VOLCENGINE_MEDIAKIT_TOKEN=your-mediakit-token
# Vidu(TTS、声音复刻、对口型)
VIDU_API_KEY=your-vidu-api-key
@@ -71,6 +74,8 @@ SMS_APP_ID=your-sms-app-id
SMS_SECRET_KEY=your-16-24-32-byte-aes-key
SMS_BASE_URL=https://bjksmtn.b2m.cn/inter/sendSingleSMS
# SMS_EXTENDED_CODE= # 扩展码(选填)
# 免验证码登录白名单(逗号分隔),名单内的手机号登录时跳过验证码校验
# SMS_CODE_WHITELIST=13800138000,13900139000
# === 日志配置 ===
# 生产环境建议 INFO
+3 -2
View File
@@ -20,17 +20,18 @@ load_dotenv()
# 导入模型
from app.db.session import Base
from app.models.bgm_music import BgmMusic # noqa
from app.models.broll_category import BrollCategory # noqa
from app.models.broll_material import BrollMaterial # noqa
from app.models.broll_tag import BrollTag # noqa
from app.models.cover_background import CoverBackground # noqa
from app.models.point_batch import PointBatch # noqa
from app.models.point_recharge_order import PointRechargeOrder # noqa
from app.models.point_transaction import PointTransaction # noqa
from app.models.update import AppRelease, ReleasePackage # noqa
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,28 @@
"""add_url_to_bgm_music
Revision ID: 100366516fbd
Revises: 7172a476e5b2
Create Date: 2026-05-24 15:24:11.076162
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '100366516fbd'
down_revision: Union[str, Sequence[str], None] = '7172a476e5b2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column('mjk_bgm_musics', sa.Column('url', sa.String(length=1024), nullable=True, comment='七牛云 URL'))
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column('mjk_bgm_musics', 'url')
@@ -0,0 +1,46 @@
"""add_bgm_music_table
Revision ID: 7172a476e5b2
Revises: d8f4912d7a52
Create Date: 2026-05-23 13:56:46.013156
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7172a476e5b2'
down_revision: Union[str, Sequence[str], None] = 'd8f4912d7a52'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('mjk_bgm_musics',
sa.Column('title', sa.String(length=255), nullable=False, comment='音乐名称'),
sa.Column('artist', sa.String(length=255), nullable=True, comment='艺术家'),
sa.Column('category', sa.String(length=32), nullable=False, comment='场景分类'),
sa.Column('file_path', sa.String(length=512), nullable=False, comment='相对文件路径'),
sa.Column('duration', sa.Float(), nullable=True, comment='时长(秒)'),
sa.Column('status', sa.String(length=16), nullable=False, comment='状态: active/inactive'),
sa.Column('sort_order', sa.Integer(), nullable=False, comment='排序权重'),
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mjk_bgm_musics_category'), 'mjk_bgm_musics', ['category'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_mjk_bgm_musics_category'), table_name='mjk_bgm_musics')
op.drop_table('mjk_bgm_musics')
# ### end Alembic commands ###
@@ -0,0 +1,48 @@
"""rename_old_table_prefix_for_update_tables
Revision ID: d8f4912d7a52
Revises: c3a0e1c71ce6
Create Date: 2026-05-20 18:02:45.186600
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd8f4912d7a52'
down_revision: Union[str, Sequence[str], None] = 'c3a0e1c71ce6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# 将旧环境(cbd4068 前)创建的 app_releases / release_packages 重命名为 mjk_ 前缀
# 使用 IF EXISTS 兼容:新环境已在 initial_schema 中创建了正确前缀的表名
op.execute(
"ALTER TABLE IF EXISTS app_releases RENAME TO mjk_app_releases"
)
op.execute(
"ALTER INDEX IF EXISTS ix_app_releases_version "
"RENAME TO ix_mjk_app_releases_version"
)
op.execute(
"ALTER TABLE IF EXISTS release_packages RENAME TO mjk_release_packages"
)
def downgrade() -> None:
"""Downgrade schema."""
op.execute(
"ALTER TABLE IF EXISTS mjk_app_releases RENAME TO app_releases"
)
op.execute(
"ALTER INDEX IF EXISTS ix_mjk_app_releases_version "
"RENAME TO ix_app_releases_version"
)
op.execute(
"ALTER TABLE IF EXISTS mjk_release_packages RENAME TO release_packages"
)
+1
View File
@@ -20,3 +20,4 @@ class Method:
CAPTION = "caption"
AUTO_ALIGN = "auto_align"
VIDEO_GENERATE = "video_generate"
REMOVE_BACKGROUND = "remove_background"
@@ -0,0 +1,95 @@
"""
火山引擎 MediaKit Adapter
==========================
实现 PlatformAdapter + SyncCapable。
直接接入 VolcengineMediakitProvider,提供标准 Protocol 接口。
"""
from __future__ import annotations
import logging
from typing import Any
from app.ai.adapters.base import AdapterResponse, PlatformAdapter, SyncCapable
from app.ai.adapters.constants import Method
from app.ai.providers.volcengine_mediakit_provider import VolcengineMediakitProvider
from app.core.exceptions import PlatformError, PlatformErrorType
logger = logging.getLogger(__name__)
class VolcengineMediakitAdapter(PlatformAdapter, SyncCapable):
"""火山引擎 MediaKit 平台标准 Adapter"""
platform_id = "volcengine_mediakit"
def __init__(self, provider: VolcengineMediakitProvider):
self.provider = provider
# ── PlatformAdapter ──
async def health(self) -> AdapterResponse:
try:
# 用无效 URL 测试连通性(400 说明网络通且认证通过)
await self.provider.remove_background(
image_url="https://example.com/health-check.jpg",
scene="general",
)
return AdapterResponse(success=True)
except PlatformError as e:
if e.error_type in (
PlatformErrorType.AUTH_FAILED,
PlatformErrorType.BAD_REQUEST,
):
return AdapterResponse(success=True)
return AdapterResponse(
success=False,
error_message=str(e),
retryable=e.retryable,
)
except Exception as e:
return AdapterResponse(
success=False,
error_message=str(e),
retryable=False,
)
async def close(self) -> None:
await self.provider.close()
# ── SyncCapable ──
async def call(self, method: str, payload: dict[str, Any]) -> AdapterResponse:
try:
if method == Method.REMOVE_BACKGROUND:
result = await self.provider.remove_background(
image_url=payload["image_url"],
scene=payload.get("scene", "general"),
need_contour=payload.get("need_contour", False),
contour_color=payload.get("contour_color", "#FFFFFF"),
contour_size=payload.get("contour_size", 10),
need_crop_background=payload.get("need_crop_background", False),
)
data = result.get("data", {})
return AdapterResponse(
success=True,
data={"image_url": data.get("image_url")},
)
else:
return AdapterResponse(
success=False,
error_message=f"不支持的方法: {method}",
retryable=False,
)
except PlatformError:
raise
except Exception as e:
raise PlatformError(
f"MediaKit {method} 调用失败: {e}",
platform="volcengine_mediakit",
retryable=False,
error_type=PlatformErrorType.UNKNOWN,
) from e
@@ -0,0 +1,174 @@
"""
火山引擎 MediaKit Provider
===========================
直接封装火山引擎 MediaKit HTTP API
- 图像背景移除(/api/v1/tools/remove-image-background/sync
使用 httpx.AsyncClient,支持外部注入(由 lifespan 管理生命周期)。
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from app.config import get_settings
from app.core.exceptions import PlatformError, PlatformErrorType
logger = logging.getLogger(__name__)
def _map_mediakit_error(status: int, message: str, code: int | None = None) -> PlatformError:
"""把 MediaKit 错误映射为标准 PlatformError"""
error_mapping = {
400: (PlatformErrorType.BAD_REQUEST, False),
401: (PlatformErrorType.AUTH_FAILED, False),
403: (PlatformErrorType.AUTH_FAILED, False),
429: (PlatformErrorType.RATE_LIMIT, True),
500: (PlatformErrorType.SERVER_ERROR, True),
502: (PlatformErrorType.SERVER_ERROR, True),
503: (PlatformErrorType.SERVER_ERROR, True),
}
error_type, retryable = error_mapping.get(status, (PlatformErrorType.UNKNOWN, False))
return PlatformError(
message, platform="volcengine_mediakit",
retryable=retryable, error_type=error_type,
status_code=status,
)
class VolcengineMediakitProvider:
"""火山引擎 MediaKit Provider
直接调用 MediaKit HTTP API,不做业务层处理。
"""
BASE_URL = "https://mediakit.cn-beijing.volces.com"
REMOVE_BG_PATH = "/api/v1/tools/remove-image-background/sync"
DEFAULT_TIMEOUT = 60.0
def __init__(
self,
token: str | None = None,
client: httpx.AsyncClient | None = None,
):
settings = get_settings()
self.token = token or settings.VOLCENGINE_MEDIAKIT_TOKEN or ""
if not self.token:
raise PlatformError(
"VOLCENGINE_MEDIAKIT_TOKEN 未配置",
platform="volcengine_mediakit",
retryable=False,
error_type=PlatformErrorType.BAD_REQUEST,
)
if client is not None:
self.client = client
self._owns_client = False
else:
self.client = httpx.AsyncClient(timeout=self.DEFAULT_TIMEOUT)
self._owns_client = True
def _get_headers(self) -> dict:
return {
"Authorization": f"Bearer; {self.token}",
"Content-Type": "application/json",
}
async def close(self) -> None:
"""关闭 HTTP 客户端"""
if self._owns_client and self.client and not self.client.is_closed:
await self.client.aclose()
async def remove_background(
self,
image_url: str,
scene: str = "general",
need_contour: bool = False,
contour_color: str = "#FFFFFF",
contour_size: int = 10,
need_crop_background: bool = False,
) -> dict[str, Any]:
"""同步抠图,返回原始 JSON
Args:
image_url: 原始图片 URL
scene: 场景类型
need_contour: 是否为主体生成描边(仅 human/product 场景生效)
contour_color: 描边颜色,十六进制 RGB 格式
contour_size: 描边宽度(px),范围 [1, 100]
need_crop_background: 是否裁剪透明背景到刚好包裹主体
Returns:
{"code": 0, "message": "Success", "data": {"image_url": "https://..."}}
"""
payload: dict[str, Any] = {"image_url": image_url, "scene": scene}
if need_contour:
payload["need_contour"] = True
payload["contour_color"] = contour_color
payload["contour_size"] = max(1, min(100, contour_size))
if need_crop_background:
payload["need_crop_background"] = True
try:
response = await self.client.post(
f"{self.BASE_URL}{self.REMOVE_BG_PATH}",
json=payload,
headers=self._get_headers(),
)
response.raise_for_status()
data = response.json()
# 火山引擎 MediaKit 有两种响应格式:
# 格式1: {"code": 0, "message": "...", "data": {...}}
# 格式2: {"success": true, "result": {...}, "expires_at": ...}
code = data.get("code")
if code is not None:
# 格式1
if code != 0:
logger.warning(
f"[MediaKit] 抠图业务失败: code={code}, "
f"message={data.get('message', 'N/A')}, "
f"raw_response={data}, image_url={image_url[:80]}..."
)
raise _map_mediakit_error(
response.status_code,
data.get("message", f"抠图失败: code={code}"),
code=code,
)
return data
else:
# 格式2
if not data.get("success", False):
logger.warning(
f"[MediaKit] 抠图业务失败: success=false, "
f"raw_response={data}, image_url={image_url[:80]}..."
)
raise _map_mediakit_error(
response.status_code,
"抠图失败: 平台返回失败状态",
)
# 将格式2标准化为格式1,方便上层统一处理
return {
"code": 0,
"message": "Success",
"data": data.get("result", {}),
}
except PlatformError:
raise
except httpx.HTTPStatusError as e:
raise _map_mediakit_error(
e.response.status_code, f"HTTP错误: {e.response.status_code}"
) from e
except (httpx.NetworkError, httpx.TimeoutException) as e:
raise PlatformError(
f"MediaKit 网络错误: {e}", platform="volcengine_mediakit",
retryable=True, error_type=PlatformErrorType.TIMEOUT,
) from e
except Exception as e:
raise _map_mediakit_error(500, f"抠图失败: {str(e)}") from e
+43
View File
@@ -0,0 +1,43 @@
"""
背景音乐 API
===========
提供装修行业场景化 BGM 列表查询。
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db
from app.crud.bgm_music import bgm_music
from app.schemas.bgm_music import BgmMusicItem, BgmMusicListResponse
from app.schemas.common import ApiResponse
router = APIRouter()
@router.get("/bgm-musics", response_model=ApiResponse[BgmMusicListResponse])
async def list_bgm_musics(
category: str | None = Query(None, description="场景分类筛选"),
db: AsyncSession = Depends(get_db),
) -> ApiResponse[BgmMusicListResponse]:
"""
获取背景音乐列表
按场景分类返回可用的背景音乐列表。
分类说明:
- knowledge: 知识科普(极简、低频)
- showcase: 案例展示(积极、有成就感)
- promotion: 促销活动(轻快、有能量)
- lifestyle: 家居生活(温馨、治愈)
- tech: 智能家居(科技感、高级感)
"""
items = await bgm_music.get_active_by_category(db, category=category)
return ApiResponse(
code=200,
message="success",
data=BgmMusicListResponse(
items=[BgmMusicItem.model_validate(item) for item in items],
total=len(items),
),
)
+262
View File
@@ -0,0 +1,262 @@
"""
图片处理 API
============
提供图片上传(七牛云)和 AI 抠图(火山引擎 MediaKit)功能。
"""
import io
import logging
import time
import uuid
from pathlib import Path
import httpx
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.config import get_settings
from app.models.user import User
from app.schemas.common import ApiResponse, success_response
from app.services import point_service as ps
from app.services.qiniu_service import get_qiniu_service
from app.services.volcengine_mediakit_service import VolcengineMediakitService
from app.utils.file_validation import check_upload_file
router = APIRouter(tags=["Image"])
logger = logging.getLogger(__name__)
settings = get_settings()
# ── Dependencies ──
async def get_mediakit_service(request: Request) -> VolcengineMediakitService:
"""FastAPI Depends:从 app.state 获取全局 VolcengineMediakitService 实例。"""
service = getattr(request.app.state, "volcengine_mediakit_service", None)
if service is None:
raise HTTPException(
status_code=503,
detail="MediaKit 服务未初始化,请检查配置",
)
return service
# ── Schemas ──
class ImageUploadResponse(BaseModel):
"""图片上传响应"""
url: str = Field(..., description="七牛云图片 URL")
key: str = Field(..., description="七牛云文件 key")
size: int = Field(..., description="文件大小(字节)")
class RemoveBackgroundResponse(BaseModel):
"""抠图响应"""
url: str = Field(..., description="抠图结果图片 URL")
class RemoveBackgroundRequest(BaseModel):
"""抠图请求"""
image_url: str = Field(..., description="原始图片 URL")
scene: str = Field(default="human", description="场景类型:general(通用)、human(人物,默认白色描边)或 product(商品)")
# ── Endpoints ──
@router.post("/upload/image", response_model=ApiResponse[ImageUploadResponse])
async def upload_image(
file: UploadFile = File(..., description="图片文件"),
current_user: User = Depends(get_current_user),
) -> ApiResponse[ImageUploadResponse]:
"""
上传图片到七牛云
支持格式:jpg, jpeg, png, gif, webp
返回七牛云永久访问 URL。
"""
try:
allowed_types = {
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
}
content_type = file.content_type or ""
# 如果 content_type 为空,尝试从文件名推断
if not content_type:
ext = Path(file.filename or "").suffix.lower()
ext_to_mime = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
}
content_type = ext_to_mime.get(ext, "")
if content_type not in allowed_types:
raise HTTPException(
status_code=400,
detail=f"不支持的图片格式: {content_type},请上传 jpg/png/gif/webp 图片",
)
# 读取文件内容
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="文件内容为空")
# 校验大小和魔数
check_upload_file(
content,
settings.UPLOAD_MAX_IMAGE_SIZE,
content_type,
"图片",
)
# 生成唯一文件名
ext = Path(file.filename or "image.jpg").suffix or ".jpg"
unique_name = f"{uuid.uuid4().hex[:16]}{ext}"
# 上传到七牛云
qiniu = get_qiniu_service()
bucket, domain = qiniu._get_bucket_and_domain("image")
file_key = qiniu.generate_key("image", unique_name)
stream = io.BytesIO(content)
result = await qiniu.upload_stream_async(
stream=stream,
key=file_key,
mime_type=content_type or "image/jpeg",
bucket=bucket,
domain=domain,
)
url = result.get("url")
returned_key = result.get("key")
if not url:
raise HTTPException(status_code=500, detail="上传到七牛云失败:未返回 URL")
logger.info(f"[Upload] 图片上传成功: {url[:80]}..., size={len(content)}")
return success_response(
data=ImageUploadResponse(
url=url,
key=returned_key or file_key,
size=len(content),
)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[Upload] 图片上传失败: {e}")
raise HTTPException(status_code=500, detail=f"上传失败: {e}")
@router.post("/image/remove-background", response_model=ApiResponse[RemoveBackgroundResponse])
async def remove_background(
req: RemoveBackgroundRequest,
current_user: User = Depends(get_current_user),
mediakit_service: VolcengineMediakitService = Depends(get_mediakit_service),
db: AsyncSession = Depends(get_db),
) -> ApiResponse[RemoveBackgroundResponse]:
"""
AI 抠图(火山引擎 MediaKit
移除图片背景,返回透明背景图片 URL。
每次调用消耗 10 积分。
"""
# 前置积分检查
required_points = ps._calculate_cost("cover_avatar")
check = await ps.check_balance(db, current_user.id, required_points)
if not check["sufficient"]:
raise HTTPException(
status_code=402,
detail=f"积分不足,需要 {required_points} 积分,当前余额 {check['balance']}",
)
try:
logger.info(
f"[RemoveBackground] 开始抠图: image_url={req.image_url[:80]}..., scene={req.scene}"
)
result = await mediakit_service.remove_background(
image_url=req.image_url,
scene=req.scene,
)
if not result.image_url:
logger.error(
f"[RemoveBackground] 抠图返回空 URL: raw={result.raw}"
)
raise HTTPException(status_code=500, detail="抠图失败:未返回结果图片 URL")
logger.info(f"[RemoveBackground] 抠图成功: {result.image_url[:80]}...")
# 下载抠图结果并转存到七牛云(避免前端 CORS 问题)
try:
async with httpx.AsyncClient(timeout=60.0) as client:
img_resp = await client.get(result.image_url, follow_redirects=True)
img_resp.raise_for_status()
img_content = img_resp.content
if not img_content:
raise HTTPException(status_code=500, detail="抠图结果下载失败:内容为空")
# 上传到七牛云 image bucket
qiniu = get_qiniu_service()
bucket, domain = qiniu._get_bucket_and_domain("image")
unique_name = f"{uuid.uuid4().hex[:16]}.png"
file_key = qiniu.generate_key("image", unique_name)
stream = io.BytesIO(img_content)
upload_result = await qiniu.upload_stream_async(
stream=stream,
key=file_key,
mime_type="image/png",
bucket=bucket,
domain=domain,
)
qiniu_url = upload_result.get("url")
if not qiniu_url:
raise HTTPException(status_code=500, detail="抠图结果转存到七牛云失败")
logger.info(f"[RemoveBackground] 结果已转存七牛云: {qiniu_url[:80]}...")
# 后置扣费(服务已调用成功)
await ps.consume(
db,
user_id=current_user.id,
points=required_points,
source_type="cover_avatar",
source_id=f"cover_avatar_{current_user.id}_{int(time.time() * 1000)}",
description="【封面形象抠图】",
)
await db.commit()
return success_response(
data=RemoveBackgroundResponse(url=qiniu_url),
message="抠图成功",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[RemoveBackground] 结果转存失败: {e}")
raise HTTPException(status_code=500, detail=f"抠图结果转存失败: {e}")
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(
f"[RemoveBackground] 抠图失败: image_url={req.image_url[:80]}..., error={e}"
)
raise HTTPException(status_code=500, detail=f"抠图失败: {e}")
+8
View File
@@ -7,9 +7,11 @@ from fastapi import APIRouter
from app.api.v1 import (
auth,
bgm_music,
caption,
cover_background,
events,
image,
materials,
points,
script,
@@ -59,5 +61,11 @@ api_router.include_router(cover_background.router, tags=["Cover Background"])
# 积分系统模块
api_router.include_router(points.router, tags=["Points"])
# 图片处理模块(上传 + 抠图)
api_router.include_router(image.router, tags=["Image"])
# 背景音乐模块
api_router.include_router(bgm_music.router, tags=["BGM Music"])
# 应用更新模块
api_router.include_router(update.router, prefix="/update", tags=["Update"])
+4 -4
View File
@@ -7,7 +7,7 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -44,11 +44,11 @@ async def check_update(
latest: AppRelease | None = result.scalar_one_or_none()
if not latest:
raise HTTPException(status_code=status.HTTP_204_NO_CONTENT)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# 已是最新版本(或更高)
if latest.version == version:
raise HTTPException(status_code=status.HTTP_204_NO_CONTENT)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# 查询对应平台的包
result = await db.execute(
@@ -62,7 +62,7 @@ async def check_update(
if not pkg:
# 该平台无包,返回 204(避免报错阻断用户)
raise HTTPException(status_code=status.HTTP_204_NO_CONTENT)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# 构建 Tauri 格式的响应
platform_key = f"{target}-{arch}"
+3 -97
View File
@@ -18,6 +18,7 @@ from app.config import get_settings
from app.models.user import User
from app.schemas.common import ApiResponse, success_response
from app.services.qiniu_service import get_qiniu_service
from app.utils.file_validation import check_upload_file
router = APIRouter(prefix="/upload", tags=["Upload"])
@@ -25,101 +26,6 @@ logger = logging.getLogger(__name__)
settings = get_settings()
def _validate_file_magic(content: bytes, expected_content_type: str) -> bool:
"""通过文件头魔数校验文件真实类型,防止 MIME 伪造攻击。"""
if len(content) < 12:
return False
# 拒绝常见危险文件头
dangerous_signatures = [
(b"MZ", "Windows 可执行文件"), # .exe, .dll
(b"#!", "Shell 脚本"), # bash, python, etc
(b"PK\x03\x04", "ZIP 压缩包"), # .zip, .jar, .docx
(b"<?xml", "XML 文件"),
(b"<html", "HTML 文件"),
(b"<!DO", "HTML 文档"),
(b"%PDF", "PDF 文件"),
]
for sig, _ in dangerous_signatures:
if content.startswith(sig):
return False
if b"<script" in content[:512].lower():
return False
main_type = expected_content_type.split("/")[0]
# 图片校验
if main_type == "image":
if content.startswith(b"\xff\xd8\xff"):
return expected_content_type in ("image/jpeg", "image/jpg")
if content.startswith(b"\x89PNG\r\n\x1a\n"):
return expected_content_type == "image/png"
if content.startswith(b"GIF89a") or content.startswith(b"GIF87a"):
return expected_content_type == "image/gif"
if content.startswith(b"RIFF") and content[8:12] == b"WEBP":
return expected_content_type == "image/webp"
return False
# 视频校验
if main_type == "video":
# MP4 / MOV / M4V 等 ISO Base Media File Format
if content[4:8] == b"ftyp":
brand = content[8:12]
if brand in (b"qt ", b"qtw "):
return expected_content_type in ("video/quicktime",)
# mp4, isom, avc1, mp41, mp42 等
return expected_content_type in (
"video/mp4",
"video/quicktime",
)
if content.startswith(b"RIFF") and content[8:12] == b"AVI ":
return expected_content_type == "video/x-msvideo"
if content.startswith(b"\x1aE\xdf\xa3"):
return expected_content_type == "video/webm"
return False
# 音频校验
if main_type == "audio":
if content[:3] == b"ID3" or content[:2] in (
b"\xff\xfb",
b"\xff\xf3",
b"\xff\xf2",
):
return expected_content_type in ("audio/mpeg", "audio/mp3")
if content.startswith(b"RIFF") and content[8:12] == b"WAVE":
return expected_content_type in ("audio/wav", "audio/x-wav")
if content.startswith(b"fLaC"):
return expected_content_type == "audio/flac"
if content.startswith(b"OggS"):
return expected_content_type == "audio/ogg"
# AAC / M4A(也是 ftyp 格式)
if content[4:8] == b"ftyp":
brand = content[8:12]
if brand in (b"M4A ", b"m4a ", b"mp42", b"isom", b"M4P "):
return expected_content_type in (
"audio/mp4",
"audio/aac",
"audio/m4a",
)
return False
return False
def _check_upload_file(content: bytes, max_size: int, content_type: str, type_label: str) -> None:
"""统一校验文件大小和魔数,失败时直接抛 HTTPException。"""
if len(content) > max_size:
max_mb = max_size // 1024 // 1024
raise HTTPException(
status_code=413,
detail=f"{type_label}文件大小不能超过 {max_mb}MB",
)
if not _validate_file_magic(content, content_type):
raise HTTPException(
status_code=400,
detail=f"{type_label}文件内容与实际格式不符,可能存在安全风险",
)
class UploadResponse(BaseModel):
"""上传响应"""
@@ -173,7 +79,7 @@ async def upload_video(
raise HTTPException(status_code=400, detail="文件内容为空")
# 校验大小和魔数
_check_upload_file(
check_upload_file(
content,
settings.UPLOAD_MAX_VIDEO_SIZE,
content_type,
@@ -269,7 +175,7 @@ async def upload_audio(
raise HTTPException(status_code=400, detail="文件内容为空")
# 校验大小和魔数
_check_upload_file(
check_upload_file(
content,
settings.UPLOAD_MAX_AUDIO_SIZE,
content_type,
+18
View File
@@ -106,6 +106,9 @@ class Settings(BaseSettings):
VOLCENGINE_CAPTION_APPID: str | None = Field(default=None, description="火山字幕 AppID")
VOLCENGINE_CAPTION_TOKEN: str | None = Field(default=None, description="火山字幕 Token")
# 火山引擎 MediaKit 服务(背景移除等多媒体处理)
VOLCENGINE_MEDIAKIT_TOKEN: str | None = Field(default=None, description="火山引擎 MediaKit Token")
# Vidu 密钥(base_url 已从 Settings 移除,改用 config/platform-config.yaml 配置)
VIDU_API_KEY: str | None = Field(default=None, description="Vidu API Key")
@@ -134,6 +137,10 @@ class Settings(BaseSettings):
SMS_EXTENDED_CODE: str | None = Field(
default=None, description="B2M 短信平台扩展码(选填)"
)
SMS_CODE_WHITELIST: str = Field(
default="",
description="免验证码登录白名单(逗号分隔的手机号,如 13800138000,13900139000",
)
@@ -175,6 +182,17 @@ class Settings(BaseSettings):
"""是否使用 Redis"""
return bool(self.REDIS_HOST)
@property
def sms_code_whitelist_set(self) -> set[str]:
"""免验证码登录白名单(去重、去空格)"""
if not self.SMS_CODE_WHITELIST:
return set()
return {
mobile.strip()
for mobile in self.SMS_CODE_WHITELIST.split(",")
if mobile.strip()
}
@lru_cache
def get_settings() -> Settings:
+2
View File
@@ -10,6 +10,7 @@ CRUD 模块
user_obj = await user.get(db, id="xxx")
"""
from app.crud.bgm_music import bgm_music
from app.crud.broll_category import broll_category
from app.crud.broll_material import broll_material
from app.crud.cover_background import cover_background
@@ -17,6 +18,7 @@ from app.crud.user import user
__all__ = [
"user",
"bgm_music",
"broll_category",
"broll_material",
"cover_background",
+37
View File
@@ -0,0 +1,37 @@
"""
背景音乐 CRUD
============
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud.base import CRUDBase
from app.models.bgm_music import BgmMusic
class BgmMusicCRUD(CRUDBase[BgmMusic]):
"""背景音乐数据访问"""
def __init__(self) -> None:
super().__init__(BgmMusic)
async def get_active_by_category(
self, db: AsyncSession, *, category: str | None = None
) -> list[BgmMusic]:
"""
获取指定分类下状态为 active 的音乐列表
Args:
category: 场景分类,None 表示获取全部
"""
query = select(BgmMusic).where(BgmMusic.status == "active")
if category:
query = query.where(BgmMusic.category == category)
query = query.order_by(BgmMusic.sort_order.asc(), BgmMusic.id.asc())
result = await db.execute(query)
return list(result.scalars().all())
# 导出实例
bgm_music = BgmMusicCRUD()
+35
View File
@@ -66,6 +66,10 @@ async def lifespan(app: FastAPI):
timeout=httpx.Timeout(60.0, connect=5.0),
limits=httpx.Limits(max_connections=10, max_keepalive_connections=10),
),
"volcengine_mediakit": httpx.AsyncClient(
timeout=httpx.Timeout(60.0, connect=5.0),
limits=httpx.Limits(max_connections=10, max_keepalive_connections=10),
),
"default": httpx.AsyncClient(
timeout=httpx.Timeout(30.0, connect=5.0),
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
@@ -90,6 +94,18 @@ async def lifespan(app: FastAPI):
logger.warning(f"Volcengine Caption Provider 初始化跳过: {e}")
app.state.volcengine_caption_provider = None
# 火山 Mediakit Provider
from app.ai.providers.volcengine_mediakit_provider import VolcengineMediakitProvider
try:
app.state.volcengine_mediakit_provider = VolcengineMediakitProvider(
client=app.state.http_clients["volcengine_mediakit"]
)
logger.info("Volcengine Mediakit Provider initialized")
except Exception as e:
logger.warning(f"Volcengine Mediakit Provider 初始化跳过: {e}")
app.state.volcengine_mediakit_provider = None
# 火山方舟 Provider(可选,需要 API Key
try:
from app.ai.providers.volcengine_provider import VolcengineProvider
@@ -104,6 +120,7 @@ async def lifespan(app: FastAPI):
from app.ai.adapters.vidu_adapter import ViduAdapter
from app.ai.adapters.volcengine_ark_adapter import VolcengineArkAdapter
from app.ai.adapters.volcengine_caption_adapter import VolcengineCaptionAdapter
from app.ai.adapters.volcengine_mediakit_adapter import VolcengineMediakitAdapter
from app.platform_gateway import PlatformGateway
app.state.vidu_adapter = ViduAdapter(app.state.vidu_provider)
@@ -122,6 +139,15 @@ async def lifespan(app: FastAPI):
)
logger.info("VolcengineCaptionAdapter initialized")
if app.state.volcengine_mediakit_provider:
app.state.volcengine_mediakit_adapter = VolcengineMediakitAdapter(
app.state.volcengine_mediakit_provider
)
app.state.platform_gateway.register(
"volcengine_mediakit", app.state.volcengine_mediakit_adapter
)
logger.info("VolcengineMediakitAdapter initialized")
if app.state.volcengine_provider:
app.state.volcengine_ark_adapter = VolcengineArkAdapter(
app.state.volcengine_provider
@@ -145,6 +171,7 @@ async def lifespan(app: FastAPI):
# 初始化 Service(传入 Gateway
from app.services.vidu_service import ViduService
from app.services.volcengine_caption_service import VolcengineCaptionService
from app.services.volcengine_mediakit_service import VolcengineMediakitService
app.state.vidu_service = ViduService(app.state.platform_gateway)
logger.info("Vidu Service initialized")
@@ -157,6 +184,14 @@ async def lifespan(app: FastAPI):
else:
app.state.volcengine_caption_service = None
if app.state.volcengine_mediakit_provider:
app.state.volcengine_mediakit_service = VolcengineMediakitService(
app.state.platform_gateway
)
logger.info("Volcengine Mediakit Service initialized")
else:
app.state.volcengine_mediakit_service = None
# LLM Gateway(可选,向后兼容)
if app.state.volcengine_provider:
from app.ai.gateways.llm_gateway import LLMGateway
+2
View File
@@ -7,6 +7,7 @@
"""
from app.models.base import BaseModel, BaseModelBigInt
from app.models.bgm_music import BgmMusic
from app.models.broll_category import BrollCategory
from app.models.broll_material import BrollMaterial
from app.models.broll_tag import BrollTag
@@ -21,6 +22,7 @@ from app.models.user_point import UserPoint
__all__ = [
"BaseModel",
"BaseModelBigInt",
"BgmMusic",
"User",
"UserDevice",
"UserPoint",
+50
View File
@@ -0,0 +1,50 @@
"""
背景音乐模型
==========
装修行业场景化 BGM 库,按知识科普/案例展示/促销活动/家居生活/智能家居
五个场景分类管理。
"""
from sqlalchemy import Float, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BaseModelBigInt
class BgmMusic(BaseModelBigInt):
"""
背景音乐表
Attributes:
title: 音乐名称
artist: 艺术家
category: 场景分类 (knowledge/showcase/promotion/lifestyle/tech)
file_path: 相对文件路径(如 knowledge/3_Dance_with_Me.mp3
duration: 时长(秒)
status: 状态 (active/inactive)
sort_order: 排序权重(越小越靠前)
"""
__tablename__ = "mjk_bgm_musics"
title: Mapped[str] = mapped_column(String(255), nullable=False, comment="音乐名称")
artist: Mapped[str] = mapped_column(String(255), nullable=True, comment="艺术家")
category: Mapped[str] = mapped_column(
String(32), nullable=False, index=True, comment="场景分类"
)
file_path: Mapped[str] = mapped_column(
String(512), nullable=False, comment="相对文件路径"
)
url: Mapped[str | None] = mapped_column(
String(1024), nullable=True, comment="七牛云 URL"
)
duration: Mapped[float] = mapped_column(
Float, nullable=True, comment="时长(秒)"
)
status: Mapped[str] = mapped_column(
String(16), default="active", nullable=False, comment="状态: active/inactive"
)
sort_order: Mapped[int] = mapped_column(
Integer, default=0, nullable=False, comment="排序权重"
)
+29
View File
@@ -0,0 +1,29 @@
"""
背景音乐 Schema
==============
"""
from pydantic import BaseModel, Field
class BgmMusicItem(BaseModel):
"""背景音乐项"""
id: int = Field(description="音乐ID")
title: str = Field(description="音乐名称")
artist: str | None = Field(default=None, description="艺术家")
category: str = Field(description="场景分类")
file_path: str = Field(description="相对文件路径")
url: str | None = Field(default=None, description="七牛云 URL")
duration: float | None = Field(default=None, description="时长(秒)")
sort_order: int = Field(default=0, description="排序权重")
class Config:
from_attributes = True
class BgmMusicListResponse(BaseModel):
"""背景音乐列表响应"""
items: list[BgmMusicItem] = Field(description="音乐列表")
total: int = Field(description="总数")
+4 -2
View File
@@ -18,6 +18,7 @@ from typing import Any
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.redis_client import get_redis_client
from app.core.security import (
create_access_token,
@@ -188,8 +189,9 @@ async def login_with_sms(
5. 创建/覆盖设备记录
6. 签发双 Token
"""
# 1. 校验验证码
if not await verify_sms_code(mobile, code):
# 1. 校验验证码(白名单内的手机号跳过校验)
settings = get_settings()
if mobile not in settings.sms_code_whitelist_set and not await verify_sms_code(mobile, code):
raise ValueError("验证码错误或已过期")
# 2. 查询用户(不再自动注册)
+1
View File
@@ -329,6 +329,7 @@ _CATEGORY_MAP: dict[str, str] = {
"compose": "压制成片",
"subtitle_burn": "字幕烧录",
"cover_design": "封面设计",
"cover_avatar": "封面形象",
"wxpay": "充值",
"compensation": "充值",
"invite": "充值",
@@ -0,0 +1,89 @@
"""
火山引擎 MediaKit Service
=========================
通过 PlatformGateway 调用 MediaKit 第三方 API,自身负责:
- 参数校验
- 结果提取与格式化
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
from app.ai.adapters.constants import Method
from app.platform_gateway import PlatformGateway
logger = logging.getLogger(__name__)
@dataclass
class RemoveBackgroundResult:
"""抠图结果"""
image_url: str = ""
raw: dict[str, Any] = field(default_factory=dict)
class VolcengineMediakitService:
"""火山引擎 MediaKit 服务封装"""
# 支持的场景
SUPPORTED_SCENES = {"general", "human", "product"}
def __init__(self, gateway: PlatformGateway):
self.gateway = gateway
async def remove_background(
self,
image_url: str,
scene: str = "human",
) -> RemoveBackgroundResult:
"""同步抠图
Args:
image_url: 原始图片 URL
scene: 场景类型,"general"(通用)、"human"(人物)或 "product"(商品)
Returns:
RemoveBackgroundResult: 包含抠图结果图片 URL
Raises:
ValueError: 参数校验失败
PlatformError: 平台调用失败
"""
if not image_url:
raise ValueError("image_url 不能为空")
if scene not in self.SUPPORTED_SCENES:
raise ValueError(f"不支持的场景: {scene},可选: {self.SUPPORTED_SCENES}")
# 人物/商品场景默认启用白色描边 + 裁剪背景
enable_contour = scene in ("human", "product")
payload = {
"image_url": image_url,
"scene": scene,
"need_contour": enable_contour,
"contour_color": "#FFFFFF",
"contour_size": 20,
"need_crop_background": enable_contour,
}
response = await self.gateway.call_sync(
platform="volcengine_mediakit",
method=Method.REMOVE_BACKGROUND,
payload=payload,
)
if not response.success:
raise RuntimeError(
response.error_message or "抠图失败"
)
result_image_url = (response.data or {}).get("image_url", "")
return RemoveBackgroundResult(
image_url=result_image_url,
raw=response.data or {},
)
+105
View File
@@ -0,0 +1,105 @@
"""
文件校验工具
==========
提供文件头魔数校验和上传文件统一校验功能,
防止 MIME 伪造攻击和危险文件上传。
"""
from fastapi import HTTPException
def validate_file_magic(content: bytes, expected_content_type: str) -> bool:
"""通过文件头魔数校验文件真实类型,防止 MIME 伪造攻击。"""
if len(content) < 12:
return False
# 拒绝常见危险文件头
dangerous_signatures = [
(b"MZ", "Windows 可执行文件"), # .exe, .dll
(b"#!", "Shell 脚本"), # bash, python, etc
(b"PK\x03\x04", "ZIP 压缩包"), # .zip, .jar, .docx
(b"<?xml", "XML 文件"),
(b"<html", "HTML 文件"),
(b"<!DO", "HTML 文档"),
(b"%PDF", "PDF 文件"),
]
for sig, _ in dangerous_signatures:
if content.startswith(sig):
return False
if b"<script" in content[:512].lower():
return False
main_type = expected_content_type.split("/")[0]
# 图片校验
if main_type == "image":
if content.startswith(b"\xff\xd8\xff"):
return expected_content_type in ("image/jpeg", "image/jpg")
if content.startswith(b"\x89PNG\r\n\x1a\n"):
return expected_content_type == "image/png"
if content.startswith(b"GIF89a") or content.startswith(b"GIF87a"):
return expected_content_type == "image/gif"
if content.startswith(b"RIFF") and content[8:12] == b"WEBP":
return expected_content_type == "image/webp"
return False
# 视频校验
if main_type == "video":
# MP4 / MOV / M4V 等 ISO Base Media File Format
if content[4:8] == b"ftyp":
brand = content[8:12]
if brand in (b"qt ", b"qtw "):
return expected_content_type in ("video/quicktime",)
# mp4, isom, avc1, mp41, mp42 等
return expected_content_type in (
"video/mp4",
"video/quicktime",
)
if content.startswith(b"RIFF") and content[8:12] == b"AVI ":
return expected_content_type == "video/x-msvideo"
if content.startswith(b"\x1aE\xdf\xa3"):
return expected_content_type == "video/webm"
return False
# 音频校验
if main_type == "audio":
if content[:3] == b"ID3" or content[:2] in (
b"\xff\xfb",
b"\xff\xf3",
b"\xff\xf2",
):
return expected_content_type in ("audio/mpeg", "audio/mp3")
if content.startswith(b"RIFF") and content[8:12] == b"WAVE":
return expected_content_type in ("audio/wav", "audio/x-wav")
if content.startswith(b"fLaC"):
return expected_content_type == "audio/flac"
if content.startswith(b"OggS"):
return expected_content_type == "audio/ogg"
# AAC / M4A(也是 ftyp 格式)
if content[4:8] == b"ftyp":
brand = content[8:12]
if brand in (b"M4A ", b"m4a ", b"mp42", b"isom", b"M4P "):
return expected_content_type in (
"audio/mp4",
"audio/aac",
"audio/m4a",
)
return False
return False
def check_upload_file(content: bytes, max_size: int, content_type: str, type_label: str) -> None:
"""统一校验文件大小和魔数,失败时直接抛 HTTPException。"""
if len(content) > max_size:
max_mb = max_size // 1024 // 1024
raise HTTPException(
status_code=413,
detail=f"{type_label}文件大小不能超过 {max_mb}MB",
)
if not validate_file_magic(content, content_type):
raise HTTPException(
status_code=400,
detail=f"{type_label}文件内容与实际格式不符,可能存在安全风险",
)
+17
View File
@@ -87,6 +87,23 @@ platforms:
qps: 5
burst: 10
# ── 火山引擎媒体处理(MediaKit)──
volcengine_mediakit:
name: "火山引擎媒体处理"
provider: "volcengine_mediakit"
base_url: "https://mediakit.cn-beijing.volces.com"
rate_limit:
qps: 5
burst: 10
models: []
methods:
remove_background:
timeout: 120
max_connections: 10
rate_limit:
qps: 5
burst: 10
# ── 任务默认模型映射 ──
# 当调用方未指定模型时,按任务类型选择默认模型
task_defaults:
+11 -9
View File
@@ -24,10 +24,13 @@ fixed_costs:
voice_clone: 200
# 字幕烧录:将生成的字幕文件烧录到视频中(FFmpeg 合成)
subtitle_burn: 2
subtitle_burn: 5
# 封面设计:根据视频内容自动生成封面图
cover_design: 2
cover_design: 5
# 封面形象:上传人物照片 AI 抠图生成透明背景形象
cover_avatar: 10
# 压制成片:将多个素材片段合并为最终视频(FFmpeg 拼接)
compose: 5
@@ -52,11 +55,11 @@ duration_based_costs:
# 计算依据:中文正常朗读语速约 200~250 字/分钟,取 240 字/分钟:
# 60秒 ÷ 240字 = 0.25 秒/字
# 注意:此为经验值,未经过 Vidu TTS 实测校准。实际时长受标点停顿、
# 数字/英文混合、TTS 角色风格等因素影响,误差约 ±20%。
# 数字/英文混合、TTS 角色风格等因素影响,误差约 ±20%。
# TODO: 收集实测数据后校准此值。
seconds_per_char: 0.25
# ── 视频生成(对口型)──
# ── 视频生成 ──
# 计费公式:max(min_points, ceil(实际视频秒数) × multiplier)
# 说明:秒数先向上取整为整数,再乘以 multiplier。不足 1 秒按 1 秒计算。
# 示例:4.5秒视频 → ceil(4.5) × 1 = 5 积分;0.8秒 → ceil(0.8) × 1 = 1 积分
@@ -66,8 +69,7 @@ duration_based_costs:
# 预估参数(执行业务前检查余额时使用)
estimation:
# 是否直接使用输入素材秒数作为预估上限
# 视频生成时长 = 输入音频/素材时长,因此用 input_seconds 预估最准确。
# 视频生成时长 = 输入音频时长,因此用 input_seconds 预估最准确
# 调用方需传入 input_seconds 参数。
use_input_seconds: true
@@ -77,10 +79,10 @@ duration_based_costs:
# 支持积分赠送:points 为实际到账积分数,amount_rmb 为支付金额(分)。
# label 为空时不显示标签角标。
recharge_options:
- { price: 10000, points: 2000, label: "", validity_days: 180 }
- { price: 10000, points: 2000, label: "", validity_days: 90 }
- { price: 50000, points: 11000, label: "热销", validity_days: 180 }
- { price: 100000, points: 23000, label: "推荐", validity_days: 365 }
- { price: 500000, points: 125000, label: "超值", validity_days: 0 }
- { price: 100000, points: 22500, label: "推荐", validity_days: 180 }
- { price: 500000, points: 120000, label: "超值", validity_days: 365 }
# ── 免费业务(不扣积分)───────────────────────────────
Regular → Executable
+12 -6
View File
@@ -15,6 +15,7 @@ echo "========================================"
PROJECT_DIR="/opt/meijiaka-zy"
GIT_REPO="http://git2.haodian.cn/xiaoyu/meijiaka-zy.git"
API_PORT=8081
COMPOSE_FILE="docker-compose.test.yml"
# 1. 检查 Docker
echo "[1/7] 检查 Docker 环境..."
@@ -34,7 +35,8 @@ docker compose version || echo "docker-compose 版本: $(docker-compose --versio
echo "[2/7] 更新代码..."
if [ -d "$PROJECT_DIR/.git" ]; then
cd "$PROJECT_DIR"
git pull origin master
git fetch origin master
git reset --hard origin/master
else
git clone "$GIT_REPO" "$PROJECT_DIR"
cd "$PROJECT_DIR"
@@ -83,12 +85,12 @@ echo "✅ 环境变量检查通过"
# 5. 构建镜像
echo "[5/7] 构建 Docker 镜像..."
docker compose -f docker-compose.test.yml build --no-cache
docker compose -f "$COMPOSE_FILE" build --pull
# 6. 启动服务
echo "[6/7] 启动服务..."
docker compose -f docker-compose.test.yml down 2>/dev/null || true
docker compose -f docker-compose.test.yml up -d
docker compose -f "$COMPOSE_FILE" down 2>/dev/null || true
docker compose -f "$COMPOSE_FILE" up -d
# 7. 等待并验证
echo "[7/7] 等待服务启动..."
@@ -98,6 +100,10 @@ MAX_RETRY=12
RETRY=0
while [ $RETRY -lt $MAX_RETRY ]; do
if curl -s http://localhost:$API_PORT/health | grep -q "healthy"; then
# 验证 scheduler 容器也在运行
if ! docker ps --filter "name=meijiaka-zy-scheduler" --filter "status=running" -q | grep -q .; then
echo "⚠️ API 已就绪,但 scheduler 容器未运行,请检查日志"
fi
echo ""
echo "========================================"
echo " ✅ 测试服部署成功!"
@@ -114,8 +120,8 @@ while [ $RETRY -lt $MAX_RETRY ]; do
echo ""
echo " 常用命令:"
echo " cd $PROJECT_DIR/python-api"
echo " docker compose -f docker-compose.test.yml logs -f"
echo " docker compose -f docker-compose.test.yml restart api"
echo " docker compose -f $COMPOSE_FILE logs -f"
echo " docker compose -f $COMPOSE_FILE restart api"
echo "========================================"
exit 0
fi
+3
View File
@@ -61,6 +61,9 @@ dependencies = [
# 音频时长探测(TTS 扣费用)
"mutagen~=1.47.0",
# 图像处理(智能抠图合成封面)
"Pillow>=11.0.0",
]
[project.optional-dependencies]
File diff suppressed because it is too large Load Diff
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
导入 Mixkit BGM 音频文件到数据库并上传七牛云
扫描 mixkit_bgm/ 目录下的分类文件夹,读取音频元数据并插入 mjk_bgm_musics 表
同时上传音频到七牛云视频/音频 bucket,保存 URL。
"""
import asyncio
import os
import sys
from pathlib import Path
# 将项目根目录加入 Python 路径
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
# 加载环境变量
from dotenv import load_dotenv
load_dotenv()
from mutagen.mp3 import MP3
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.models.bgm_music import BgmMusic
from app.services.qiniu_service import get_qiniu_service
BGM_BASE_DIR = Path("/Users/0fun/work/meijiaka-zy/mixkit_bgm")
def parse_title(filename: str) -> str:
"""从文件名解析标题,如 '105_See_Line_Funk.mp3' -> 'See Line Funk'"""
# 去掉扩展名
name = filename.rsplit(".", 1)[0]
# 去掉开头的编号前缀(如 105_
parts = name.split("_", 1)
if len(parts) > 1 and parts[0].isdigit():
title_part = parts[1]
else:
title_part = name
# 将下划线替换为空格
return title_part.replace("_", " ")
def get_duration(filepath: Path) -> float | None:
"""获取音频文件时长(秒)"""
try:
audio = MP3(str(filepath))
return audio.info.length
except Exception as e:
print(f" 无法读取时长: {filepath.name} ({e})")
return None
async def import_bgm():
"""扫描目录并导入数据库,同时上传七牛云"""
if not BGM_BASE_DIR.exists():
print(f"错误: BGM 目录不存在: {BGM_BASE_DIR}")
return
categories = [d.name for d in BGM_BASE_DIR.iterdir() if d.is_dir()]
print(f"发现分类: {categories}")
# 初始化七牛云服务
try:
qiniu = get_qiniu_service()
print("七牛云服务初始化成功")
except ValueError as e:
print(f"七牛云配置错误: {e}")
return
async with AsyncSessionLocal() as session:
# 清空现有数据(可选)
result = await session.execute(select(BgmMusic))
existing = result.scalars().all()
if existing:
print(f"数据库中已有 {len(existing)} 条 BGM 记录,将删除后重新导入")
for row in existing:
await session.delete(row)
await session.commit()
total = 0
upload_ok = 0
upload_fail = 0
for category in sorted(categories):
cat_dir = BGM_BASE_DIR / category
files = sorted([f for f in cat_dir.iterdir() if f.suffix.lower() in (".mp3", ".wav", ".m4a")])
print(f"\n分类 [{category}]: {len(files)}")
for idx, filepath in enumerate(files):
title = parse_title(filepath.name)
relative_path = f"{category}/{filepath.name}"
duration = get_duration(filepath)
# 上传七牛云
qiniu_key = f"meijiaka-zy/bgm/{relative_path}"
url = None
try:
upload_result = qiniu.upload_file(
local_path=str(filepath),
key=qiniu_key,
file_type="audio",
check_duplicate=True,
)
url = upload_result.get("url")
if upload_result.get("isDuplicate"):
print(f" + {title} (复用已有文件)")
else:
print(f" + {title} (上传成功)")
upload_ok += 1
except Exception as e:
print(f" ! {title} (上传失败: {e})")
upload_fail += 1
# 上传失败也继续入库,只是 url 为空
bgm = BgmMusic(
title=title,
artist=None,
category=category,
file_path=relative_path,
url=url,
duration=duration,
status="active",
sort_order=idx,
)
session.add(bgm)
total += 1
await session.commit()
print(f"\n导入完成,共 {total}")
print(f" 上传成功: {upload_ok}")
print(f" 上传失败: {upload_fail}")
if __name__ == "__main__":
asyncio.run(import_bgm())
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""
BGM 种子数据导入脚本
==================
用于部署环境初始化 BGM 数据。读取同目录下的 bgm_seed_data.json
将 129 首系统背景音乐的元数据(含七牛云 URL)写入数据库。
执行方式(在 API 容器内):
python scripts/seed_bgm.py
环境变量依赖:
DATABASE_URL — 同应用配置,容器启动时已注入
"""
import asyncio
import json
import os
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from dotenv import load_dotenv
load_dotenv()
from sqlalchemy import select, func
from app.db.session import AsyncSessionLocal
from app.models.bgm_music import BgmMusic
SEED_FILE = Path(__file__).parent / "bgm_seed_data.json"
async def seed_bgm():
if not SEED_FILE.exists():
print(f"错误: 种子数据文件不存在: {SEED_FILE}")
return
with open(SEED_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
async with AsyncSessionLocal() as session:
# 检查是否已有数据
count = await session.scalar(select(func.count(BgmMusic.id)))
if count and count > 0:
print(f"数据库中已有 {count} 条 BGM 记录,跳过导入")
print("如需强制重新导入,请先清空 mjk_bgm_musics 表")
return
for idx, item in enumerate(data):
bgm = BgmMusic(
title=item["title"],
artist=item.get("artist"),
category=item["category"],
file_path=item["file_path"],
url=item.get("url"),
duration=item.get("duration"),
status=item.get("status", "active"),
sort_order=item.get("sort_order", idx),
)
session.add(bgm)
await session.commit()
print(f"种子数据导入完成,共 {len(data)}")
if __name__ == "__main__":
asyncio.run(seed_bgm())
+60
View File
@@ -957,6 +957,7 @@ dependencies = [
{ name = "openai" },
{ name = "orjson" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "pillow" },
{ name = "psycopg2-binary" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
@@ -999,6 +1000,7 @@ requires-dist = [
{ name = "openai", specifier = "~=1.58.0" },
{ name = "orjson", specifier = ">=3.11.0" },
{ name = "passlib", extras = ["bcrypt"], specifier = "~=1.7.4" },
{ name = "pillow", specifier = ">=11.0.0" },
{ name = "pip-audit", marker = "extra == 'dev'", specifier = "~=2.7.0" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = "~=4.0.0" },
{ name = "psycopg2-binary", specifier = "~=2.9.10" },
@@ -1280,6 +1282,64 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
]
[[package]]
name = "pip"
version = "26.0.1"
+61 -20
View File
@@ -1,14 +1,17 @@
#!/usr/bin/env python3
"""生成圆角图标:原图(白底+内容)作为整体,缩放居中,圆角外透明"""
"""生成圆角图标:原图(透明背景 logo)作为整体,缩放居中,圆角外透明"""
import os
from PIL import Image, ImageDraw
ICONS_DIR = "/Users/0fun/work/meijiaka-zy/tauri-app/src-tauri/icons"
SOURCE_PNG = "/tmp/original-source-icon.png"
SOURCE_PNG = "/Users/0fun/work/meijiaka-zy/tauri-app/public/assets/logo.png"
# macOS Big Sur 圆角比例 ≈ 22.6%
CORNER_RATIO = 0.226
CORNER_RATIO_MACOS = 0.226
# Windows 11 风格圆角比例 ≈ 18%Fluent Design 圆角矩形)
CORNER_RATIO_WINDOWS = 0.18
# 内容占画布比例(参考腾讯视频 ≈ 80.5%)
CONTENT_RATIO = 0.805
@@ -43,14 +46,33 @@ def create_rounded_rect_mask(size: int, radius: int) -> Image.Image:
return mask
def prepare_source(source: Image.Image, canvas_size: int = 1024) -> Image.Image:
"""将源图居中放置在正方形透明画布上,作为后续处理的统一源图"""
src_w, src_h = source.size
canvas = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
# 等比缩放,长边充满画布的 CONTENT_RATIO
target_size = int(canvas_size * CONTENT_RATIO)
ratio = max(target_size / src_w, target_size / src_h)
new_w = int(src_w * ratio)
new_h = int(src_h * ratio)
resized = source.resize((new_w, new_h), Image.LANCZOS)
# 居中放置
left = (canvas_size - new_w) // 2
top = (canvas_size - new_h) // 2
canvas.paste(resized, (left, top), resized)
return canvas
def compose_icon(size: int, source: Image.Image, rounded: bool = True) -> Image.Image:
"""原图作为整体,缩放至画布 CONTENT_RATIO,居中"""
"""macOS / Linux 图标:内容占画布 80.5%,大圆角"""
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
plate_size = int(size * CONTENT_RATIO)
plate_offset = (size - plate_size) // 2
# 图等比缩放,短边充满 plate_size
# 图等比缩放,短边充满 plate_size
src_w, src_h = source.size
ratio = max(plate_size / src_w, plate_size / src_h)
new_w = int(src_w * ratio)
@@ -64,18 +86,20 @@ def compose_icon(size: int, source: Image.Image, rounded: bool = True) -> Image.
if rounded:
# 圆角蒙版裁剪(macOS / Linux
radius = int(plate_size * CORNER_RATIO)
radius = int(plate_size * CORNER_RATIO_MACOS)
mask = create_rounded_rect_mask(plate_size, radius)
canvas.paste(img, (plate_offset, plate_offset), mask)
else:
# 正方形填满Windows 专用)
# 正方形填满
canvas.paste(img, (plate_offset, plate_offset))
return canvas
def compose_icon_windows(size: int, source: Image.Image) -> Image.Image:
"""Windows 专用:原图填满整个画布 100%无圆角,无透明边距"""
# 原图等比缩放,短边充满画布
def compose_icon_windows(size: int, source: Image.Image, rounded: bool = True) -> Image.Image:
"""Windows 图标:原图填满整个画布 100%支持轻微圆角"""
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
# 源图等比缩放,短边充满画布
src_w, src_h = source.size
ratio = max(size / src_w, size / src_h)
new_w = int(src_w * ratio)
@@ -85,7 +109,17 @@ def compose_icon_windows(size: int, source: Image.Image) -> Image.Image:
# 居中裁剪到画布尺寸
left = (new_w - size) // 2
top = (new_h - size) // 2
return resized.crop((left, top, left + size, top + size))
img = resized.crop((left, top, left + size, top + size))
if rounded:
# Windows 11 风格轻微圆角
radius = max(1, int(size * CORNER_RATIO_WINDOWS))
mask = create_rounded_rect_mask(size, radius)
canvas.paste(img, (0, 0), mask)
else:
canvas.paste(img, (0, 0))
return canvas
def generate_icns(source: Image.Image, output_path: str):
@@ -112,17 +146,18 @@ def generate_icns(source: Image.Image, output_path: str):
def generate_ico(source: Image.Image, output_path: str):
"""生成 Windows .ico 文件"""
"""生成 Windows .ico 文件,包含更多尺寸以支持高 DPI"""
import struct
import io
sizes = [16, 24, 32, 48, 64, 128, 256]
# 添加 20x20 和 40x40 以支持 Windows 高 DPI (125%, 150%)
sizes = [16, 20, 24, 32, 40, 48, 64, 128, 256]
png_datas = []
entries = []
for sz in sizes:
# Windows .ico 填满画布,无圆角,无透明边距
img = compose_icon_windows(sz, source)
# Windows 图标使用轻微圆角
img = compose_icon_windows(sz, source, rounded=True)
buf = io.BytesIO()
img.save(buf, format="PNG")
data = buf.getvalue()
@@ -149,18 +184,24 @@ def generate_ico(source: Image.Image, output_path: str):
def main():
source = Image.open(SOURCE_PNG).convert("RGBA")
print(f"源图标尺寸: {source.size}")
# 加载原始 logo(透明背景)
raw_source = Image.open(SOURCE_PNG).convert("RGBA")
print(f"原始 logo 尺寸: {raw_source.size}")
# 预处理:居中放置在 1024x1024 透明画布上
source = prepare_source(raw_source, canvas_size=1024)
print(f"预处理后源图尺寸: {source.size}")
# macOS / Linux PNG 图标(大圆角 + 透明边距)
for filename, size in PNG_SIZES:
path = os.path.join(ICONS_DIR, filename)
compose_icon(size, source).save(path)
compose_icon(size, source, rounded=True).save(path)
print(f"已生成: {filename} ({size}x{size})")
# Windows Square Logo 填满画布,无圆角,无透明边距
# Windows Square Logo(轻微圆角 + 填满画布)
for filename, size in SQUARE_SIZES:
path = os.path.join(ICONS_DIR, filename)
compose_icon_windows(size, source).save(path)
compose_icon_windows(size, source, rounded=True).save(path)
print(f"已生成: {filename} ({size}x{size})")
generate_icns(source, os.path.join(ICONS_DIR, "icon.icns"))
+5 -5
View File
@@ -23,12 +23,12 @@
### 2.2 密钥管理
**公钥**
- 文件: `src-tauri/tauri.key.pub`
- 文件: `src-tauri/.tauri-signing-key.pub`
- 已嵌入 `tauri.conf.json``plugins.updater.pubkey`
- **已提交到 Git 仓库**(公钥可以公开)
**私钥**
- 文件: `tauri.key`**已删除,未提交到 Git**
- 文件: `.tauri-signing-key`**已加入 .gitignore,未提交到 Git**
- 必须保存在安全位置(如 GitLab CI/CD Variables、1Password、公司密码管理器)
- **切勿泄露或提交到仓库**
@@ -48,11 +48,11 @@
```bash
cd tauri-app
npm run tauri signer generate -- --write-keys src-tauri/tauri.key
npm run tauri signer generate -- --write-keys src-tauri/.tauri-signing-key
```
然后:
1. 将新生成的公钥(`tauri.key.pub` 内容)更新到 `tauri.conf.json`
1. 将新生成的公钥(`.tauri-signing-key.pub` 内容)更新到 `tauri.conf.json`
2. 将新生成的私钥保存到 GitLab CI/CD Variables
3. **旧版本客户端将无法通过自动更新升级**(公钥不匹配),必须重新下载安装包
@@ -120,5 +120,5 @@ npm run tauri signer generate -- --write-keys src-tauri/tauri.key
## 五、相关文件
- `src-tauri/tauri.conf.json` — updater 公钥配置
- `src-tauri/tauri.key.pub` — minisign 公钥(已提交)
- `src-tauri/.tauri-signing-key.pub` — minisign 公钥(已提交)
- `.gitlab-ci.yml` — CI 构建流程与签名环境变量
+63 -44
View File
@@ -28,7 +28,7 @@
"zustand": "^5.0.12"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@tauri-apps/cli": "^2.11.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/qrcode": "^1.5.6",
@@ -1818,7 +1818,9 @@
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.10.0",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz",
"integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
@@ -1832,21 +1834,23 @@
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.10.0",
"@tauri-apps/cli-darwin-x64": "2.10.0",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0",
"@tauri-apps/cli-linux-arm64-gnu": "2.10.0",
"@tauri-apps/cli-linux-arm64-musl": "2.10.0",
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.0",
"@tauri-apps/cli-linux-x64-gnu": "2.10.0",
"@tauri-apps/cli-linux-x64-musl": "2.10.0",
"@tauri-apps/cli-win32-arm64-msvc": "2.10.0",
"@tauri-apps/cli-win32-ia32-msvc": "2.10.0",
"@tauri-apps/cli-win32-x64-msvc": "2.10.0"
"@tauri-apps/cli-darwin-arm64": "2.11.2",
"@tauri-apps/cli-darwin-x64": "2.11.2",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.2",
"@tauri-apps/cli-linux-arm64-musl": "2.11.2",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-musl": "2.11.2",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.2",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.2",
"@tauri-apps/cli-win32-x64-msvc": "2.11.2"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.10.0",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz",
"integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==",
"cpu": [
"arm64"
],
@@ -1861,9 +1865,9 @@
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz",
"integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz",
"integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==",
"cpu": [
"x64"
],
@@ -1878,9 +1882,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz",
"integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz",
"integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==",
"cpu": [
"arm"
],
@@ -1895,13 +1899,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz",
"integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz",
"integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1912,13 +1919,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz",
"integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz",
"integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1929,13 +1939,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz",
"integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz",
"integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1946,13 +1959,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz",
"integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz",
"integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1963,13 +1979,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz",
"integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz",
"integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1980,9 +1999,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz",
"integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz",
"integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==",
"cpu": [
"arm64"
],
@@ -1997,9 +2016,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz",
"integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz",
"integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==",
"cpu": [
"ia32"
],
@@ -2014,9 +2033,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz",
"integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz",
"integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==",
"cpu": [
"x64"
],
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "tauri-app",
"private": true,
"version": "1.5.18",
"version": "1.6.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -39,7 +39,7 @@
"zustand": "^5.0.12"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@tauri-apps/cli": "^2.11.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/qrcode": "^1.5.6",
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIwOEVEODY4MTgyRkJFRTMKUldUanZpOFlhTmlPSUJzS0FLL1NMUEgzLzRtNXpsT1FoTXZlS3JLOHJvak5KeThIeDJQRFpJZWgK
+1 -1
View File
@@ -4219,7 +4219,7 @@ dependencies = [
[[package]]
name = "tauri-app"
version = "1.5.18"
version = "1.6.0"
dependencies = [
"base64 0.22.1",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tauri-app"
version = "1.5.18"
version = "1.6.0"
description = "美家卡智影 - AI 视频创作桌面应用"
authors = ["美家卡科技"]
edition = "2021"
@@ -51,6 +51,8 @@
},
"dialog:default",
"dialog:allow-open",
"core:window:allow-show",
"core:window:allow-set-focus",
"updater:default"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,123 @@
//! 封面形象管理 IPC 命令
use crate::ApiResponse;
use crate::storage::cover_avatar as cover_avatar_storage;
// --------------------- 封面形象库命令 ---------------------
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CoverAvatarArgs {
pub id: String,
pub name: String,
pub image_url: String,
pub local_path: Option<String>,
pub created_at: String,
}
/// 加载封面形象库
#[tauri::command]
pub async fn load_cover_avatars() -> ApiResponse<Vec<cover_avatar_storage::CoverAvatar>> {
match cover_avatar_storage::load_cover_avatars() {
Ok(list) => ApiResponse {
code: 200,
message: "封面形象库加载成功".to_string(),
data: Some(list.avatars),
},
Err(e) => ApiResponse {
code: 500,
message: format!("加载封面形象库失败: {}", e),
data: Some(vec![]),
},
}
}
/// 保存封面形象
#[tauri::command]
pub async fn save_cover_avatar(
args: CoverAvatarArgs,
) -> ApiResponse<bool> {
let avatar = cover_avatar_storage::CoverAvatar {
id: args.id,
name: args.name,
image_url: args.image_url,
local_path: args.local_path,
created_at: args.created_at,
};
match cover_avatar_storage::add_cover_avatar(avatar) {
Ok(_) => ApiResponse {
code: 200,
message: "封面形象保存成功".to_string(),
data: Some(true),
},
Err(e) => ApiResponse {
code: 500,
message: format!("保存封面形象失败: {}", e),
data: Some(false),
},
}
}
/// 删除封面形象
#[tauri::command]
pub async fn delete_cover_avatar_cmd(
id: String,
) -> ApiResponse<bool> {
match cover_avatar_storage::delete_cover_avatar(&id) {
Ok(_) => ApiResponse {
code: 200,
message: "封面形象删除成功".to_string(),
data: Some(true),
},
Err(e) => ApiResponse {
code: 500,
message: format!("删除封面形象失败: {}", e),
data: Some(false),
},
}
}
// --------------------- 封面形象图片文件命令 ---------------------
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveCoverAvatarImageArgs {
pub avatar_id: String,
pub image_data: String, // base64 编码
pub ext: String, // 文件扩展名,如 "png"
}
/// 保存封面形象图片文件(前端传入 base64 编码)
#[tauri::command]
pub async fn save_cover_avatar_image(
args: SaveCoverAvatarImageArgs,
) -> ApiResponse<String> {
let image_bytes = match base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
&args.image_data,
) {
Ok(data) => data,
Err(e) => return ApiResponse {
code: 400,
message: format!("Invalid base64 data: {}", e),
data: None,
},
};
match cover_avatar_storage::save_cover_avatar_image(
&args.avatar_id,
&image_bytes,
&args.ext,
) {
Ok(path) => ApiResponse {
code: 200,
message: "封面形象图片保存成功".to_string(),
data: Some(path),
},
Err(e) => ApiResponse {
code: 500,
message: format!("保存封面形象图片失败: {}", e),
data: None,
},
}
}
+1
View File
@@ -11,3 +11,4 @@ pub mod project;
pub mod voice;
pub mod video_compose;
pub mod file;
pub mod cover_avatar;
@@ -174,6 +174,77 @@ pub async fn generate_empty_shot_clip(
}
}
/// 混合 BGM 请求参数
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MixBgmToVideoArgs {
pub video_path: String,
pub bgm_path: String,
pub output_path: String,
pub video_volume: Option<f64>,
pub bgm_volume: Option<f64>,
}
/// 混合背景音乐到视频(保留原音频)
#[tauri::command]
pub async fn mix_bgm_to_video(
app: AppHandle,
args: MixBgmToVideoArgs,
) -> ApiResponse<String> {
let safe_output = match sanitize_output_path(&args.output_path) {
Ok(p) => p,
Err(e) => return ApiResponse { code: 500, message: e, data: None },
};
let video_vol = args.video_volume.unwrap_or(1.0);
let bgm_vol = args.bgm_volume.unwrap_or(0.25);
// 如果输出路径和输入路径相同,使用临时文件避免 FFmpeg 报错
let use_temp = args.video_path == safe_output;
let temp_output = if use_temp {
format!("{}.tmp_bgm.mp4", safe_output)
} else {
safe_output.clone()
};
match crate::ffmpeg_cmd::mix_bgm_to_video(
&app,
&args.video_path,
&args.bgm_path,
&temp_output,
video_vol,
bgm_vol,
).await {
Ok(_) => {
if use_temp {
// 用混合后的文件替换原文件
let _ = std::fs::remove_file(&safe_output);
if let Err(e) = std::fs::rename(&temp_output, &safe_output) {
let _ = std::fs::remove_file(&temp_output);
return ApiResponse {
code: 500,
message: format!("替换原文件失败: {}", e),
data: None,
};
}
}
ApiResponse {
code: 200,
message: "BGM 混合成功".to_string(),
data: Some(safe_output),
}
}
Err(e) => {
let _ = std::fs::remove_file(&temp_output);
ApiResponse {
code: 500,
message: format!("BGM 混合失败: {}", e),
data: None,
}
}
}
}
/// 截取视频片段请求参数
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
+294 -2
View File
@@ -2,7 +2,16 @@ use tauri_plugin_shell::ShellExt;
use crate::StringResultExt;
use tauri_plugin_shell::process::CommandEvent;
use tauri::{AppHandle, Emitter};
use serde::Serialize;
use serde::{Deserialize, Serialize};
/// 视频元数据(由 ffprobe 解析)
#[derive(Debug, Serialize, Deserialize)]
pub struct VideoMetadata {
pub width: u32,
pub height: u32,
pub duration: f64,
pub fps: f64,
}
#[derive(Serialize, Clone)]
struct PhaseInfo {
@@ -330,7 +339,7 @@ fn get_fonts_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
/**
* 压制 ASS 字幕到视频(使用嵌入字体)
*
* 使用抖音美好体 (DouyinSansBold) 作为默认字体。
* 使用抖音美好体 Bold (DouyinSans Bold) 作为默认字体。
* 支持可选的 PNG overlay(用于大标题/小标题,效果与前端 Canvas 预览一致)。
*/
pub async fn burn_ass_subtitle(
@@ -376,6 +385,7 @@ pub async fn burn_ass_subtitle(
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "veryfast".to_string(),
"-crf".to_string(), "18".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-c:a".to_string(), "copy".to_string(),
"-y".to_string(),
temp_output.clone(),
@@ -394,6 +404,7 @@ pub async fn burn_ass_subtitle(
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "medium".to_string(),
"-crf".to_string(), "23".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-c:a".to_string(), "copy".to_string(),
"-y".to_string(),
safe_output.clone(),
@@ -415,6 +426,7 @@ pub async fn burn_ass_subtitle(
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "medium".to_string(),
"-crf".to_string(), "23".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-c:a".to_string(), "copy".to_string(),
"-y".to_string(),
safe_output.clone(),
@@ -436,6 +448,7 @@ pub async fn burn_ass_subtitle(
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "medium".to_string(),
"-crf".to_string(), "23".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-c:a".to_string(), "copy".to_string(),
"-y".to_string(),
safe_output,
@@ -476,6 +489,7 @@ pub async fn burn_ass_subtitle_with_fonts(
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "medium".to_string(),
"-crf".to_string(), "23".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-c:a".to_string(), "copy".to_string(),
"-y".to_string(),
safe_output
@@ -484,6 +498,53 @@ pub async fn burn_ass_subtitle_with_fonts(
run_ffmpeg(app, args).await.map(|_| ())
}
/**
* 混合背景音乐到视频(保留原视频音频)
*
* 使用 FFmpeg amix 滤镜将 BGM 与原视频音频混合。
* 如果 BGM 短于视频,会自动循环;如果长于视频,会截断到视频长度。
*
* 注意:bgm_path 来自应用资源目录,不做 validate_safe_path 检查。
*/
pub async fn mix_bgm_to_video(
app: &AppHandle,
video_path: &str,
bgm_path: &str,
output_path: &str,
video_volume: f64,
bgm_volume: f64,
) -> Result<(), String> {
let safe_video = validate_safe_path(video_path)?;
// BGM 来自应用资源目录,直接传递(路径由前端通过 Tauri path API 解析)
let safe_bgm = bgm_path.to_string();
let safe_output = sanitize_output_path(output_path)?;
// 构建 filter_complex:
// [0:a]volume=1.0[a0]; — 原视频音频调整音量
// [1:a]volume=0.25,aloop=loop=-1:size=2e+09[bgm]; — BGM 调整音量后无限循环
// [a0][bgm]amix=inputs=2:duration=first:dropout_transition=2[aout] — 混合,以第一个输入时长为准
let filter_complex = format!(
"[0:a]volume={:.2}[a0];[1:a]volume={:.2},aloop=loop=-1:size=2e+09[bgm];[a0][bgm]amix=inputs=2:duration=first:dropout_transition=2[aout]",
video_volume, bgm_volume
);
let args = vec![
"-i".to_string(), safe_video,
"-i".to_string(), safe_bgm,
"-filter_complex".to_string(), filter_complex,
"-map".to_string(), "0:v:0".to_string(),
"-map".to_string(), "[aout]".to_string(),
"-c:v".to_string(), "copy".to_string(),
"-c:a".to_string(), "aac".to_string(),
"-b:a".to_string(), "192k".to_string(),
"-ar".to_string(), "44100".to_string(),
"-ac".to_string(), "2".to_string(),
"-y".to_string(),
safe_output,
];
run_ffmpeg(app, args).await.map(|_| ())
}
/**
* 音频替换 — 用配音音频替换视频中的原音
*
@@ -519,6 +580,117 @@ pub async fn replace_audio_track(
run_ffmpeg(app, args).await.map(|_| ())
}
/**
* 通过 ffprobe 获取视频元数据(分辨率、时长、帧率)
*
* 跨平台可靠,不依赖浏览器视频解码器。
*/
pub async fn get_video_metadata(app: &AppHandle, input_path: &str) -> Result<VideoMetadata, String> {
// URL 直接传递;本地文件只检查是否存在(用户通过系统文件选择器选取,已获授权)
let safe_input = if input_path.starts_with("http://") || input_path.starts_with("https://") {
input_path.to_string()
} else if std::path::Path::new(input_path).exists() {
input_path.to_string()
} else {
return Err(format!("输入文件不存在: {}", input_path));
};
let args = vec![
"-v".to_string(), "error".to_string(),
"-select_streams".to_string(), "v:0".to_string(),
"-show_entries".to_string(), "stream=width,height,duration,r_frame_rate:format=duration".to_string(),
"-of".to_string(), "json".to_string(),
safe_input,
];
let (mut rx, child) = app.shell()
.sidecar("ffprobe")
.map_err(|e| format!("Failed to find ffprobe sidecar: {e}"))?
.args(args)
.spawn()
.map_err(|e| format!("Failed to spawn ffprobe: {e}"))?;
let mut stdout = String::new();
let mut stderr = String::new();
let probe_future = async {
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line) => {
stdout.push_str(&String::from_utf8_lossy(&line));
}
CommandEvent::Stderr(line) => {
stderr.push_str(&String::from_utf8_lossy(&line));
}
CommandEvent::Terminated(status) => {
match status.code {
Some(0) => return Ok(()),
Some(code) => {
return Err(format!("ffprobe exited with status {}. stderr: {}", code, stderr.trim()));
}
None => return Err("ffprobe terminated by signal".to_string()),
}
}
_ => {}
}
}
Ok(())
};
match tokio::time::timeout(std::time::Duration::from_secs(30), probe_future).await {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(e),
Err(_) => {
let _ = child.kill();
return Err("ffprobe 执行超时(超过30秒),已强制终止".to_string());
}
}
// 解析 JSON 输出
let parsed: serde_json::Value = serde_json::from_str(&stdout)
.map_err(|e| format!("ffprobe JSON 解析失败: {}. raw: {}", e, &stdout))?;
let stream = parsed.get("streams")
.and_then(|s| s.as_array())
.and_then(|arr| arr.first())
.ok_or_else(|| format!("ffprobe 未返回视频流信息: {}", &stdout))?;
let width = stream.get("width")
.and_then(|v| v.as_u64())
.ok_or_else(|| "无法解析视频宽度".to_string())? as u32;
let height = stream.get("height")
.and_then(|v| v.as_u64())
.ok_or_else(|| "无法解析视频高度".to_string())? as u32;
// duration 可能是字符串或数字;某些格式(如 MPEG-TSstream duration 为 N/A,需从 format 回退
let stream_duration = stream.get("duration")
.and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok())));
let format_duration = parsed.get("format")
.and_then(|f| f.get("duration"))
.and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok())));
let duration = stream_duration.or(format_duration).unwrap_or(0.0);
// 帧率格式为 "25/1" 或 "30000/1001"
let fps = stream.get("r_frame_rate")
.and_then(|v| v.as_str())
.and_then(|s| {
let parts: Vec<&str> = s.split('/').collect();
if parts.len() == 2 {
let num: f64 = parts[0].parse().ok()?;
let den: f64 = parts[1].parse().ok()?;
if den != 0.0 { Some(num / den) } else { None }
} else {
s.parse().ok()
}
})
.unwrap_or(0.0);
Ok(VideoMetadata { width, height, duration, fps })
}
/**
* 裁剪视频片段(支持本地文件和 HTTP URL)
@@ -603,3 +775,123 @@ pub async fn extract_audio_segment(
}
/**
* 为预览转码视频(统一为浏览器兼容格式)
*
* 将任意格式的视频转码为 H.264 Baseline + YUV420p 540p
* 确保在所有平台的浏览器/WebView 中都能正常预览。
* 转码结果按(文件路径 + 大小 + 修改时间)缓存,避免重复处理。
*/
pub async fn transcode_for_preview(app: &AppHandle, input_path: &str) -> Result<String, String> {
let input = std::path::Path::new(input_path);
if !input.exists() {
return Err(format!("输入文件不存在: {}", input_path));
}
let path_str = input.canonicalize()
.unwrap_or(input.to_path_buf())
.to_string_lossy()
.to_string();
// 快速检测:如果已经是 H.264 + YUV420p,直接返回原始路径(避免应用自己生成的成品重复转码)
let probe_args = vec![
"-v".to_string(), "error".to_string(),
"-select_streams".to_string(), "v:0".to_string(),
"-show_entries".to_string(), "stream=codec_name,pix_fmt,width,height".to_string(),
"-of".to_string(), "json".to_string(),
path_str.clone(),
];
let probe_result = app.shell().sidecar("ffprobe")
.and_then(|s| s.args(probe_args).spawn());
if let Ok((mut rx, child)) = probe_result {
let mut stdout = String::new();
let probe_future = async {
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line) => stdout.push_str(&String::from_utf8_lossy(&line)),
CommandEvent::Terminated(status) if status.code == Some(0) => return Ok(()),
_ => {}
}
}
Ok::<(), ()>(())
};
match tokio::time::timeout(std::time::Duration::from_secs(3), probe_future).await {
Ok(Ok(())) => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stdout) {
if let Some(stream) = parsed.get("streams").and_then(|s| s.as_array()).and_then(|a| a.first()) {
let codec = stream.get("codec_name").and_then(|v| v.as_str()).unwrap_or("");
let pix_fmt = stream.get("pix_fmt").and_then(|v| v.as_str()).unwrap_or("");
// 应用生成的视频通常是 1080p 及以下;若用户上传了 4K H.264,仍转码为 540p 代理以保证预览流畅
let width = stream.get("width").and_then(|v| v.as_u64()).unwrap_or(0);
let height = stream.get("height").and_then(|v| v.as_u64()).unwrap_or(0);
if codec == "h264" && pix_fmt == "yuv420p" && width <= 1920 && height <= 1920 {
return Ok(path_str);
}
}
}
}
_ => {
// 超时或异常:强制结束 ffprobe,避免僵尸进程
let _ = child.kill();
}
}
}
// 获取文件元数据用于缓存 key
let metadata = std::fs::metadata(input_path)
.map_err(|e| format!("无法读取文件元数据: {}", e))?;
// 某些文件系统(如 FAT32)不支持修改时间,失败时回退为 0
let mtime = metadata.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.unwrap_or_default()
.as_secs();
let file_size = metadata.len();
// 计算缓存路径(基于文件路径 hash + 大小 + 修改时间)
let path_hash = {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
path_str.hash(&mut hasher);
format!("{:016x}", hasher.finish())
};
let app_data_dir = crate::storage::paths::get_app_data_dir()
.map_err(|e| format!("无法获取应用数据目录: {}", e))?;
let cache_dir = app_data_dir.join("video_cache");
std::fs::create_dir_all(&cache_dir)
.map_err(|e| format!("无法创建缓存目录: {}", e))?;
let cache_path = cache_dir.join(format!("preview_{}_{}_{}.mp4", path_hash, file_size, mtime));
// 缓存命中且文件完整,直接返回
if cache_path.exists() {
let cache_meta = std::fs::metadata(&cache_path)
.map_err(|e| format!("无法读取缓存文件: {}", e))?;
if cache_meta.len() > 1024 {
return Ok(cache_path.to_string_lossy().to_string());
}
}
// FFmpeg 转码:H.264 Baseline + YUV420p540p,无音频,faststart
let args = vec![
"-i".to_string(), path_str,
"-c:v".to_string(), "libx264".to_string(),
"-profile:v".to_string(), "baseline".to_string(),
"-level".to_string(), "3.0".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-preset".to_string(), "ultrafast".to_string(),
"-crf".to_string(), "28".to_string(),
"-vf".to_string(), "scale=540:-2:force_original_aspect_ratio=decrease".to_string(),
"-an".to_string(),
"-movflags".to_string(), "+faststart".to_string(),
"-y".to_string(),
cache_path.to_string_lossy().to_string(),
];
run_ffmpeg(app, args).await?;
Ok(cache_path.to_string_lossy().to_string())
}
+219 -4
View File
@@ -30,6 +30,11 @@ fn restart_app(app: tauri::AppHandle) {
app.restart();
}
#[tauri::command]
fn open_devtools(window: tauri::WebviewWindow) {
let _ = window.open_devtools();
}
// ============================================================
// 公共类型导出
// ============================================================
@@ -41,25 +46,163 @@ pub struct ApiResponse<T> {
pub data: Option<T>,
}
// ============================================================
// 视频缓存清理
// ============================================================
/// 清理 video_cache 目录
///
/// 策略:
/// 1. 删除超过 30 天未修改的文件
/// 2. 总容量超过 500MB 时,按修改时间删最旧文件直到 300MB
fn clean_video_cache(app_data_dir: &std::path::Path) {
let cache_dir = app_data_dir.join("video_cache");
if !cache_dir.exists() {
return;
}
let max_age = std::time::Duration::from_secs(30 * 24 * 60 * 60);
let max_total_size: u64 = 500 * 1024 * 1024;
let target_size: u64 = 300 * 1024 * 1024;
let now = std::time::SystemTime::now();
let mut entries: Vec<(std::path::PathBuf, std::time::SystemTime, u64)> = Vec::new();
let mut total_size: u64 = 0;
let read_dir = match std::fs::read_dir(&cache_dir) {
Ok(d) => d,
Err(e) => {
eprintln!("[cache] 无法读取缓存目录: {}", e);
return;
}
};
for entry in read_dir.flatten() {
let Ok(metadata) = entry.metadata() else { continue };
if !metadata.is_file() { continue; }
let mtime = metadata.modified().unwrap_or(now);
let size = metadata.len();
total_size += size;
entries.push((entry.path(), mtime, size));
}
// 1. 删除超过 30 天的文件
let mut deleted_size: u64 = 0;
for (path, mtime, size) in &entries {
if now.duration_since(*mtime).unwrap_or_default() > max_age {
if std::fs::remove_file(path).is_ok() {
deleted_size += size;
}
}
}
// 2. 容量超限,删最旧的
let remaining_size = total_size.saturating_sub(deleted_size);
if remaining_size > max_total_size {
let mut sorted = entries;
sorted.sort_by_key(|(_, mtime, _)| *mtime);
let mut to_free = remaining_size.saturating_sub(target_size);
for (path, _, size) in sorted {
if to_free == 0 { break; }
if path.exists() && std::fs::remove_file(&path).is_ok() {
to_free = to_free.saturating_sub(size);
}
}
}
}
/// 手动清理 video_cache 目录,返回释放的字节数
#[tauri::command]
fn clear_video_cache_cmd() -> Result<u64, String> {
let app_data_dir = crate::storage::paths::get_app_data_dir()
.map_err(|e| e.to_string())?;
let cache_dir = app_data_dir.join("video_cache");
if !cache_dir.exists() {
return Ok(0);
}
let mut freed: u64 = 0;
let read_dir = match std::fs::read_dir(&cache_dir) {
Ok(d) => d,
Err(e) => return Err(format!("无法读取缓存目录: {}", e)),
};
for entry in read_dir.flatten() {
let Ok(metadata) = entry.metadata() else { continue };
if !metadata.is_file() { continue; }
let size = metadata.len();
if std::fs::remove_file(entry.path()).is_ok() {
freed += size;
}
}
Ok(freed)
}
/// 获取 video_cache 目录当前占用大小(字节)
#[tauri::command]
fn get_video_cache_size_cmd() -> Result<u64, String> {
let app_data_dir = crate::storage::paths::get_app_data_dir()
.map_err(|e| e.to_string())?;
let cache_dir = app_data_dir.join("video_cache");
if !cache_dir.exists() {
return Ok(0);
}
let mut total: u64 = 0;
let read_dir = match std::fs::read_dir(&cache_dir) {
Ok(d) => d,
Err(e) => return Err(format!("无法读取缓存目录: {}", e)),
};
for entry in read_dir.flatten() {
let Ok(metadata) = entry.metadata() else { continue };
if metadata.is_file() {
total += metadata.len();
}
}
Ok(total)
}
// ============================================================
// 应用入口
// ============================================================
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Windows: 禁用 D3D11 硬件视频解码,解决本地视频预览黑屏问题
// Chromium 在 Windows 上的 D3D11 视频解码器与部分显卡驱动/视频编码不兼容,
// 回退到软件解码可确保视频画面正常渲染。对 5~20 秒短视频预览性能影响可忽略。
#[cfg(target_os = "windows")]
{
std::env::set_var(
"WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS",
"--disable-features=MediaFoundationD3D11VideoDecode"
);
}
let _api_base_url = crate::storage::config::get_api_base_url_sync();
tauri::Builder::default()
.setup(|app| {
// 初始化应用数据目录(所有业务数据的根目录)
if let Ok(app_data_dir) = app.path().app_local_data_dir() {
crate::storage::init_app_data_dir(app_data_dir);
crate::storage::init_app_data_dir(app_data_dir.clone());
// 后台清理过期视频缓存,不阻塞首屏
std::thread::spawn(move || clean_video_cache(&app_data_dir));
}
// Release 构建也打开 DevTools(临时:排查 Windows 网络问题)
// 排查完成后可移除或改为快捷键触发
// 窗口初始 visible=falsesetup 阶段先显示窗口
if let Some(window) = app.get_webview_window("main") {
let _ = window.open_devtools();
if let Err(e) = window.show() {
eprintln!("[setup] window.show() failed: {}", e);
}
if let Err(e) = window.set_focus() {
eprintln!("[setup] window.set_focus() failed: {}", e);
}
} else {
eprintln!("[setup] main window not found");
}
// macOS 自定义菜单栏(中文本地化)
@@ -126,6 +269,7 @@ pub fn run() {
storage::config::save_app_config,
storage::config::get_environment_presets,
restart_app,
open_devtools,
// 项目存储
commands::project::save_project_meta_raw,
commands::project::load_project_meta,
@@ -167,12 +311,25 @@ pub fn run() {
commands::voice::load_voice_materials,
commands::voice::save_voice_material,
commands::voice::delete_voice_material_cmd,
// 封面形象库
commands::cover_avatar::load_cover_avatars,
commands::cover_avatar::save_cover_avatar,
commands::cover_avatar::delete_cover_avatar_cmd,
commands::cover_avatar::save_cover_avatar_image,
// 压制成片(Phase 2
commands::video_compose::extract_video_segment,
commands::video_compose::concat_video_clips,
commands::video_compose::generate_empty_shot_clip,
commands::video_compose::upload_video_file,
commands::video_compose::download_file,
commands::video_compose::mix_bgm_to_video,
// 视频元数据读取(ffprobe
get_video_metadata_cmd,
// 视频预览转码(统一浏览器兼容格式)
transcode_for_preview_cmd,
// 缓存清理
get_video_cache_size_cmd,
clear_video_cache_cmd,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@@ -274,6 +431,64 @@ async fn video_composite_synthesis(
}
}
// ============================================================
// 视频元数据读取(ffprobe
// ============================================================
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct GetVideoMetadataRequest {
path: String,
}
#[tauri::command]
async fn get_video_metadata_cmd(
app: tauri::AppHandle,
request: GetVideoMetadataRequest,
) -> ApiResponse<ffmpeg_cmd::VideoMetadata> {
match ffmpeg_cmd::get_video_metadata(&app, &request.path).await {
Ok(meta) => ApiResponse {
code: 200,
message: "读取成功".to_string(),
data: Some(meta),
},
Err(e) => ApiResponse {
code: 500,
message: e,
data: None,
},
}
}
// ============================================================
// 视频预览转码
// ============================================================
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct TranscodeForPreviewRequest {
path: String,
}
#[tauri::command]
async fn transcode_for_preview_cmd(
app: tauri::AppHandle,
request: TranscodeForPreviewRequest,
) -> ApiResponse<String> {
match ffmpeg_cmd::transcode_for_preview(&app, &request.path).await {
Ok(path) => ApiResponse {
code: 200,
message: "转码成功".to_string(),
data: Some(path),
},
Err(e) => ApiResponse {
code: 500,
message: e,
data: None,
},
}
}
// ============================================================
// 错误处理扩展
// ============================================================
+25 -36
View File
@@ -2,19 +2,21 @@
//!
//! 存储路径: {app_local_data_dir}/config.json
//!
//! 所有环境预设和默认配置均在 Rust 层定义,前端不硬编码任何 API 地址
//! 运行模式(生产/调试)控制桌面端行为,API 地址固定为 dev 环境
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::storage::engine::{read_json, atomic_write_json, StorageError};
const API_BASE_URL: &str = "https://dev.tapi.meijiaka.cn/api/v1";
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AppConfig {
pub api_base_url: String,
pub environment: String,
}
/// 环境预设(唯一数据源,前端通过 IPC 获取)
/// 运行模式预设(唯一数据源,前端通过 IPC 获取)
#[derive(Serialize, Clone, Debug)]
pub struct EnvironmentPreset {
pub label: String,
@@ -25,27 +27,22 @@ pub struct EnvironmentPreset {
fn presets() -> Vec<EnvironmentPreset> {
vec![
EnvironmentPreset {
label: "生产环境".to_string(),
url: "https://tapi.meijiaka.cn/api/v1".to_string(),
label: "生产模式".to_string(),
url: API_BASE_URL.to_string(),
env: "production".to_string(),
},
EnvironmentPreset {
label: "测试环境".to_string(),
url: "https://dev.tapi.meijiaka.cn/api/v1".to_string(),
env: "staging".to_string(),
},
EnvironmentPreset {
label: "本地开发".to_string(),
url: "http://127.0.0.1:8081/api/v1".to_string(),
env: "development".to_string(),
label: "调试模式".to_string(),
url: API_BASE_URL.to_string(),
env: "debug".to_string(),
},
]
}
impl Default for AppConfig {
fn default() -> Self {
// 默认指向测试环境(索引 1),避免首次安装时误连生产环境
let preset = &presets()[1];
// 默认生产模式
let preset = &presets()[0];
Self {
api_base_url: preset.url.clone(),
environment: preset.env.clone(),
@@ -59,22 +56,24 @@ fn get_config_path() -> Result<PathBuf, StorageError> {
/// 同步读取 API Base URL(供 Rust 命令内部使用)
pub fn get_api_base_url_sync() -> String {
let path = match get_config_path() {
Ok(p) => p,
// 默认指向测试环境(索引 1
Err(_) => return presets()[1].url.clone(),
};
match read_json::<AppConfig>(&path) {
Ok(Some(config)) => config.api_base_url,
_ => AppConfig::default().api_base_url,
}
API_BASE_URL.to_string()
}
#[tauri::command]
pub async fn load_app_config() -> Result<AppConfig, String> {
let path = get_config_path().map_err(|e| e.to_string())?;
match read_json::<AppConfig>(&path) {
Ok(Some(config)) => Ok(config),
Ok(Some(config)) => {
// 兼容旧环境值:旧值(staging/development/custom)统一映射为 debug
let environment = match config.environment.as_str() {
"production" => "production".to_string(),
_ => "debug".to_string(),
};
Ok(AppConfig {
api_base_url: API_BASE_URL.to_string(),
environment,
})
}
Ok(None) => Ok(AppConfig::default()),
Err(e) => Err(e.to_string()),
}
@@ -86,19 +85,9 @@ pub async fn get_environment_presets() -> Result<Vec<EnvironmentPreset>, String>
}
#[tauri::command]
pub async fn save_app_config(environment: String, custom_url: Option<String>) -> Result<(), String> {
let url = if environment == "custom" {
custom_url.ok_or("自定义地址不能为空")?
} else {
presets()
.iter()
.find(|p| p.env == environment)
.map(|p| p.url.clone())
.unwrap_or_else(|| "https://dev.tapi.meijiaka.cn/api/v1".to_string())
};
pub async fn save_app_config(environment: String, _custom_url: Option<String>) -> Result<(), String> {
let config = AppConfig {
api_base_url: url,
api_base_url: API_BASE_URL.to_string(),
environment,
};
@@ -0,0 +1,101 @@
//! 封面形象存储模块
//!
//! 管理用户上传的人物照片(抠图后)的本地存储。
use serde::{Deserialize, Serialize};
use crate::storage::engine::{atomic_write_bytes, atomic_write_json, ensure_dir, read_json, StorageError};
use crate::storage::paths::get_cover_avatars_dir;
/// 封面形象记录
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CoverAvatar {
pub id: String,
pub name: String,
/// 抠图后的透明背景图片 URL(七牛云)
pub image_url: String,
/// 本地文件路径(相对于项目目录或绝对路径)
pub local_path: Option<String>,
pub created_at: String,
}
/// 封面形象列表
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct CoverAvatarsList {
pub avatars: Vec<CoverAvatar>,
pub updated_at: String,
}
/// 加载封面形象库
pub fn load_cover_avatars() -> Result<CoverAvatarsList, StorageError> {
let path = crate::storage::paths::get_cover_avatars_json_path()?;
Ok(read_json(&path)?.unwrap_or_default())
}
/// 保存封面形象库
pub fn save_cover_avatars(list: &CoverAvatarsList) -> Result<(), StorageError> {
let path = crate::storage::paths::get_cover_avatars_json_path()?;
atomic_write_json(&path, list)
}
/// 添加封面形象
pub fn add_cover_avatar(avatar: CoverAvatar) -> Result<(), StorageError> {
let mut list = load_cover_avatars()?;
// 去重:相同 id 替换
if let Some(pos) = list.avatars.iter().position(|a| a.id == avatar.id) {
list.avatars[pos] = avatar;
} else {
list.avatars.push(avatar);
}
list.updated_at = chrono_lite_now();
save_cover_avatars(&list)
}
/// 删除封面形象
pub fn delete_cover_avatar(id: &str) -> Result<(), StorageError> {
let mut list = load_cover_avatars()?;
let pos = list.avatars.iter().position(|a| a.id == id)
.ok_or_else(|| StorageError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("封面形象 {} 不存在", id),
)))?;
list.avatars.remove(pos);
list.updated_at = chrono_lite_now();
save_cover_avatars(&list)
}
/// 保存封面形象图片文件到本地
///
/// 将 base64 编码的图片数据写入 `cover_avatars/` 子目录。
pub fn save_cover_avatar_image(
avatar_id: &str,
data: &[u8],
ext: &str,
) -> Result<String, StorageError> {
let avatars_dir = get_cover_avatars_dir()?;
ensure_dir(&avatars_dir)?;
// 净化扩展名:只允许字母数字,防止路径遍历
let safe_ext = ext
.chars()
.filter(|c| c.is_alphanumeric())
.collect::<String>();
if safe_ext.is_empty() {
return Err(StorageError::InvalidId("无效的文件扩展名".into()));
}
let file_name = format!("{}.{}", avatar_id, safe_ext);
let file_path = avatars_dir.join(&file_name);
atomic_write_bytes(&file_path, data)?;
Ok(file_path.to_str().unwrap_or_default().to_string())
}
// ====================== 工具函数 ======================
fn chrono_lite_now() -> String {
chrono::Utc::now().to_rfc3339()
}
+2 -1
View File
@@ -12,6 +12,7 @@ pub mod project;
pub mod auth;
pub mod voice;
pub mod config;
pub mod cover_avatar;
pub use engine::{
atomic_write_json, atomic_write_bytes,
@@ -22,5 +23,5 @@ pub use paths::{
init_app_data_dir, get_app_data_dir, get_projects_root_dir,
get_project_dir, get_project_dir_path, get_project_assets_dir,
get_project_videos_dir, get_project_products_dir, get_voices_json_path,
get_app_config_json_path,
get_app_config_json_path, get_cover_avatars_dir, get_cover_avatars_json_path,
};
+16
View File
@@ -104,3 +104,19 @@ pub fn get_auth_state_path(app: &AppHandle) -> Result<PathBuf, StorageError> {
crate::storage::engine::ensure_dir(&path)?;
Ok(path.join("auth.json"))
}
/// 获取封面形象图片存储目录
/// {app_local_data_dir}/cover_avatars/
pub fn get_cover_avatars_dir() -> Result<PathBuf, StorageError> {
let base = get_app_data_dir()?;
let path = base.join("cover_avatars");
crate::storage::engine::ensure_dir(&path)?;
Ok(path)
}
/// 获取封面形象库 JSON 路径
/// {app_local_data_dir}/cover_avatars.json
pub fn get_cover_avatars_json_path() -> Result<PathBuf, StorageError> {
let base = get_app_data_dir()?;
Ok(base.join("cover_avatars.json"))
}
+3 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "美家卡智影",
"version": "1.5.18",
"version": "1.6.0",
"identifier": "cn.meijiaka.ai-zy",
"build": {
"beforeDevCommand": "npm run dev",
@@ -23,7 +23,7 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' https: data: blob: asset:; media-src 'self' https: blob: asset:; connect-src 'self' https: ws://localhost:*;",
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' https: data: blob: asset: http://asset.localhost; media-src 'self' https: blob: asset: http://asset.localhost; connect-src 'self' https: ws://localhost:*;",
"capabilities": [
"default"
],
@@ -41,7 +41,7 @@
"plugins": {
"opener": {},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkwRTdFOUI5NkZERkNFRDMKUldUVHp0OXZ1ZW5ua0pVN2M0ZGoyakxneFc5am5pR21UNFdCaThDN0p5S0JubUxUVUhLNnlkMWkK",
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIwOEVEODY4MTgyRkJFRTMKUldUanZpOFlhTmlPSUJzS0FLL1NMUEgzLzRtNXpsT1FoTXZlS3JLOHJvak5KeThIeDJQRFpJZWgK",
"endpoints": [
"https://dev.tapi.meijiaka.cn/api/v1/update/check?version={{current_version}}&target={{target}}&arch={{arch}}"
]
-1
View File
@@ -1 +0,0 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEZCMUQyRjU1MzY3ODZDREIKUldUYmJIZzJWUzhkK3l4VTl0RGE3OFRhN2JXdjBjZnR5UC9aMmwxTUY5K2ZoeDVNOStjZlFwQWYK
+2 -2
View File
@@ -13,7 +13,7 @@
width: 40px;
height: 40px;
border: 3px solid #e8e8e8;
border-top-color: #1a9e8a;
border-top-color: var(--primary);
border-radius: 50%;
animation: app-loading-spin 0.8s linear infinite;
}
@@ -27,7 +27,7 @@
.app-loading-text {
font-size: 16px;
font-weight: 500;
color: #1a9e8a;
color: var(--primary);
letter-spacing: 2px;
}
+6 -8
View File
@@ -9,10 +9,8 @@ import Login from './pages/Login/Login';
import VideoCreation from './pages/VideoCreation';
import MyWorks from './pages/ContentManagement/MyWorks';
import VoiceMaterialLibrary from './pages/ContentManagement/VoiceMaterialLibrary';
import AboutUs from './pages/Settings/AboutUs';
import SystemUpdate from './pages/Settings/SystemUpdate';
import CoverAvatarLibrary from './pages/ContentManagement/CoverAvatarLibrary';
import Settings from './pages/Settings/Settings';
import Profile from './pages/Profile/Profile';
import UsageDetail from './pages/Profile/UsageDetail';
import ToastContainer from './components/Toast/ToastContainer';
@@ -32,9 +30,9 @@ import './App.css';
type PageType =
| 'video-creation'
| 'voice-material'
| 'cover-avatar'
| 'my-works'
| 'about-us'
| 'system-update'
| 'settings'
| 'profile'
| 'usage-detail';
@@ -42,9 +40,9 @@ type PageType =
const pages: Record<PageType, React.ComponentType> = {
'video-creation': VideoCreation,
'voice-material': VoiceMaterialLibrary,
'cover-avatar': CoverAvatarLibrary,
'my-works': MyWorks,
'about-us': AboutUs,
'system-update': SystemUpdate,
settings: Settings,
profile: Profile,
'usage-detail': UsageDetail,
};
+32
View File
@@ -0,0 +1,32 @@
import { client } from '../client';
export interface BgmMusicItem {
id: number;
title: string;
artist: string | null;
category: string;
filePath: string;
url: string | null;
duration: number | null;
sortOrder: number;
}
export interface BgmMusicListResponse {
items: BgmMusicItem[];
total: number;
}
/**
* BGM API
*/
export const bgmMusicApi = {
/**
*
* @param category
*/
list: async (category?: string): Promise<BgmMusicItem[]> => {
const path = category ? `/bgm-musics?category=${encodeURIComponent(category)}` : '/bgm-musics';
const response = await client.get<{ items: BgmMusicItem[]; total: number }>(path);
return response.items || [];
},
};
+2 -2
View File
@@ -40,8 +40,8 @@ export async function getEnvironmentPresets(): Promise<EnvironmentPreset[]> {
/**
*
* @param environment production / staging / development / custom
* @param customUrl custom
* @param environment production / debug
* @param customUrl
*/
export async function saveAppConfig(environment: string, customUrl?: string): Promise<void> {
await invoke('save_app_config', { environment, customUrl });
+104
View File
@@ -0,0 +1,104 @@
/**
* Cover Avatar API
* ================================
*
* AI
*/
import { invoke } from '@tauri-apps/api/core';
import { client } from '../client';
// ====================== 类型 ======================
export interface CoverAvatar {
id: string;
name: string;
imageUrl: string;
localPath?: string;
createdAt: string;
}
// ====================== HTTP API(后端直连) ======================
/**
*
*/
export async function uploadImage(file: File): Promise<string> {
const formData = new FormData();
formData.append('file', file);
const result = await client.postForm<{ url: string; key: string }>('/upload/image', formData);
return result.url;
}
/**
* AI MediaKit
*/
export async function removeBackground(imageUrl: string, scene = 'human'): Promise<string> {
const result = await client.post<{ url: string }>('/image/remove-background', {
imageUrl,
scene,
});
return result.url;
}
// ====================== IPC 命令(本地存储) ======================
/**
*
*/
export async function loadCoverAvatars(): Promise<CoverAvatar[]> {
const result = await invoke<{ code: number; data?: CoverAvatar[]; message: string }>('load_cover_avatars');
if (result.code !== 200) {
throw new Error(result.message || '加载封面形象库失败');
}
return result.data || [];
}
/**
*
*/
export async function saveCoverAvatar(avatar: CoverAvatar): Promise<void> {
const result = await invoke<{ code: number; message: string }>('save_cover_avatar', {
args: {
id: avatar.id,
name: avatar.name,
imageUrl: avatar.imageUrl,
localPath: avatar.localPath,
createdAt: avatar.createdAt,
},
});
if (result.code !== 200) {
throw new Error(result.message || '保存封面形象失败');
}
}
/**
*
*/
export async function deleteCoverAvatar(id: string): Promise<void> {
const result = await invoke<{ code: number; message: string }>('delete_cover_avatar_cmd', { id });
if (result.code !== 200) {
throw new Error(result.message || '删除封面形象失败');
}
}
/**
* base64
*/
export async function saveCoverAvatarImage(args: {
avatarId: string;
imageData: string;
ext: string;
}): Promise<string> {
const result = await invoke<{ code: number; data?: string; message: string }>('save_cover_avatar_image', {
args: {
avatarId: args.avatarId,
imageData: args.imageData,
ext: args.ext,
},
});
if (result.code !== 200 || !result.data) {
throw new Error(result.message || '保存封面形象图片失败');
}
return result.data;
}
@@ -101,6 +101,7 @@ export const localProjectApi = {
updatedAt: meta.updatedAt,
coverPath: meta.coverPath,
finalVideoPath: meta.finalVideoPath,
finalVideoDuration: meta.finalVideoDuration,
exportedAt: meta.exportedAt,
selectedVoiceId: meta.selectedVoiceId,
composedVideoUrl: meta.composedVideoUrl,
@@ -111,6 +112,7 @@ export const localProjectApi = {
lipSyncedVideoUrl: meta.lipSyncedVideoUrl,
dubbingAudioUrl: meta.dubbingAudioUrl,
dubbingAudioPath: meta.dubbingAudioPath,
dubbingAudioDuration: meta.dubbingAudioDuration,
voiceSpeed: meta.voiceSpeed,
voiceVolume: meta.voiceVolume,
voicePitch: meta.voicePitch,
@@ -125,8 +127,13 @@ export const localProjectApi = {
subTitlePreset: meta.subTitlePreset,
captionPreset: meta.captionPreset,
coverConfig: meta.coverConfig,
bgmMusicId: meta.bgmMusicId,
bgmMusicTitle: meta.bgmMusicTitle,
bgmMusicPath: meta.bgmMusicPath,
bgmVolume: meta.bgmVolume,
version: meta.version,
userUploadedMaterials: meta.userUploadedMaterials,
stepDirtyFlags: meta.stepDirtyFlags,
};
const jsonContent = JSON.stringify(orderedMeta, null, 2);
const res = await safeInvoke<ApiResponse<boolean>>('save_project_meta_raw', {
@@ -0,0 +1,25 @@
interface AppHeaderProps {
title: string;
showBack?: boolean;
onBack?: () => void;
rightActions?: React.ReactNode;
}
export default function AppHeader({ title, showBack, onBack, rightActions }: AppHeaderProps) {
return (
<div className="app-header">
<h2 className="app-header-title">{title}</h2>
<div className="app-header-right">
{showBack && onBack && (
<button className="page-back-btn" onClick={onBack}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
)}
{rightActions}
</div>
</div>
);
}
+87 -5
View File
@@ -164,15 +164,28 @@
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
position: relative;
}
.sidebar-footer-card {
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
overflow: hidden;
}
.sidebar-balance-text {
font-size: 12px;
font-weight: 600;
color: var(--primary);
margin-top: 2px;
}
.sidebar-user {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
border-radius: var(--radius-md);
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
transition: background var(--transition-fast);
}
@@ -199,15 +212,84 @@
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
gap: 1px;
}
.user-name {
font-size: var(--font-sm);
font-weight: 500;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-chevron {
flex-shrink: 0;
transition: transform var(--transition-fast);
color: var(--text-tertiary);
}
.user-chevron.expanded {
transform: rotate(90deg);
}
.user-dropdown-menu {
position: absolute;
bottom: calc(100% + 4px);
left: var(--spacing-md);
right: var(--spacing-md);
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
padding: var(--spacing-xs);
display: flex;
flex-direction: column;
gap: 2px;
z-index: 200;
animation: fadeIn 0.15s ease;
}
.user-dropdown-item {
display: flex;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
border: none;
background: transparent;
cursor: pointer;
font-size: var(--font-sm);
font-family: var(--font-family);
color: var(--text-secondary);
text-align: left;
transition: all var(--transition-fast);
}
.user-dropdown-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.user-dropdown-item.active {
color: var(--primary);
font-weight: 500;
}
.user-dropdown-divider {
height: 1px;
background: var(--border-light);
margin: var(--spacing-xs) 0;
}
.user-dropdown-danger {
color: var(--text-secondary);
}
.user-dropdown-danger:hover {
color: #e74c3c;
background: #fdf2f2;
}
+86 -26
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { createNewProject, useAuthStore } from '../../store';
import { usePointStore } from '../../store';
import { useNavigation } from '../../contexts/NavigationContext';
import './Sidebar.css';
@@ -22,19 +23,10 @@ const navItems: NavItem[] = [
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
children: [
{ id: 'voice-material', label: '声音复刻' },
{ id: 'cover-avatar', label: '封面形象' },
{ id: 'my-works', label: '我的作品' },
],
},
{
id: 'settings',
label: '系统设置',
icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z',
children: [
{ id: 'system-update', label: '系统更新' },
{ id: 'about-us', label: '关于我们' },
],
},
];
interface SidebarProps {
@@ -42,12 +34,38 @@ interface SidebarProps {
onNavigate: (path: string) => void;
}
const userMenuItems = [
{ id: 'profile', label: '我的账户' },
{ id: 'settings', label: '设置' },
];
export default function Sidebar({ currentPath, onNavigate }: SidebarProps) {
const { appEnvironment } = useNavigation();
const authUser = useAuthStore((s) => s.user);
const balance = usePointStore((s) => s.balance);
const fetchBalance = usePointStore((s) => s.fetchBalance);
const [expandedItems, setExpandedItems] = useState<Set<string>>(
new Set(['content-management', 'settings'])
new Set(['content-management'])
);
const [showUserMenu, setShowUserMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// 组件挂载时拉取一次余额
useEffect(() => {
fetchBalance().catch(() => {});
}, [fetchBalance]);
// 点击外部关闭用户菜单
useEffect(() => {
if (!showUserMenu) return;
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowUserMenu(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [showUserMenu]);
const toggleExpand = (id: string) => {
setExpandedItems(prev => {
@@ -64,10 +82,6 @@ export default function Sidebar({ currentPath, onNavigate }: SidebarProps) {
const handleClick = (item: NavItem) => {
if (item.children) {
toggleExpand(item.id);
// Navigate to first child
if (!expandedItems.has(item.id)) {
onNavigate(item.children[0].id);
}
} else {
onNavigate(item.id);
}
@@ -196,18 +210,64 @@ export default function Sidebar({ currentPath, onNavigate }: SidebarProps) {
{appEnvironment === 'staging' ? '测试环境' : appEnvironment === 'development' ? '开发环境' : appEnvironment}
</div>
)}
<div className="sidebar-footer">
<div className="sidebar-user" onClick={() => onNavigate('profile')} title="个人中心">
<img
src={authUser?.avatar || '/default-avatar.svg'}
alt="avatar"
className="user-avatar"
style={{ objectFit: 'cover' }}
/>
<div className="user-info">
<span className="user-name">{authUser?.nickname || '用户'}</span>
<div className="sidebar-footer" ref={menuRef}>
<div className="sidebar-footer-card">
<div
className="sidebar-user"
onClick={() => setShowUserMenu(!showUserMenu)}
title="菜单"
>
<img
src={authUser?.avatar || '/default-avatar.svg'}
alt="avatar"
className="user-avatar"
style={{ objectFit: 'cover' }}
/>
<div className="user-info">
<span className="user-name">{authUser?.nickname || '用户'}</span>
<span className="sidebar-balance-text">{balance} </span>
</div>
<svg
className={`user-chevron ${showUserMenu ? 'expanded' : ''}`}
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
{showUserMenu && (
<div className="user-dropdown-menu">
{userMenuItems.map((item) => (
<button
key={item.id}
className={`user-dropdown-item ${currentPath === item.id ? 'active' : ''}`}
onClick={() => {
setShowUserMenu(false);
onNavigate(item.id);
}}
>
{item.label}
</button>
))}
<div className="user-dropdown-divider" />
<button
className="user-dropdown-item user-dropdown-danger"
onClick={() => {
setShowUserMenu(false);
onNavigate('logout');
}}
>
退
</button>
</div>
)}
</div>
</aside>
);
@@ -1,5 +1,5 @@
/**
*
*
* =============
*
* 5
@@ -13,7 +13,7 @@ import { getEnvironmentPresets, type EnvironmentPreset } from '../../api/modules
interface EnvironmentSwitchModalProps {
open: boolean;
currentEnv: string;
onSave: (env: string, customUrl?: string) => void;
onSave: (env: string) => void;
onCancel: () => void;
}
@@ -25,7 +25,6 @@ export default function EnvironmentSwitchModal({
}: EnvironmentSwitchModalProps) {
const [presets, setPresets] = useState<EnvironmentPreset[]>([]);
const [selected, setSelected] = useState(currentEnv);
const [customUrl, setCustomUrl] = useState('');
useEffect(() => {
if (open) {
@@ -36,12 +35,7 @@ export default function EnvironmentSwitchModal({
if (!open) {return null;}
const handleSave = () => {
if (selected === 'custom') {
if (!customUrl.trim()) {return;}
onSave('custom', customUrl.trim());
} else {
onSave(selected);
}
onSave(selected);
};
return (
@@ -55,7 +49,7 @@ export default function EnvironmentSwitchModal({
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</div>
<h3 className="confirm-modal-title" style={{ marginBottom: '8px' }}></h3>
<h3 className="confirm-modal-title" style={{ marginBottom: '8px' }}></h3>
</div>
<div style={{ marginBottom: '20px' }}>
@@ -85,67 +79,11 @@ export default function EnvironmentSwitchModal({
style={{ margin: '2px 0 0' }}
/>
</div>
<div style={{ display: 'inline-block', verticalAlign: 'top', marginLeft: '8px', width: 'calc(100% - 32px)' }}>
<div style={{ display: 'inline-block', verticalAlign: 'top', marginLeft: '8px' }}>
<div style={{ fontSize: '14px', lineHeight: '20px' }}>{preset.label}</div>
<div
style={{
fontSize: '11px',
color: 'var(--text-tertiary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={preset.url}
>
{preset.url}
</div>
</div>
</label>
))}
<label
style={{
display: 'block',
padding: '8px 12px',
borderRadius: '8px',
cursor: 'pointer',
background: selected === 'custom' ? 'var(--bg-hover)' : 'transparent',
}}
>
<div style={{ display: 'inline-block', verticalAlign: 'top', width: '20px' }}>
<input
type="radio"
name="env"
value="custom"
checked={selected === 'custom'}
onChange={() => setSelected('custom')}
style={{ margin: '2px 0 0' }}
/>
</div>
<div style={{ display: 'inline-block', verticalAlign: 'top', marginLeft: '8px' }}>
<div style={{ fontSize: '14px', lineHeight: '20px' }}></div>
</div>
</label>
{selected === 'custom' && (
<input
type="text"
placeholder="输入 API 地址,如 http://192.168.1.100:8081/api/v1"
value={customUrl}
onChange={e => setCustomUrl(e.target.value)}
style={{
marginLeft: '32px',
padding: '8px 12px',
borderRadius: '8px',
border: '1px solid var(--border-light)',
fontSize: '14px',
background: 'var(--bg-primary)',
color: 'var(--text-primary)',
width: 'calc(100% - 56px)',
boxSizing: 'border-box',
}}
/>
)}
</div>
</div>
@@ -0,0 +1,48 @@
interface PointsCardProps {
balance: number;
todayConsumed: number;
onRecharge: () => void;
onViewDetail: () => void;
onViewPricing: () => void;
}
export default function PointsCard({
balance,
todayConsumed,
onRecharge,
onViewDetail,
onViewPricing,
}: PointsCardProps) {
return (
<div className="profile-points-section">
<div className="profile-points-grid">
<div className="profile-points-card">
<div className="profile-points-label"></div>
<div className="profile-points-value-row">
<span className="profile-points-value primary">{balance}</span>
<span className="profile-points-unit"></span>
</div>
</div>
<div className="profile-points-card">
<div className="profile-points-label"></div>
<div className="profile-points-value-row">
<span className="profile-points-value danger">{todayConsumed}</span>
<span className="profile-points-unit"></span>
</div>
</div>
</div>
<div className="profile-points-actions-row">
<button className="btn btn-primary btn-sm" onClick={onRecharge}>
</button>
<button className="btn btn-ghost btn-sm" onClick={onViewDetail}>
</button>
<button className="btn btn-ghost btn-sm" onClick={onViewPricing}>
</button>
</div>
</div>
);
}
@@ -0,0 +1,120 @@
import { useEffect, useState } from 'react';
import { pointsApi, type PointRule } from '../../api/modules/points';
import Modal from '../Modal/Modal';
const SOURCE_TYPE_LABELS: Record<string, string> = {
script: '脚本生成',
polish: '文案润色',
title: '标题生成',
tts: '配音合成',
voice_clone: '声音复刻',
video: '视频生成',
compose: '压制成片',
subtitle_burn: '字幕烧录',
cover_design: '封面设计',
caption: '字幕生成',
};
const ClockIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
);
interface PricingModalProps {
open: boolean;
onClose: () => void;
}
export default function PricingModal({ open, onClose }: PricingModalProps) {
const [rules, setRules] = useState<PointRule[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || rules.length > 0) return;
setLoading(true);
pointsApi.getRules()
.then(setRules)
.catch((e) => console.error('[PricingModal] 获取积分规则失败:', e))
.finally(() => setLoading(false));
}, [open, rules.length]);
return (
<Modal open={open} onClose={onClose} width="600px">
{loading ? (
<div className="profile-pricing-loading">
...
</div>
) : (
<div className="profile-pricing-body">
{/* 表格 */}
<div className="profile-pricing-table">
<div className="profile-pricing-header">
<span></span>
<span></span>
<span></span>
</div>
{rules
.filter((rule) => rule.mode !== 'free')
.map((rule) => (
<div key={rule.sourceType} className="profile-pricing-row">
<span className="profile-pricing-name">
{SOURCE_TYPE_LABELS[rule.sourceType] || rule.sourceType}
</span>
<span className="profile-pricing-mode">
<span className={`profile-pricing-tag ${rule.mode}`}>
{rule.mode === 'fixed' ? '固定' : '按时长'}
</span>
</span>
<span className="profile-pricing-points">
{rule.mode === 'fixed'
? `${rule.points} 积分/次`
: `${rule.unit} ${rule.pointsPerUnit} 积分`}
</span>
</div>
))}
{rules.filter((rule) => rule.mode !== 'free').length === 0 && (
<div className="profile-pricing-empty"></div>
)}
</div>
{/* 按时长细则 */}
{rules.some((r) => r.mode === 'duration') && (
<div className="profile-pricing-detail-section">
<div className="profile-pricing-detail-title">
<ClockIcon />
</div>
<div className="profile-pricing-detail-list">
{rules
.filter((r) => r.mode === 'duration')
.map((rule) => {
const contentType =
rule.sourceType === 'tts' ? '音频'
: rule.sourceType === 'video' ? '视频'
: '内容';
const unitNum = (rule.unit?.match(/\d+/) || ['1'])[0];
return (
<div key={rule.sourceType} className="profile-pricing-detail-row">
<strong>{SOURCE_TYPE_LABELS[rule.sourceType] || rule.sourceType}</strong>
<span>
{contentType}{rule.unit} {rule.pointsPerUnit} {unitNum}{unitNum}
{rule.sourceType === 'video' && (
<>
<br />
使 2
</>
)}
</span>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</Modal>
);
}
@@ -53,7 +53,39 @@ export default function ProgressModal() {
<div className="progress-modal-container" onClick={(e) => e.stopPropagation()}>
{/* 头部标题 */}
<div className="progress-modal-header">
{(title.includes('脚本') || title.includes('文案')) && (
{(title.includes('形象') || title.includes('克隆')) ? (
<div className="progress-modal-icon-wrapper">
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
) : (title.includes('图片') || title.includes('封面')) ? (
<div className="progress-modal-icon-wrapper">
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
) : (title.includes('视频') || title.includes('合成')) ? (
<div className="progress-modal-icon-wrapper">
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
</div>
) : (title.includes('字幕') || title.includes('压制')) ? (
<div className="progress-modal-icon-wrapper">
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="12" rx="2" ry="2" />
<line x1="7" y1="9" x2="17" y2="9" />
<line x1="7" y1="13" x2="12" y2="13" />
<path d="M8 21h8M12 17v4" />
</svg>
</div>
) : (title.includes('脚本') || title.includes('文案')) ? (
<div className="progress-modal-icon-wrapper">
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
@@ -63,43 +95,7 @@ export default function ProgressModal() {
<polyline points="10 9 9 9 8 9" />
</svg>
</div>
)}
{(title.includes('视频') || title.includes('合成')) && (
<div className="progress-modal-icon-wrapper">
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
</div>
)}
{(title.includes('字幕') || title.includes('压制')) && (
<div className="progress-modal-icon-wrapper">
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="12" rx="2" ry="2" />
<line x1="7" y1="9" x2="17" y2="9" />
<line x1="7" y1="13" x2="12" y2="13" />
<path d="M8 21h8M12 17v4" />
</svg>
</div>
)}
{(title.includes('图片') || title.includes('封面')) && (
<div className="progress-modal-icon-wrapper">
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
)}
{(title.includes('形象') || title.includes('克隆')) && (
<div className="progress-modal-icon-wrapper">
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
)}
) : null}
<h3 className="progress-modal-title">{title}</h3>
</div>
@@ -6,7 +6,6 @@
* ScriptCreation
*/
import React from 'react';
import './ShotStats.css';
export interface ShotStatsData {
@@ -11,11 +11,6 @@
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);
@@ -23,10 +18,10 @@
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);
animation: updateSlideUp 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slideUp {
@keyframes updateSlideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.96);
@@ -162,10 +157,6 @@
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 进度条 */
.update-dialog-progress {
display: flex;
+113 -45
View File
@@ -28,6 +28,7 @@ interface CoverDesignConfig {
backgroundImage: string | null;
mainTitle: string;
subtitle: string;
avatarImage?: string | null;
}
interface TemplateDef {
@@ -98,16 +99,24 @@ function wrapTextByWidth(
): string[] {
if (!text.trim()) {return [];}
// 把中英文逗号、顿号替换为换行(语义断句)
text = text
.replace(//g, '\n')
.replace(/,/g, '\n')
.replace(/、/g, '\n');
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
ctx.font = `${fontSize}px ${FONT_FAMILY}`;
// 先按用户手动换行分割,再对每行按宽度自动换行
// 换行分割,再对每行按宽度自动换行
const paragraphs = text.split('\n');
const lines: string[] = [];
for (const paragraph of paragraphs) {
const chars = paragraph.split('');
const trimmed = paragraph.trim();
if (!trimmed) {continue;}
const chars = trimmed.split('');
let currentLine = '';
for (const char of chars) {
@@ -218,6 +227,47 @@ export function useCoverFabric() {
[]
);
// 加载封面形象(叠加在背景之上,文字之下)
const loadAvatarImage = useCallback(
async (canvas: Canvas, imageUrl: string): Promise<number> => {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = (e) => reject(e);
image.src = imageUrl;
});
const fabricImg = new FabricImage(img);
// 计算缩放:宽度/高度最大占画布的 68%
const maxWidth = CANVAS_WIDTH * 0.68;
const maxHeight = CANVAS_HEIGHT * 0.68;
const scale = Math.min(
maxWidth / (fabricImg.width || 1),
maxHeight / (fabricImg.height || 1)
);
fabricImg.scale(scale);
const scaledHeight = (fabricImg.height || 1) * scale;
// 左下区域定位:左侧留边,底部与背景对齐
const leftMargin = 40;
fabricImg.set({
left: leftMargin,
top: CANVAS_HEIGHT - scaledHeight,
originX: 'left',
originY: 'top',
selectable: false,
evented: false,
});
canvas.add(fabricImg);
canvas.renderAll();
return scaledHeight;
},
[]
);
// 渲染封面
const renderCover = useCallback(
async (config: CoverDesignConfig) => {
@@ -240,54 +290,51 @@ export function useCoverFabric() {
}
}
const template = TEMPLATES[config.template];
// 2. 主标题(自动换行,最多2行,居中)
if (config.mainTitle.trim()) {
const maxWidth = CANVAS_WIDTH - 120; // 左右留边
const lines = wrapTextByWidth(
config.mainTitle.trim(),
maxWidth,
template.mainTitle.fontSize
);
const displayLines = lines.slice(0, 2); // 最多2行
const lineHeight = template.mainTitle.fontSize * 1.2;
displayLines.forEach((line, i) => {
const text = new FabricText(line, {
left: CANVAS_WIDTH / 2,
top: template.mainTitle.top + i * lineHeight,
fontSize: template.mainTitle.fontSize,
fill: template.mainTitle.fill,
fontWeight: template.mainTitle.fontWeight,
fontFamily: FONT_FAMILY,
textAlign: 'center',
originX: 'center',
originY: 'top',
selectable: false,
evented: false,
shadow: template.mainTitle.shadow,
});
canvas.add(text);
});
// 2. 封面形象(叠加在背景之上)
let avatarTop = CANVAS_HEIGHT;
if (config.avatarImage) {
try {
const scaledHeight = await loadAvatarImage(canvas, config.avatarImage);
avatarTop = CANVAS_HEIGHT - scaledHeight;
} catch {
// no-op: 封面形象加载失败不影响主流程
}
}
// 3. 副标题(底部居中,加粗)
if (config.subtitle.trim()) {
const maxWidth = CANVAS_WIDTH - 120;
const lines = wrapTextByWidth(
config.subtitle.trim(),
maxWidth,
template.subtitle.fontSize
);
const displayLines = lines.slice(0, 2);
const lineHeight = template.subtitle.fontSize * 1.5;
const totalHeight = displayLines.length * lineHeight;
const template = TEMPLATES[config.template];
displayLines.forEach((line, i) => {
// 预计算主副标题行数和高度,用于动态定位
const mainTitleLines = config.mainTitle.trim()
? wrapTextByWidth(config.mainTitle.trim(), CANVAS_WIDTH - 120, template.mainTitle.fontSize).slice(0, 2)
: [];
const subtitleLines = config.subtitle.trim()
? wrapTextByWidth(config.subtitle.trim(), CANVAS_WIDTH - 120, template.subtitle.fontSize).slice(0, 2)
: [];
const mainTitleLineHeight = template.mainTitle.fontSize * 1.2;
const subtitleLineHeight = template.subtitle.fontSize * 1.5;
const mainTitleHeight = mainTitleLines.length * mainTitleLineHeight;
const subtitleHeight = subtitleLines.length * subtitleLineHeight;
// 间距配置
const gapAvatarToMain = 50; // 人物与主标题间距
const gapMainToSub = 24; // 主标题与副标题间距
// 从人物头顶往上计算文字位置
// 主标题底部 = 人物顶部 - 间距
const mainTitleBottom = avatarTop - gapAvatarToMain;
const mainTitleTop = mainTitleBottom - mainTitleHeight;
// 副标题底部 = 主标题顶部 - 间距
const subtitleBottom = mainTitleTop - gapMainToSub;
const subtitleTop = subtitleBottom - subtitleHeight;
// 3. 副标题(放在人物上方,主标题上方)
if (subtitleLines.length > 0) {
subtitleLines.forEach((line, i) => {
const text = new FabricText(line, {
left: CANVAS_WIDTH / 2,
top: template.subtitle.top - totalHeight / 2 + i * lineHeight,
top: subtitleTop + i * subtitleLineHeight,
fontSize: template.subtitle.fontSize,
fill: template.subtitle.fill,
fontWeight: 'bold',
@@ -306,6 +353,27 @@ export function useCoverFabric() {
});
}
// 4. 主标题(放在人物上方,副标题下方)
if (mainTitleLines.length > 0) {
mainTitleLines.forEach((line, i) => {
const text = new FabricText(line, {
left: CANVAS_WIDTH / 2,
top: mainTitleTop + i * mainTitleLineHeight,
fontSize: template.mainTitle.fontSize,
fill: template.mainTitle.fill,
fontWeight: template.mainTitle.fontWeight,
fontFamily: FONT_FAMILY,
textAlign: 'center',
originX: 'center',
originY: 'top',
selectable: false,
evented: false,
shadow: template.mainTitle.shadow,
});
canvas.add(text);
});
}
canvas.renderAll();
},
[loadBackground]
+6 -5
View File
@@ -2,12 +2,13 @@
* Hook
* =================
*
* 使 Rust Command + convertFileSrc 访
* getPreviewVideoUrl URL
* H.264 Baseline + YUV420p
* readFile + Blob URL video seek
*/
import { useState, useEffect } from 'react';
import { getLocalFileUrl } from '../utils/fileUrl';
import { getPreviewVideoUrl } from '../utils/videoPreview';
interface UseLocalVideoResult {
videoUrl: string | undefined;
@@ -16,10 +17,10 @@ interface UseLocalVideoResult {
}
/**
* asset:// URL
* asset:// URL
*
* @param filePath /Users/.../scene_1.mp4
* @returns asset:// URL 或 undefined
* @returns asset:// URL 或 undefined(远程 URL 直接返回)
*/
export function useLocalVideo(filePath: string | undefined): UseLocalVideoResult {
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
@@ -49,7 +50,7 @@ export function useLocalVideo(filePath: string | undefined): UseLocalVideoResult
setError(null);
try {
const url = await getLocalFileUrl(filePath!);
const url = await getPreviewVideoUrl(filePath!);
if (canceled) {return;}
setVideoUrl(url);
+70 -33
View File
@@ -1,45 +1,82 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles/variables.css';
import './styles/global.css';
import { loadAppConfig } from './api/modules/config';
// 全局禁用浏览器默认右键菜单,提升桌面应用质感
// 输入框/文本区自动放行(保留复制/粘贴/全选)
document.addEventListener('contextmenu', (e) => {
const target = e.target as HTMLElement;
const tag = target.tagName.toLowerCase();
const isInput = tag === 'input' || tag === 'textarea' || target.isContentEditable;
if (!isInput) {
e.preventDefault();
async function bootstrap() {
// 加载运行模式配置
let appEnvironment = 'production';
try {
const config = await loadAppConfig();
appEnvironment = config.environment;
} catch {
// 加载失败时默认为生产模式
}
});
(window as unknown as Record<string, unknown>).__APP_ENV__ = appEnvironment;
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
// 右键菜单:生产模式受限,调试模式开放
// 生产模式:非输入框/文本区且未按 Shift 时阻止默认右键(不支持刷新)
// 调试模式:不拦截,WebView 默认右键菜单可用(含刷新/检查元素)
document.addEventListener('contextmenu', (e) => {
const env = (window as unknown as Record<string, unknown>).__APP_ENV__ as string;
if (env === 'production') {
const target = e.target as HTMLElement;
const tag = target.tagName.toLowerCase();
const isInput = tag === 'input' || tag === 'textarea' || target.isContentEditable;
if (!isInput && !e.shiftKey) {
e.preventDefault();
}
}
// 调试模式:不拦截,放行默认右键菜单
});
root.render(
<BrowserRouter>
<App />
</BrowserRouter>
);
// 全局快捷键:仅 F12 打开 DevTools
// 生产模式禁用,调试模式开放(macOS 需 Fn+F12
document.addEventListener('keydown', (e) => {
if (e.key === 'F12') {
const env = (window as unknown as Record<string, unknown>).__APP_ENV__ as string;
if (env === 'production') {
e.preventDefault();
return;
}
e.preventDefault();
import('@tauri-apps/api/core')
.then(({ invoke }) => invoke('open_devtools'))
.catch(() => {
// 非 Tauri 环境忽略
});
}
});
// 前端渲染完成后,通知 Tauri 显示主窗口
// 使用 requestIdleCallback 确保首帧已绘制
const showWindow = () => {
import('@tauri-apps/api/webviewWindow')
.then(({ getCurrentWebviewWindow }) => {
const win = getCurrentWebviewWindow();
win.show();
win.setFocus();
})
.catch(() => {
// 非 Tauri 环境(如浏览器开发)忽略
});
};
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
if ('requestIdleCallback' in window) {
requestIdleCallback(showWindow, { timeout: 500 });
} else {
setTimeout(showWindow, 100);
root.render(
<BrowserRouter>
<App />
</BrowserRouter>
);
// 前端渲染完成后,通知 Tauri 显示主窗口
// 使用 requestIdleCallback 确保首帧已绘制
const showWindow = () => {
import('@tauri-apps/api/webviewWindow')
.then(({ getCurrentWebviewWindow }) => {
const win = getCurrentWebviewWindow();
win.show();
win.setFocus();
})
.catch(() => {
// 非 Tauri 环境(如浏览器开发)忽略
});
};
if ('requestIdleCallback' in window) {
requestIdleCallback(showWindow, { timeout: 500 });
} else {
setTimeout(showWindow, 100);
}
}
bootstrap();
@@ -342,7 +342,8 @@
}
.settings-section h2 {
font-size: var(--font-xl);
font-size: var(--font-lg);
font-weight: 600;
}
.settings-row {
@@ -403,6 +404,432 @@
border-color: var(--text-secondary);
}
/* ── Profile 页面重构样式(基于设计系统)── */
.profile-user-section {
display: flex;
align-items: center;
gap: var(--spacing-lg);
padding: var(--spacing-xl) 28px;
}
.profile-user-info {
flex: 1;
min-width: 0;
}
.profile-nickname-wrap {
display: flex;
align-items: center;
gap: 6px;
}
.profile-nickname {
font-size: var(--font-lg);
font-weight: 600;
color: var(--text-primary);
}
.profile-nickname-input {
padding: 4px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
font-size: var(--font-lg);
font-weight: 600;
color: var(--text-primary);
outline: none;
width: 160px;
font-family: inherit;
}
.profile-nickname-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
.profile-nickname-input.error {
border-color: var(--error);
}
.profile-nickname-error {
color: var(--error);
font-size: 12px;
}
.profile-edit-icon {
cursor: pointer;
flex-shrink: 0;
color: var(--primary);
}
.profile-mobile {
font-size: var(--font-sm);
color: var(--text-secondary);
margin-top: 2px;
}
.profile-divider {
height: 1px;
background: var(--border-light);
margin: 0 28px;
}
.profile-points-section {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
padding: 20px 28px;
}
.profile-points-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-lg);
flex: 1;
}
.profile-points-card {
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius-xl);
padding: 16px 20px;
}
.profile-points-label {
font-size: var(--font-sm);
color: var(--text-secondary);
margin-bottom: 12px;
}
.profile-points-value-row {
display: flex;
align-items: baseline;
gap: 6px;
}
.profile-points-value {
font-size: 36px;
font-weight: 700;
line-height: 1;
}
.profile-points-value.primary {
color: var(--primary);
}
.profile-points-value.danger {
color: #ff6b6b;
}
.profile-points-unit {
font-size: var(--font-sm);
color: var(--text-tertiary);
}
.profile-points-actions-row {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.profile-points-actions-row .btn {
padding: 10px 20px;
font-size: var(--font-sm);
white-space: nowrap;
}
.profile-section-title {
font-size: var(--font-md);
font-weight: 600;
margin-bottom: var(--spacing-sm);
}
.profile-menu-list {
padding: 0;
overflow: hidden;
}
.profile-menu-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 16px 24px;
border: none;
border-bottom: 1px solid var(--border-light);
background: transparent;
cursor: pointer;
font-size: var(--font-base);
font-family: inherit;
color: var(--text-primary);
text-align: left;
transition: background var(--transition-fast);
}
.profile-menu-item:last-child {
border-bottom: none;
}
.profile-menu-item:hover {
background: var(--bg-hover);
}
.profile-menu-icon {
font-size: 18px;
line-height: 1;
color: var(--text-secondary);
}
.profile-menu-label {
flex: 1;
}
.profile-menu-arrow {
color: var(--text-tertiary);
}
.profile-logout-btn {
width: 100%;
padding: 12px;
border-radius: var(--radius-lg);
border: none;
background: transparent;
font-size: var(--font-sm);
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s;
}
.profile-logout-btn:hover {
color: var(--error);
}
/* 定价 Modal */
.profile-pricing-body {
padding: 8px 4px;
}
.profile-pricing-table {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-light);
overflow: hidden;
}
.profile-pricing-header {
display: grid;
grid-template-columns: 1fr 90px 140px;
gap: 12px;
padding: 14px 20px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-light);
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-secondary);
}
.profile-pricing-header span:nth-child(2) {
text-align: center;
}
.profile-pricing-header span:nth-child(3) {
text-align: right;
}
.profile-pricing-row {
display: grid;
grid-template-columns: 1fr 90px 140px;
gap: 12px;
padding: 10px 20px;
border-bottom: 1px solid var(--border-light);
align-items: center;
}
.profile-pricing-row:last-child {
border-bottom: none;
}
.profile-pricing-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.profile-pricing-mode {
text-align: center;
}
.profile-pricing-tag {
display: inline-block;
font-size: 12px;
padding: 3px 10px;
border-radius: 20px;
font-weight: 500;
}
.profile-pricing-tag.fixed {
background: #e8f4fd;
color: #1976d2;
}
.profile-pricing-tag.duration {
background: #fff3e0;
color: #e65100;
}
.profile-pricing-points {
font-size: 14px;
font-weight: 600;
color: var(--primary);
text-align: right;
}
.profile-pricing-empty {
text-align: center;
padding: 32px;
color: var(--text-secondary);
font-size: 14px;
}
.profile-pricing-detail-section {
margin-top: var(--spacing-lg);
padding: 16px 20px;
background: #fff8f0;
border-radius: 10px;
border: 1px solid #ffe8d0;
}
.profile-pricing-detail-title {
font-size: var(--font-sm);
font-weight: 600;
color: #d46b08;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
.profile-pricing-detail-row {
display: flex;
align-items: baseline;
gap: 8px;
font-size: var(--font-sm);
color: var(--text-secondary);
line-height: 1.6;
}
.profile-pricing-detail-row strong {
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
.profile-pricing-info-section {
margin-top: var(--spacing-lg);
padding: 16px 20px;
background: #f8faf8;
border-radius: 10px;
border: 1px solid #e8f0e8;
}
.profile-pricing-info-title {
font-size: var(--font-sm);
font-weight: 600;
color: #2e7d32;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
.profile-pricing-info-list {
margin: 0;
padding-left: 18px;
font-size: var(--font-sm);
color: var(--text-secondary);
line-height: 1.8;
}
.profile-card-flat {
padding: 0;
overflow: hidden;
}
/* ── 页面返回按钮 ── */
.page-back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: var(--spacing-sm) var(--spacing-md);
border: none;
background: transparent;
color: var(--text-secondary);
font-size: var(--font-sm);
font-family: inherit;
cursor: pointer;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
.page-back-btn:hover {
color: var(--primary);
background: var(--bg-hover);
}
.page-back-btn svg {
flex-shrink: 0;
}
/* ── AppHeader ── */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.app-header-left {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.app-header-title {
font-size: var(--font-xl);
font-weight: 600;
margin: 0;
}
.app-header-right {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.profile-edit-wrap {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.profile-section-spaced {
margin-top: var(--spacing-lg);
}
.profile-pricing-loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.profile-pricing-detail-list {
display: flex;
flex-direction: column;
gap: 10px;
}
/* Avatar Clone Card - 这个就是我们要复用的样式 */
.avatar-card {
position: relative;
@@ -506,9 +933,7 @@
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.avatar-card:hover .avatar-card-video {
transform: scale(1.05);
@@ -555,11 +980,13 @@
transform: translateY(-8px);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 10;
pointer-events: none;
}
.avatar-card:hover .avatar-card-actions {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.avatar-card-action-btn {
@@ -664,6 +1091,18 @@
flex: 1;
}
/* 封面形象网格 — 复用 avatar-card 风格 */
.cover-avatar-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-xl);
padding: var(--spacing-sm) var(--spacing-xs);
flex: 1;
align-content: start;
align-items: start;
overflow-y: auto;
}
/* Content Page Compact variant */
.content-page-compact {
gap: var(--spacing-md);
@@ -1632,3 +2071,171 @@
max-width: 280px;
line-height: 1.6;
}
/* ============================================================
关于我们 (About) - 品牌展示 + 版本更新
============================================================ */
.about-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-md);
}
.about-brand {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-xs);
}
.about-logo {
width: 64px;
height: 64px;
border-radius: var(--radius-lg);
object-fit: contain;
}
.about-name {
font-size: var(--font-lg);
font-weight: 600;
color: var(--text-primary);
}
.about-version-row {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 320px;
margin: 0 auto;
padding: var(--spacing-xs) 0;
gap: var(--spacing-sm);
}
.about-version-text {
font-size: var(--font-sm);
color: var(--text-secondary);
cursor: default;
user-select: none;
flex-shrink: 0;
font-weight: 500;
}
.about-version-actions {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.about-checking-status {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: var(--font-sm);
color: var(--text-tertiary);
}
.about-check-result {
font-size: var(--font-sm);
font-weight: 500;
white-space: nowrap;
}
.about-check-latest {
color: var(--success);
}
.about-check-available {
color: var(--primary);
}
.about-check-btn {
flex-shrink: 0;
background: transparent;
color: var(--primary);
border: none;
border-radius: var(--radius-md);
padding: 4px 10px;
font-size: var(--font-xs);
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.about-check-btn:hover:not(:disabled) {
background: rgba(54, 178, 106, 0.08);
}
.about-check-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.about-update-detail {
width: 100%;
padding: var(--spacing-lg);
border-top: 1px solid var(--border-light);
background: var(--bg-input);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.about-update-body-title {
font-size: var(--font-sm);
font-weight: 500;
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
}
.about-update-body pre {
font-size: var(--font-sm);
line-height: 1.7;
white-space: pre-wrap;
margin: 0;
color: var(--text-primary);
}
.about-update-progress {
margin-bottom: var(--spacing-sm);
}
.about-progress-bar {
height: 6px;
background: var(--border-light);
border-radius: 3px;
overflow: hidden;
}
.about-progress-fill {
height: 100%;
background: var(--primary);
border-radius: 3px;
transition: width 0.3s;
}
.about-progress-meta {
display: flex;
justify-content: space-between;
font-size: var(--font-xs);
color: var(--text-tertiary);
margin-top: 4px;
}
.about-update-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
}
.about-update-error {
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
background: #fef2f2;
color: #dc2626;
font-size: var(--font-sm);
border-radius: var(--radius-md);
}
@@ -0,0 +1,397 @@
/**
*
* ============
*
*
* MediaKit
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { useCoverAvatarStore } from '../../store';
import { usePointStore } from '../../store';
import { toast } from '../../store/uiStore';
import { useProgressStore } from '../../store/progressStore';
import Modal from '../../components/Modal/Modal';
import ConfirmModal from '../../components/Modal/ConfirmModal';
import RechargeModal from '../../components/RechargeModal/RechargeModal';
import './ContentManagement.css';
export default function CoverAvatarLibrary() {
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadName, setUploadName] = useState('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
// 重命名状态
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
// 删除确认状态
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
// 积分不足弹窗
const [showPointsModal, setShowPointsModal] = useState(false);
const showRechargeModal = usePointStore((state) => state.showRechargeModal);
const setShowRechargeModal = usePointStore((state) => state.setShowRechargeModal);
const fetchBalance = usePointStore((state) => state.fetchBalance);
const fileInputRef = useRef<HTMLInputElement>(null);
const {
coverAvatars,
isLoading,
loadCoverAvatars,
addCoverAvatar,
renameCoverAvatar,
deleteCoverAvatar,
} = useCoverAvatarStore();
// 加载数据
useEffect(() => {
loadCoverAvatars();
}, [loadCoverAvatars]);
// 图片文件验证
const validateImageFile = (file: File): { valid: boolean; error?: string } => {
const maxSize = 20 * 1024 * 1024; // 20MB
if (file.size > maxSize) {
return { valid: false, error: `文件大小 ${(file.size / 1024 / 1024).toFixed(1)}MB,要求不超过 20MB` };
}
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return { valid: false, error: '仅支持 JPG、PNG、GIF、WebP 格式' };
}
return { valid: true };
};
// 文件选择
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {return;}
const validation = validateImageFile(file);
if (!validation.valid) {
toast.error(validation.error || '文件验证失败');
e.target.value = '';
return;
}
setSelectedFile(file);
// 默认使用文件名(不含扩展名)作为名称
if (!uploadName.trim()) {
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
setUploadName(nameWithoutExt);
}
}, [uploadName]);
// 重置弹窗状态
const resetUploadModal = useCallback(() => {
setUploadModalOpen(false);
setUploadName('');
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, []);
// 上传处理
const handleUpload = useCallback(async () => {
if (!uploadName.trim() || !selectedFile) {return;}
// 前置积分检查
await fetchBalance();
const coverAvatarPoints = usePointStore.getState().getRule('cover_avatar')?.points || 10;
const balance = usePointStore.getState().balance;
if (balance < coverAvatarPoints) {
setShowPointsModal(true);
return;
}
const progress = useProgressStore.getState();
setUploadModalOpen(false);
progress.show('上传封面形象');
try {
progress.update('正在上传并抠图...');
const avatar = await addCoverAvatar(selectedFile, uploadName.trim());
progress.success('封面形象保存成功', 200);
toast.success(`${avatar.name}」已保存`);
} catch (err) {
const msg = err instanceof Error ? err.message : '上传失败';
progress.error(msg);
toast.error(msg);
}
setUploadName('');
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [uploadName, selectedFile, addCoverAvatar, fetchBalance]);
// 删除处理
const openDeleteModal = (id: string, name: string) => {
setDeleteTarget({ id, name });
setDeleteModalOpen(true);
};
const handleConfirmDelete = useCallback(async () => {
if (!deleteTarget) {return;}
try {
await deleteCoverAvatar(deleteTarget.id);
toast.success('已删除');
} catch {
toast.error('删除失败');
} finally {
setDeleteModalOpen(false);
setDeleteTarget(null);
}
}, [deleteTarget, deleteCoverAvatar]);
// 重命名
const startRename = (id: string, currentName: string) => {
setEditingId(id);
setEditingName(currentName);
};
const cancelRename = () => {
setEditingId(null);
setEditingName('');
};
const confirmRename = useCallback(async () => {
if (!editingId || !editingName.trim()) {
cancelRename();
return;
}
try {
await renameCoverAvatar(editingId, editingName.trim());
setEditingId(null);
setEditingName('');
} catch {
toast.error('重命名失败');
}
}, [editingId, editingName, renameCoverAvatar]);
return (
<div className="content-page">
{/* 页面标题和上传区域 */}
<div className="voice-clone-wrapper">
<div className="voice-clone-title-group">
<h2></h2>
<p className="voice-clone-desc">AI </p>
</div>
{/* 上传引导卡片 */}
<div
className="voice-upload-card"
onClick={() => setUploadModalOpen(true)}
>
<div className="voice-upload-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
<div className="voice-upload-text">
<span className="voice-upload-title"></span>
<span className="voice-upload-hint">JPG / PNG / GIF / WebP</span>
</div>
<div className="voice-upload-arrow">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</div>
</div>
</div>
{/* 上传弹窗 */}
<Modal
open={uploadModalOpen}
onClose={resetUploadModal}
title=""
width="480px"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<label style={{ fontSize: 'var(--font-sm)', fontWeight: 500, marginBottom: 8, display: 'block' }}>
</label>
<input
type="text"
className="input"
placeholder="例如:我的形象"
value={uploadName}
onChange={e => setUploadName(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div>
<label style={{ fontSize: 'var(--font-sm)', fontWeight: 500, marginBottom: 8, display: 'block' }}>
</label>
<div
style={{
border: '2px dashed var(--border-color)',
borderRadius: 'var(--radius-md)',
padding: 'var(--spacing-xl)',
textAlign: 'center',
cursor: 'pointer',
transition: 'all var(--transition-fast)',
}}
onClick={() => fileInputRef.current?.click()}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--primary)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-color)'; }}
>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
{selectedFile ? (
<div>
<div style={{ fontWeight: 500, fontSize: 'var(--font-sm)' }}>{selectedFile.name}</div>
<div style={{ fontSize: 'var(--font-xs)', color: 'var(--text-secondary)', marginTop: 4 }}>
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</div>
</div>
) : (
<div style={{ color: 'var(--text-secondary)' }}>
<div style={{ fontSize: 'var(--font-sm)' }}></div>
<div style={{ fontSize: 'var(--font-xs)', marginTop: 6, lineHeight: 1.6 }}>
JPG / PNG / GIF / WebP 20MB
</div>
</div>
)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 'var(--font-xs)', color: 'var(--text-tertiary)' }}>
<strong style={{ color: 'var(--primary)' }}>10</strong>
</span>
<div style={{ display: 'flex', gap: 12 }}>
<button className="btn btn-secondary" onClick={resetUploadModal}></button>
<button
className="btn btn-primary"
onClick={handleUpload}
disabled={!uploadName.trim() || !selectedFile}
>
</button>
</div>
</div>
</div>
</Modal>
{/* 封面形象列表 */}
{isLoading ? (
<p style={{ color: 'var(--text-secondary)' }}>...</p>
) : coverAvatars.length === 0 ? (
<div className="empty-state-page">
<div className="empty-state-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
<p className="empty-state-title"></p>
<p className="empty-state-desc"><br />AI </p>
</div>
) : (
<div className="cover-avatar-grid">
{coverAvatars.map(a => (
<div key={a.id} className="avatar-card">
{/* 图片预览 */}
<div className="avatar-card-thumb-container">
<img
src={a.imageUrl}
alt={a.name}
className="avatar-card-video"
style={{ objectFit: 'contain', background: 'var(--bg-input)' }}
loading="lazy"
/>
{/* 悬停操作按钮 */}
<div className="avatar-card-actions">
<button
className="avatar-card-action-btn"
onClick={() => startRename(a.id, a.name)}
title="重命名"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" /></svg>
</button>
<button
className="avatar-card-action-btn delete"
onClick={() => openDeleteModal(a.id, a.name)}
title="删除"
>
</button>
</div>
</div>
{/* 名称 */}
<div className="avatar-card-info">
{editingId === a.id ? (
<input
type="text"
className="avatar-card-name-input"
value={editingName}
onChange={e => setEditingName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {confirmRename();}
if (e.key === 'Escape') {cancelRename();}
}}
onBlur={confirmRename}
autoFocus
/>
) : (
<div className="avatar-card-name">{a.name}</div>
)}
</div>
</div>
))}
</div>
)}
{/* 删除确认弹窗 */}
<ConfirmModal
open={deleteModalOpen}
type="danger"
title={<> <strong>{deleteTarget?.name}</strong> </>}
description="此操作不可撤销,形象将被永久删除"
confirmText="确认删除"
cancelText="取消"
confirmButtonType="danger"
onConfirm={handleConfirmDelete}
onCancel={() => { setDeleteModalOpen(false); setDeleteTarget(null); }}
/>
{/* 积分不足弹窗 */}
<ConfirmModal
open={showPointsModal}
type="warning"
title="积分不足"
description={`每次上传封面形象消耗 10 积分,当前余额不足。请先充值后再尝试。`}
confirmText="立即充值"
cancelText="稍后再说"
confirmButtonType="danger"
onConfirm={() => { setShowPointsModal(false); setShowRechargeModal(true); }}
onCancel={() => setShowPointsModal(false)}
onClose={() => setShowPointsModal(false)}
/>
<RechargeModal
open={showRechargeModal}
onClose={() => setShowRechargeModal(false)}
onRechargeSuccess={() => { fetchBalance(); setShowPointsModal(false); }}
/>
</div>
);
}
@@ -392,15 +392,20 @@ export default function VoiceMaterialLibrary() {
</div>
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button className="btn btn-secondary" onClick={() => setUploadModalOpen(false)}></button>
<button
className="btn btn-primary"
onClick={handleUpload}
disabled={!uploadName.trim() || !selectedFile}
>
</button>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 'var(--font-xs)', color: 'var(--text-tertiary)' }}>
<strong style={{ color: 'var(--primary)' }}>200</strong>
</span>
<div style={{ display: 'flex', gap: 12 }}>
<button className="btn btn-secondary" onClick={() => setUploadModalOpen(false)}></button>
<button
className="btn btn-primary"
onClick={handleUpload}
disabled={!uploadName.trim() || !selectedFile}
>
</button>
</div>
</div>
</div>
</Modal>
+72 -178
View File
@@ -4,6 +4,9 @@ import { useAuthStore } from '../../store';
import { client } from '../../api/client';
import { pointsApi, type PointBalance, type PointTransaction } from '../../api/modules/points';
import RechargeModal from '../../components/RechargeModal/RechargeModal';
import PricingModal from '../../components/PricingModal/PricingModal';
import PointsCard from '../../components/PointsCard/PointsCard';
import AppHeader from '../../components/Layout/AppHeader';
import '../ContentManagement/ContentManagement.css';
interface UserProfile {
@@ -13,40 +16,28 @@ interface UserProfile {
avatar: string;
}
function formatTxTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
const TYPE_LABELS: Record<string, string> = {
recharge: '充值',
consume: '消费',
expire: '过期',
refund: '退款',
};
function maskMobile(mobile: string): string {
if (!mobile || mobile.length !== 11) {return mobile;}
if (!mobile || mobile.length !== 11) { return mobile; }
return `${mobile.slice(0, 3)}****${mobile.slice(7)}`;
}
const EditIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
</svg>
);
export default function Profile() {
const { navigate } = useNavigation();
const authUser = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const [user, setUser] = useState<UserProfile | null>(null);
const [balance, setBalance] = useState<PointBalance | null>(null);
const [recentTx, setRecentTx] = useState<PointTransaction[]>([]);
const [todayConsumed, setTodayConsumed] = useState(0);
const [loading, setLoading] = useState(true);
const [showRechargeModal, setShowRechargeModal] = useState(false);
const [showPricingModal, setShowPricingModal] = useState(false);
const [recentTx, setRecentTx] = useState<PointTransaction[]>([]);
const [loading, setLoading] = useState(true);
// 昵称编辑状态
const [nickname, setNickname] = useState('');
@@ -65,14 +56,14 @@ export default function Profile() {
setUser(profileData);
setNickname(profileData.nickname || '');
}
if (balanceData) {setBalance(balanceData);}
if (balanceData) { setBalance(balanceData); }
const [txData, todayData] = await Promise.all([
pointsApi.getTransactions({ page: 1, pageSize: 10 }).catch(() => null),
pointsApi.getTransactions({ page: 1, pageSize: 5 }).catch(() => null),
pointsApi.getTodayConsumed().catch(() => null),
]);
if (txData) {setRecentTx(txData.items);}
if (todayData) {setTodayConsumed(todayData.total);}
if (txData) { setRecentTx(txData.items); }
if (todayData) { setTodayConsumed(todayData.total); }
} catch (e) {
console.error('[Profile] 加载数据失败:', e);
} finally {
@@ -88,6 +79,10 @@ export default function Profile() {
loadData();
};
const handleOpenPricing = () => {
setShowPricingModal(true);
};
const handleSaveNickname = async () => {
const trimmed = nickname.trim();
if (!trimmed) { setNickError('昵称不能为空'); return; }
@@ -109,30 +104,43 @@ export default function Profile() {
}
};
const handleLogout = async () => {
if (!window.confirm('确定要退出登录吗?')) {return;}
await logout();
window.location.reload();
};
const displayName = user?.nickname || authUser?.nickname || '用户';
const displayAvatar = user?.avatar || authUser?.avatar || '';
const displayMobile = user?.mobile ? maskMobile(user.mobile) : '';
const TYPE_LABELS: Record<string, string> = {
recharge: '充值',
consume: '消费',
expire: '过期',
refund: '退款',
};
function formatTxTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
return (
<div className="settings-page">
{/* 个人资料卡片 */}
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<AppHeader title="我的账户" />
{/* 个人信息 + 积分 */}
<div className="card profile-card-flat">
{/* 用户区 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', padding: '24px 28px' }}>
<div className="profile-user-section">
<img
src={displayAvatar || '/default-avatar.svg'}
alt="avatar"
className="profile-topbar-avatar"
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="profile-user-info">
{editing ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<div className="profile-edit-wrap">
<input
type="text"
value={nickname}
@@ -140,19 +148,9 @@ export default function Profile() {
maxLength={20}
autoFocus
disabled={saving}
style={{
padding: '4px 10px',
borderRadius: '6px',
border: nickError ? '1px solid #e74c3c' : '1px solid var(--border-color)',
fontSize: '18px',
fontWeight: 600,
color: 'var(--text-primary)',
outline: 'none',
width: '160px',
fontFamily: 'inherit',
}}
className={`profile-nickname-input ${nickError ? 'error' : ''}`}
onKeyDown={(e) => {
if (e.key === 'Enter') {handleSaveNickname();}
if (e.key === 'Enter') { handleSaveNickname(); }
if (e.key === 'Escape') {
setEditing(false);
setNickname(displayName);
@@ -171,114 +169,42 @@ export default function Profile() {
}}
/>
{nickError && (
<span style={{ color: '#e74c3c', fontSize: '12px' }}>
{nickError}
</span>
<span className="profile-nickname-error">{nickError}</span>
)}
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontSize: '18px', fontWeight: 600, color: 'var(--text-primary)' }}>
{displayName}
<div className="profile-nickname-wrap">
<span className="profile-nickname">{displayName}</span>
<span className="profile-edit-icon" onClick={() => setEditing(true)}>
<EditIcon />
</span>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="#36b26a"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
style={{ cursor: 'pointer', flexShrink: 0 }}
onClick={() => setEditing(true)}
>
<title></title>
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
</svg>
</div>
)}
{displayMobile && (
<div style={{ fontSize: '13px', color: 'var(--text-secondary)', marginTop: '2px' }}>
{displayMobile}
</div>
<div className="profile-mobile">{displayMobile}</div>
)}
</div>
</div>
<div style={{ height: 1, background: 'var(--border-light)', margin: '0 28px' }} />
<div className="profile-divider" />
{/* 积分统计卡片 */}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '16px', padding: '24px 28px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', flex: 1 }}>
{/* 剩余积分 */}
<div
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-xl)',
padding: '20px 24px',
}}
>
<div style={{ fontSize: 'var(--font-sm)', color: 'var(--text-secondary)', marginBottom: '12px' }}>
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '6px' }}>
<span style={{ fontSize: '36px', fontWeight: 700, color: '#36b26a', lineHeight: 1 }}>
{balance?.balance ?? 0}
</span>
<span style={{ fontSize: 'var(--font-sm)', color: 'var(--text-tertiary)' }}></span>
</div>
</div>
{/* 今日消耗 */}
<div
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-xl)',
padding: '20px 24px',
}}
>
<div style={{ fontSize: 'var(--font-sm)', color: 'var(--text-secondary)', marginBottom: '12px' }}>
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '6px' }}>
<span style={{ fontSize: '36px', fontWeight: 700, color: '#ff6b6b', lineHeight: 1 }}>
{todayConsumed}
</span>
<span style={{ fontSize: 'var(--font-sm)', color: 'var(--text-tertiary)' }}></span>
</div>
</div>
</div>
{/* 操作按钮 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<button
className="btn btn-primary btn-sm"
style={{ padding: '10px 24px', fontSize: 'var(--font-sm)', whiteSpace: 'nowrap' }}
onClick={() => setShowRechargeModal(true)}
>
</button>
<button
className="btn btn-ghost btn-sm"
style={{ padding: '10px 24px', fontSize: 'var(--font-sm)', whiteSpace: 'nowrap' }}
onClick={() => {
localStorage.setItem('usage-detail-initial-tab', 'recharge');
navigate('usage-detail');
}}
>
</button>
</div>
</div>
{/* 积分统计 */}
<PointsCard
balance={balance?.balance ?? 0}
todayConsumed={todayConsumed}
onRecharge={() => setShowRechargeModal(true)}
onViewDetail={() => {
localStorage.setItem('usage-detail-initial-tab', 'recharge');
navigate('usage-detail');
}}
onViewPricing={handleOpenPricing}
/>
</div>
{/* 最近记录表格 */}
<div style={{ marginTop: 'var(--spacing-xl)' }}>
{/* 最近记录 */}
<div className="profile-section-spaced">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontSize: 'var(--font-md)', fontWeight: 600 }}></h3>
<h3 style={{ fontSize: 'var(--font-md)', fontWeight: 600, margin: 0 }}></h3>
<button className="btn btn-ghost btn-sm" onClick={() => navigate('usage-detail')}>
</button>
@@ -289,8 +215,6 @@ export default function Profile() {
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
@@ -298,13 +222,13 @@ export default function Profile() {
<tbody>
{loading ? (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
<td colSpan={4} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
...
</td>
</tr>
) : recentTx.length === 0 ? (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
<td colSpan={4} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
</td>
</tr>
@@ -326,8 +250,6 @@ export default function Profile() {
<td style={{ fontWeight: 600 }}>
{tx.type === 'recharge' ? '+' : '-'}{tx.amount}
</td>
<td>{tx.balanceBefore}</td>
<td>{tx.balanceAfter}</td>
<td className="description-cell" title={tx.description || '-'}>
{tx.description || '-'}
</td>
@@ -340,41 +262,13 @@ export default function Profile() {
</div>
</div>
{/* 退出登录 — 页面底部 */}
<div style={{ marginTop: 'var(--spacing-xl)' }}>
<button
onClick={handleLogout}
style={{
width: '100%',
padding: '14px',
borderRadius: 'var(--radius-lg)',
border: '1px solid var(--border-light)',
background: 'var(--bg-card)',
fontSize: 'var(--font-sm)',
color: 'var(--text-secondary)',
cursor: 'pointer',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
(e.target as HTMLButtonElement).style.color = '#e74c3c';
(e.target as HTMLButtonElement).style.borderColor = '#e74c3c';
(e.target as HTMLButtonElement).style.background = '#fdf2f2';
}}
onMouseLeave={(e) => {
(e.target as HTMLButtonElement).style.color = 'var(--text-secondary)';
(e.target as HTMLButtonElement).style.borderColor = 'var(--border-light)';
(e.target as HTMLButtonElement).style.background = 'var(--bg-card)';
}}
>
退
</button>
</div>
<RechargeModal
open={showRechargeModal}
onClose={() => setShowRechargeModal(false)}
onRechargeSuccess={handleRechargeSuccess}
/>
<PricingModal open={showPricingModal} onClose={() => setShowPricingModal(false)} />
</div>
);
}
+5 -1
View File
@@ -1,4 +1,6 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useNavigation } from '../../contexts/NavigationContext';
import AppHeader from '../../components/Layout/AppHeader';
import { pointsApi, type PointTransaction } from '../../api/modules/points';
import DateRangePicker from '../../components/DatePicker/DateRangePicker';
import '../ContentManagement/ContentManagement.css';
@@ -360,10 +362,12 @@ export default function UsageDetail() {
return `${sign}${Math.abs(amount)}`;
};
const { navigate } = useNavigation();
return (
<div className="settings-page">
<AppHeader title="积分明细" showBack onBack={() => navigate('profile')} />
<div className="settings-section">
<h2></h2>
{/* Tab 切换 */}
<div
-112
View File
@@ -1,112 +0,0 @@
import { useState, useRef, useCallback } from 'react';
import '../ContentManagement/ContentManagement.css';
import EnvironmentSwitchModal from '../../components/Modal/EnvironmentSwitchModal';
import { useNavigation } from '../../contexts/NavigationContext';
import { saveAppConfig } from '../../api/modules/config';
import { invoke } from '@tauri-apps/api/core';
import { toast } from '../../store/uiStore';
const CURRENT_VERSION = __APP_VERSION__;
export default function AboutUs() {
const { appEnvironment } = useNavigation();
const [showModal, setShowModal] = useState(false);
// 版本号连击检测
const clickCountRef = useRef(0);
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleVersionClick = useCallback(() => {
clickCountRef.current += 1;
if (clickCountRef.current === 5) {
setShowModal(true);
clickCountRef.current = 0;
if (clickTimerRef.current) {clearTimeout(clickTimerRef.current);}
return;
}
if (clickTimerRef.current) {clearTimeout(clickTimerRef.current);}
clickTimerRef.current = setTimeout(() => {
clickCountRef.current = 0;
}, 2000);
}, []);
const handleSave = async (env: string, customUrl?: string) => {
try {
await saveAppConfig(env, customUrl);
// 开发模式下 Vite dev server 重启后无法自动恢复,改用刷新页面
if (import.meta.env.DEV) {
toast.success('配置已保存,即将刷新');
setTimeout(() => {
window.location.reload();
}, 500);
return;
}
toast.success('配置已保存,应用即将重启');
setTimeout(() => {
invoke('restart_app');
}, 800);
} catch {
toast.error('保存配置失败');
}
};
return (
<div className="settings-page">
<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"> </span>
</div>
<div className="settings-row" style={{ borderTop: '1px solid var(--border-light)' }}>
<span className="settings-row-label"></span>
<span
className="settings-row-value"
onClick={handleVersionClick}
style={{ cursor: 'default', userSelect: 'none' }}
title={appEnvironment !== 'production' ? `当前环境: ${appEnvironment}` : undefined}
>
v{CURRENT_VERSION}
</span>
</div>
</div>
</div>
<div className="settings-section">
<h2></h2>
<div className="card" style={{ padding: 'var(--spacing-xl)' }}>
<p style={{ lineHeight: 1.8, margin: 0 }}>
使
</p>
</div>
</div>
<div className="settings-section">
<h2></h2>
<div className="card" style={{ padding: 'var(--spacing-xl)' }}>
<p style={{ lineHeight: 1.8, margin: 0 }}>
Copyright 2025 (meijiaka.cn). All rights reserved.
</p>
<p style={{ lineHeight: 1.8, marginTop: 'var(--spacing-md)', marginBottom: 0 }}>
使
</p>
</div>
</div>
<EnvironmentSwitchModal
key={appEnvironment}
open={showModal}
currentEnv={appEnvironment}
onSave={handleSave}
onCancel={() => setShowModal(false)}
/>
</div>
);
}
+332
View File
@@ -0,0 +1,332 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useNavigation } from '../../contexts/NavigationContext';
import AppHeader from '../../components/Layout/AppHeader';
import EnvironmentSwitchModal from '../../components/Modal/EnvironmentSwitchModal';
import { check, type Update, type DownloadEvent } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import { saveAppConfig } from '../../api/modules/config';
import { invoke } from '@tauri-apps/api/core';
import { toast } from '../../store/uiStore';
import '../ContentManagement/ContentManagement.css';
const CURRENT_VERSION = __APP_VERSION__;
export default function Settings() {
const { navigate, appEnvironment } = useNavigation();
// ── 系统更新状态 ──
const [checking, setChecking] = 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 [updateError, setUpdateError] = useState<string | null>(null);
// ── 环境切换 ──
const [showEnvModal, setShowEnvModal] = useState(false);
// ── 缓存清理 ──
const [cacheSize, setCacheSize] = useState(0);
const [clearingCache, setClearingCache] = useState(false);
const clickCountRef = useRef(0);
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
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);
setCheckResult('none');
setUpdateError(null);
setUpdateInfo(null);
try {
const update = await check();
if (update) {
setUpdateInfo(update);
setCheckResult('available');
} else {
setCheckResult('latest');
}
} catch (err) {
console.error('[Settings] 检查更新失败:', err);
setUpdateError(err instanceof Error ? err.message : '检查更新失败');
setCheckResult('none');
} finally {
setChecking(false);
}
};
const handleDownloadAndInstall = async () => {
if (!updateInfo) return;
setDownloading(true);
setProgress(0);
setDownloadedBytes(0);
setTotalBytes(0);
setUpdateError(null);
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('[Settings] 下载安装失败:', err);
setUpdateError(err instanceof Error ? err.message : '下载安装失败');
setDownloading(false);
}
};
const handleRelaunch = async () => {
try {
await relaunch();
} catch (err) {
setUpdateError(err instanceof Error ? err.message : '重启失败');
}
};
// ── 版本号连击 ──
const handleVersionClick = useCallback(() => {
clickCountRef.current += 1;
if (clickCountRef.current === 5) {
setShowEnvModal(true);
clickCountRef.current = 0;
if (clickTimerRef.current) { clearTimeout(clickTimerRef.current); }
return;
}
if (clickTimerRef.current) { clearTimeout(clickTimerRef.current); }
clickTimerRef.current = setTimeout(() => {
clickCountRef.current = 0;
}, 2000);
}, []);
// 获取缓存大小
const fetchCacheSize = useCallback(async () => {
try {
const size = await invoke<number>('get_video_cache_size_cmd');
setCacheSize(size);
} catch {
setCacheSize(0);
}
}, []);
useEffect(() => {
fetchCacheSize();
return () => {
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
}
};
}, [fetchCacheSize]);
// 清理缓存
const handleClearCache = async () => {
if (cacheSize === 0) {
toast.info('暂无缓存需要清理');
return;
}
setClearingCache(true);
try {
const freed = await invoke<number>('clear_video_cache_cmd');
setCacheSize(0);
toast.success(`已清理 ${formatBytes(freed)} 缓存`);
} catch {
toast.error('清理缓存失败');
} finally {
setClearingCache(false);
}
};
const handleSaveEnv = async (env: string) => {
try {
await saveAppConfig(env);
if (import.meta.env.DEV) {
toast.success('配置已保存,即将刷新');
setTimeout(() => { window.location.reload(); }, 500);
return;
}
toast.success('配置已保存,应用即将重启');
setTimeout(() => { invoke('restart_app'); }, 800);
} catch {
toast.error('保存配置失败');
}
};
return (
<div className="settings-page">
<AppHeader title="设置" showBack onBack={() => navigate('profile')} />
{/* 关于我们 */}
<div className="settings-section">
<div className="card about-card" style={{ padding: 'var(--spacing-lg)', overflow: 'hidden' }}>
{/* 品牌 */}
<div className="about-brand">
<img className="about-logo" src="/assets/logo.png" alt="美家卡智影" />
<span className="about-name"> </span>
</div>
{/* 版本 + 检查更新 */}
<div className="about-version-row">
<span
className="about-version-text"
onClick={handleVersionClick}
title={appEnvironment !== 'production' ? `当前环境: ${appEnvironment}` : undefined}
>
v{CURRENT_VERSION}
</span>
<div className="about-version-actions">
{checking ? (
<span className="about-checking-status">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2" style={{ animation: 'spin 1s linear infinite' }}>
<path d="M21 12a9 9 0 11-6.219-8.56" />
</svg>
...
</span>
) : checkResult === 'latest' ? (
<span className="about-check-result about-check-latest"></span>
) : checkResult === 'available' ? (
<span className="about-check-result about-check-available"> {updateInfo?.version}</span>
) : null}
<button
className="btn btn-sm about-check-btn"
onClick={handleCheck}
disabled={checking || downloading}
>
{checking ? '检查中' : '检查更新'}
</button>
</div>
</div>
{/* 更新详情(发现新版本时展开) */}
{checkResult === 'available' && updateInfo && (
<div className="about-update-detail">
{updateInfo.body && (
<div className="about-update-body">
<div className="about-update-body-title"></div>
<pre>{updateInfo.body}</pre>
</div>
)}
{downloading && (
<div className="about-update-progress">
<div className="about-progress-bar">
<div className="about-progress-fill" style={{ width: `${progress}%` }} />
</div>
<div className="about-progress-meta">
<span>{progress}%</span>
{totalBytes > 0 && <span>{formatBytes(downloadedBytes)} / {formatBytes(totalBytes)}</span>}
</div>
</div>
)}
<div className="about-update-actions">
{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>
)}
{updateError && (
<div className="about-update-error">
{updateError}
</div>
)}
</div>
</div>
{/* 缓存清理 */}
<div className="settings-section">
<h2></h2>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div className="settings-row">
<span className="settings-row-label"></span>
<div className="settings-row-value" style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-md)', justifyContent: 'flex-end' }}>
<span style={{ fontSize: 'var(--font-sm)', color: 'var(--text-tertiary)' }}>
{cacheSize > 0 ? formatBytes(cacheSize) : '暂无缓存'}
</span>
<button
className="btn btn-sm about-check-btn"
onClick={handleClearCache}
disabled={clearingCache || cacheSize === 0}
>
{clearingCache ? '清理中...' : '立即清理'}
</button>
</div>
</div>
</div>
</div>
{/* 授权信息 */}
<div className="settings-section">
<h2></h2>
<div className="card" style={{ padding: 'var(--spacing-xl)' }}>
<p style={{ lineHeight: 1.8, margin: 0 }}>
使
support@meijiaka.cn
</p>
</div>
</div>
{/* 版权声明 */}
<div className="settings-section">
<h2></h2>
<div className="card" style={{ padding: 'var(--spacing-xl)' }}>
<p style={{ lineHeight: 1.8, margin: 0 }}>
Copyright 2025 (meijiaka.cn). All rights reserved.
</p>
<p style={{ lineHeight: 1.8, marginTop: 'var(--spacing-md)', marginBottom: 0 }}>
使
</p>
</div>
</div>
<EnvironmentSwitchModal
key={appEnvironment}
open={showEnvModal}
currentEnv={appEnvironment}
onSave={handleSaveEnv}
onCancel={() => setShowEnvModal(false)}
/>
</div>
);
}
@@ -1,189 +0,0 @@
/**
*
* ============
*
*
*/
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 [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 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);
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);
}
};
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 (
<div className="settings-page">
<div className="settings-section">
<h2></h2>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{/* 当前版本 */}
<div className="settings-row">
<span className="settings-row-label"></span>
<span className="settings-row-value">v{CURRENT_VERSION}</span>
</div>
{/* 检查更新 */}
<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' }}>
<path d="M21 12a9 9 0 11-6.219-8.56" />
</svg>
...
</span>
) : checkResult === 'latest' ? (
<span style={{ color: 'var(--success)', fontWeight: 500 }}></span>
) : checkResult === 'available' ? (
<span style={{ color: 'var(--primary)', fontWeight: 500 }}> {updateInfo?.version}</span>
) : null}
<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>
);
}
+281 -16
View File
@@ -5,13 +5,13 @@
* 完全遵循字幕压制页面结构和间距保持一致
*/
/* 布局 - 55:45 和字幕压制一致 */
/* 布局 - 40:60 */
.step-layout.subtitle-burning {
grid-template-columns: 55fr 45fr;
}
.step-layout.cover-design-variant {
grid-template-columns: 55fr 45fr;
grid-template-columns: 40fr 60fr;
}
/* 左侧操作区 - 与视频生成页面统一 */
@@ -187,19 +187,6 @@
font-weight: 600;
}
/* 生成按钮 */
.cover-generate-btn {
width: 100%;
height: 48px;
font-size: var(--font-md);
font-weight: 600;
margin-top: var(--spacing-md);
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
}
/* 右侧预览区 - 与视频生成页面统一 */
.video-gen-right {
display: flex;
@@ -378,7 +365,7 @@
min-height: 64px;
resize: none;
line-height: 1.5;
padding: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-lg);
}
/* 智能生成按钮 */
@@ -390,6 +377,284 @@
flex-shrink: 0;
}
/* 视觉素材横向卡片 */
.visual-assets-row {
display: flex;
gap: var(--spacing-2xl);
justify-content: center;
flex-wrap: wrap;
}
.visual-asset-card {
flex: 0 0 auto;
width: 200px;
max-width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
border-radius: 14px;
background: #fff;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.visual-asset-card:hover {
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.14);
transform: translateY(-2px);
}
.visual-asset-card:hover .visual-asset-overlay {
opacity: 1;
}
.visual-asset-image-wrap {
position: relative;
width: 100%;
aspect-ratio: 9 / 16;
overflow: hidden;
border-radius: 10px;
margin: 8px;
width: calc(100% - 16px);
}
.visual-asset-card img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 8px;
transition: transform 0.3s ease;
}
.visual-asset-card:hover img {
transform: scale(1.03);
}
.visual-asset-label {
position: absolute;
top: 8px;
left: 8px;
font-size: 10px;
font-weight: 600;
color: #fff;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
padding: 3px 8px;
border-radius: 6px;
letter-spacing: 0.4px;
pointer-events: none;
z-index: 2;
}
.visual-asset-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.35);
opacity: 0;
transition: opacity 0.2s ease;
border-radius: 8px;
pointer-events: none;
}
.visual-asset-overlay-text {
color: #fff;
font-size: 13px;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
}
.visual-asset-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--text-tertiary);
font-size: var(--font-xs);
background: var(--bg-secondary);
border-radius: 8px;
}
/* .visual-asset-hint removed - using hover overlay instead */
/* 弹窗背景图网格 */
.modal-bg-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-sm);
}
.modal-bg-thumb {
position: relative;
padding: 0;
border: 2px solid transparent;
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
background: var(--bg-secondary);
aspect-ratio: 9 / 16;
transition: all var(--transition-fast);
}
.modal-bg-thumb:hover {
border-color: var(--border-color);
transform: scale(1.02);
}
.modal-bg-thumb.active {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
/* 选中态勾选标记 */
.modal-bg-thumb.active::after {
content: '';
position: absolute;
top: var(--spacing-sm);
right: var(--spacing-sm);
width: 20px;
height: 20px;
background: var(--primary);
border-radius: var(--radius-full);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
box-shadow: var(--shadow-sm);
}
.modal-bg-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 弹窗封面形象网格 */
.modal-avatar-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-sm);
}
.modal-avatar-thumb {
position: relative;
padding: 0;
border: 2px solid transparent;
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
background: var(--bg-secondary);
aspect-ratio: 9 / 16;
transition: all var(--transition-fast);
}
.modal-avatar-thumb:hover {
border-color: var(--border-color);
transform: scale(1.02);
}
.modal-avatar-thumb.active {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
.modal-avatar-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #999;
}
/* 加载失败 */
.modal-bg-error {
background: var(--bg-tertiary);
}
.modal-bg-error::after {
content: '加载失败';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-xs);
color: var(--text-tertiary);
pointer-events: none;
}
/* 本地上传卡片 */
.modal-bg-upload {
border-style: dashed;
border-color: var(--border-color);
background: transparent;
}
.modal-bg-upload:hover {
border-style: solid;
border-color: var(--primary);
background: var(--primary-light);
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
width: 100%;
height: 100%;
color: var(--text-secondary);
font-size: var(--font-sm);
pointer-events: none;
}
.upload-placeholder svg {
color: var(--text-tertiary);
transition: color var(--transition-fast);
}
.modal-bg-upload:hover .upload-placeholder {
color: var(--primary);
}
.modal-bg-upload:hover .upload-placeholder svg {
color: var(--primary);
}
.avatar-thumb-error {
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-thumb-error::after {
content: '加载失败';
font-size: 10px;
color: #999;
}
/* 背景图片区域头部(标签 + 按钮) */
.bg-section-header {
display: flex;

Some files were not shown because too many files have changed in this diff Show More